diff --git a/CHANGELOG.md b/CHANGELOG.md index 70b55db9c..cc10ba073 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ Patch-ready tip on `main` after the PerfTrace / Codex measurement stack and rela - **Latency eval harness** — assert phase presence and relative magnitudes in tests (`assert-spans`, multi-tool fixture). (CL-5174, #310) - **Reasoning effort by agent role** — orchestrator vs task-leaf defaults so high-effort leaves stop multiplying wall time. (CL-5162, #302) - **Session state under `~/.corbits/projects`** — project key from git toplevel (worktrees share); dual-read migrate from in-repo `.agent-state`; path-restriction exception for the global state root. (CL-5257, #313) +- **Post-upgrade release notes** — on a fresh interactive start after upgrade, show bounded Keep-a-Changelog sections in the session banner; stamp `lastChangelogVersion` in global settings; first install is quiet; `/changelog` and `/changelog full` for on-demand history. Ships `CHANGELOG.md` next to release binaries. (CL-5333, CL-5332, CL-5334) - **Streaming stall / loop detection** — trailing-window repetition detection; preserve partial streamed output in exec and TUI; partial-capture lifecycle owned by the cycle recorder. (#280, #281) - **Nested UI polish** — quieter chrome, context meter, task/shell rows, observe-leave behavior. (#312) - **Approval queue re-eval** — when a grant widens, re-check the pending queue; stored approvals evaluated through `@intx/authz`. (#288, #295) @@ -45,7 +46,6 @@ Patch-ready tip on `main` after the PerfTrace / Codex measurement stack and rela ### Planned -- What's-new banner on interactive start after upgrade (CL-4604 — **canceled** as a ticket; still not implemented; see note below) - Local context estimate for compaction when providers omit usage (CL-4345) - Image age → rehydratable attachment URI (CL-4349) - Always-return subagent salvage without a default wall-clock death clock (CL-4401) diff --git a/scripts/release.sh b/scripts/release.sh index 1e5fb39d4..e2f8e02d5 100755 --- a/scripts/release.sh +++ b/scripts/release.sh @@ -35,7 +35,7 @@ TAP_REPO="corbitsdev/homebrew-tap" # tap repo (formula) TAP_SLUG="corbitsdev/tap" # `brew tap' name of TAP_REPO FORMULA="corbits" # formula / binary name DESC="Single-process coding agent CLI built on the Interchange runtime" -DOC_FILES=(LICENSE.md README.md GPLv2-AI-Exception.md GPL-2.0.txt) # shipped with the binary +DOC_FILES=(LICENSE.md README.md CHANGELOG.md GPLv2-AI-Exception.md GPL-2.0.txt) # shipped with the binary # Build matrix: "label|bun-target|kind|deb-arch". kind is macos or linux; # deb-arch is the Debian architecture for linux targets, "-" for macOS. diff --git a/src/changelog/index.test.ts b/src/changelog/index.test.ts new file mode 100644 index 000000000..58ed74d9f --- /dev/null +++ b/src/changelog/index.test.ts @@ -0,0 +1,163 @@ +import { describe, expect, test } from "bun:test"; +import { mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { + compareVersions, + decideStartupChangelog, + formatStartupChangelog, + getNewEntries, + parseChangelog, + parseChangelogText, + parseVersionString, + resolveChangelogPath, +} from "./index.js"; + +const SAMPLE = `# Changelog + +## [Unreleased] + +### New Features +- not a release + +## [0.2.86] - 2026-07-30 + +### New Features +- Feature A + +## [0.2.85] - 2026-07-20 + +### Fixed +- Bug B + +## [0.1.0] - 2026-01-01 + +### New Features +- Initial +`; + +describe("parseChangelogText", () => { + test("parses versioned sections and skips Unreleased", () => { + const entries = parseChangelogText(SAMPLE); + expect(entries.map((e) => `${e.major}.${e.minor}.${e.patch}`)).toEqual([ + "0.2.86", + "0.2.85", + "0.1.0", + ]); + expect(entries[0]!.content).toContain("## [0.2.86]"); + expect(entries[0]!.content).toContain("Feature A"); + expect(entries.every((e) => !e.content.includes("Unreleased"))).toBe(true); + }); + + test("accepts unbracketed version headers", () => { + const entries = parseChangelogText("## 1.2.3\n\n- note\n"); + expect(entries).toHaveLength(1); + expect(entries[0]!.major).toBe(1); + expect(entries[0]!.minor).toBe(2); + expect(entries[0]!.patch).toBe(3); + }); +}); + +describe("compareVersions / getNewEntries", () => { + test("orders major.minor.patch", () => { + const a = parseVersionString("0.2.86")!; + const b = parseVersionString("0.2.85")!; + expect(compareVersions(a, b)).toBeGreaterThan(0); + expect(compareVersions(b, a)).toBeLessThan(0); + expect(compareVersions(a, a)).toBe(0); + }); + + test("returns only newer entries", () => { + const entries = parseChangelogText(SAMPLE); + const newer = getNewEntries(entries, "0.2.85"); + expect(newer.map((e) => `${e.major}.${e.minor}.${e.patch}`)).toEqual(["0.2.86"]); + }); +}); + +describe("decideStartupChangelog", () => { + const entries = parseChangelogText(SAMPLE); + + test("missing watermark is first install — stamp, no history", () => { + const d = decideStartupChangelog({ + entries, + lastChangelogVersion: undefined, + packageVersion: "0.2.86", + }); + expect(d).toEqual({ kind: "first_install", stampVersion: "0.2.86" }); + }); + + test("malformed watermark is first install", () => { + const d = decideStartupChangelog({ + entries, + lastChangelogVersion: "not-a-version", + packageVersion: "0.2.86", + }); + expect(d.kind).toBe("first_install"); + }); + + test("upgrade shows notes and stamps package version", () => { + const d = decideStartupChangelog({ + entries, + lastChangelogVersion: "0.2.85", + packageVersion: "0.2.86", + }); + expect(d.kind).toBe("upgrade"); + if (d.kind === "upgrade") { + expect(d.markdown).toContain("0.2.86"); + expect(d.markdown).toContain("Feature A"); + expect(d.markdown).not.toContain("0.1.0"); + expect(d.stampVersion).toBe("0.2.86"); + expect(d.versions).toContain("0.2.86"); + } + }); + + test("current version is quiet", () => { + const d = decideStartupChangelog({ + entries, + lastChangelogVersion: "0.2.86", + packageVersion: "0.2.86", + }); + expect(d).toEqual({ kind: "current" }); + }); +}); + +describe("formatStartupChangelog", () => { + test("caps entry count and marks truncated", () => { + const entries = parseChangelogText(SAMPLE); + const formatted = formatStartupChangelog(entries, { maxEntries: 1 }); + expect(formatted.versions).toEqual(["0.2.86"]); + expect(formatted.truncated).toBe(true); + expect(formatted.markdown).toContain("/changelog"); + }); + + test("caps byte size", () => { + const big = parseChangelogText( + `## [9.0.0]\n\n${"x".repeat(200)}\n\n## [8.0.0]\n\n${"y".repeat(200)}\n`, + ); + const formatted = formatStartupChangelog(big, { maxEntries: 5, maxBytes: 120 }); + expect(Buffer.byteLength(formatted.markdown, "utf8")).toBeLessThanOrEqual(120); + expect(formatted.truncated).toBe(true); + }); +}); + +describe("parseChangelog file + resolveChangelogPath", () => { + test("missing file yields empty", () => { + expect(parseChangelog("/no/such/CHANGELOG.md")).toEqual([]); + }); + + test("reads a real file; resolve prefers package then cwd", () => { + const dir = mkdtempSync(join(tmpdir(), "corbits-changelog-")); + const path = join(dir, "CHANGELOG.md"); + writeFileSync(path, SAMPLE, "utf8"); + expect(parseChangelog(path)).toHaveLength(3); + // Package-root candidates win when the worktree has CHANGELOG.md; when they + // do not exist, cwd is used. + const resolved = resolveChangelogPath({ + cwd: dir, + execPath: "/nonexistent/bin/corbits", + moduleUrl: `file://${join(dir, "src", "changelog", "index.ts")}`, + }); + expect(resolved).toBe(path); + }); +}); diff --git a/src/changelog/index.ts b/src/changelog/index.ts new file mode 100644 index 000000000..61e0a498d --- /dev/null +++ b/src/changelog/index.ts @@ -0,0 +1,254 @@ +import { existsSync, readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +export type ChangelogEntry = { + major: number; + minor: number; + patch: number; + content: string; +}; + +export function entryVersion(entry: ChangelogEntry): string { + return `${entry.major}.${entry.minor}.${entry.patch}`; +} + +/** + * Parse Keep-a-Changelog version sections from a CHANGELOG.md body. + * Only `## [x.y.z]` (or unbracketed `## x.y.z`) headers become entries; + * `## [Unreleased]` and other non-semver headers are skipped. + */ +export function parseChangelogText(content: string): ChangelogEntry[] { + const lines = content.split("\n"); + const entries: ChangelogEntry[] = []; + + let currentLines: string[] = []; + let currentVersion: { major: number; minor: number; patch: number } | null = null; + + const flush = (): void => { + if (currentVersion !== null && currentLines.length > 0) { + entries.push({ + ...currentVersion, + content: currentLines.join("\n").trim(), + }); + } + }; + + for (const line of lines) { + if (line.startsWith("## ")) { + flush(); + const versionMatch = line.match(/##\s+\[?(\d+)\.(\d+)\.(\d+)\]?/); + if (versionMatch !== null) { + currentVersion = { + major: Number.parseInt(versionMatch[1]!, 10), + minor: Number.parseInt(versionMatch[2]!, 10), + patch: Number.parseInt(versionMatch[3]!, 10), + }; + currentLines = [line]; + } else { + currentVersion = null; + currentLines = []; + } + } else if (currentVersion !== null) { + currentLines.push(line); + } + } + flush(); + return entries; +} + +export function parseChangelog(changelogPath: string): ChangelogEntry[] { + if (!existsSync(changelogPath)) return []; + try { + return parseChangelogText(readFileSync(changelogPath, "utf-8")); + } catch { + return []; + } +} + +/** -1 if a < b, 0 if equal, 1 if a > b (semver-ish major.minor.patch only). */ +export function compareVersions(a: ChangelogEntry, b: ChangelogEntry): number { + if (a.major !== b.major) return a.major - b.major; + if (a.minor !== b.minor) return a.minor - b.minor; + return a.patch - b.patch; +} + +export function parseVersionString(version: string): ChangelogEntry | null { + const match = version.trim().replace(/^v/i, "").match(/^(\d+)\.(\d+)\.(\d+)/); + if (match === null) return null; + return { + major: Number.parseInt(match[1]!, 10), + minor: Number.parseInt(match[2]!, 10), + patch: Number.parseInt(match[3]!, 10), + content: "", + }; +} + +/** Entries strictly newer than lastVersion (newest-first if the file is newest-first). */ +export function getNewEntries(entries: ChangelogEntry[], lastVersion: string): ChangelogEntry[] { + const last = parseVersionString(lastVersion); + if (last === null) return []; + return entries.filter((entry) => compareVersions(entry, last) > 0); +} + +export const DEFAULT_STARTUP_ENTRY_LIMIT = 3; +export const DEFAULT_STARTUP_MARKDOWN_BYTES = 64 * 1024; + +export type StartupChangelogResult = { + markdown: string; + truncated: boolean; + versions: string[]; +}; + +/** + * Bound automatic startup markdown: newest-first, at most `maxEntries` sections, + * hard cap `maxBytes` with a trailing hint when truncated. + */ +export function formatStartupChangelog( + entries: ChangelogEntry[], + opts?: { maxEntries?: number; maxBytes?: number; fullHint?: string }, +): StartupChangelogResult { + const maxEntries = opts?.maxEntries ?? DEFAULT_STARTUP_ENTRY_LIMIT; + const maxBytes = opts?.maxBytes ?? DEFAULT_STARTUP_MARKDOWN_BYTES; + const fullHint = opts?.fullHint ?? "Run /changelog full for complete history."; + const hintBlock = `\n\n_${fullHint}_`; + const hintBytes = Buffer.byteLength(hintBlock, "utf8"); + + const selected = entries.slice(0, maxEntries); + const versions = selected.map(entryVersion); + if (selected.length === 0) { + return { markdown: "", truncated: false, versions }; + } + + let truncated = entries.length > selected.length; + const bodyBudget = Math.max(32, maxBytes - hintBytes); + const kept: string[] = []; + + for (const entry of selected) { + const piece = entry.content; + const candidate = kept.length === 0 ? piece : `${kept.join("\n\n")}\n\n${piece}`; + if (Buffer.byteLength(candidate, "utf8") <= bodyBudget) { + kept.push(piece); + continue; + } + if (kept.length === 0) { + // Single section larger than the budget: hard-cut the body so the + // watermark path never dumps unbounded markdown into the banner. + const raw = Buffer.from(piece, "utf8").subarray(0, bodyBudget).toString("utf8"); + kept.push(raw.replace(/\uFFFD$/, "").trimEnd() + "…"); + } + truncated = true; + break; + } + if (kept.length < selected.length) truncated = true; + + let markdown = kept.join("\n\n"); + if (truncated) { + markdown = `${markdown}${hintBlock}`; + } + // Final hard cap if hint + body still overshoots (pathological tiny maxBytes). + if (Buffer.byteLength(markdown, "utf8") > maxBytes) { + const cut = Buffer.from(markdown, "utf8").subarray(0, maxBytes).toString("utf8"); + markdown = cut.replace(/\uFFFD$/, "").trimEnd(); + truncated = true; + } + return { markdown, truncated, versions: kept.length > 0 ? versions.slice(0, kept.length) : versions }; +} + +export type ChangelogDisplayDecision = + | { kind: "first_install"; stampVersion: string } + | { kind: "upgrade"; markdown: string; stampVersion: string; versions: string[] } + | { kind: "current"; stampVersion?: undefined }; + +/** + * Decide what to show on interactive start. + * - Missing/empty/malformed watermark → first install: stamp package version, no history dump. + * - New versioned sections after watermark → upgrade notes + stamp package version. + * - Otherwise quiet. + */ +export function decideStartupChangelog(input: { + entries: ChangelogEntry[]; + lastChangelogVersion: string | undefined; + packageVersion: string; + maxEntries?: number; + maxBytes?: number; +}): ChangelogDisplayDecision { + const pkg = parseVersionString(input.packageVersion); + const stampVersion = pkg !== null ? entryVersion(pkg) : input.packageVersion.trim(); + + const last = input.lastChangelogVersion?.trim() ?? ""; + if (last.length === 0 || parseVersionString(last) === null) { + return { kind: "first_install", stampVersion }; + } + + const newer = getNewEntries(input.entries, last); + if (newer.length === 0) { + return { kind: "current" }; + } + + const formatted = formatStartupChangelog(newer, { + ...(input.maxEntries !== undefined ? { maxEntries: input.maxEntries } : {}), + ...(input.maxBytes !== undefined ? { maxBytes: input.maxBytes } : {}), + }); + if (formatted.markdown.length === 0) { + return { kind: "first_install", stampVersion }; + } + return { + kind: "upgrade", + markdown: formatted.markdown, + stampVersion, + versions: formatted.versions, + }; +} + +/** + * Resolve CHANGELOG.md for runtime: package root (dev / npm), then next to the + * executable (binary install), then cwd. + */ +export function resolveChangelogPath(opts?: { + moduleUrl?: string; + execPath?: string; + cwd?: string; +}): string | undefined { + const candidates: string[] = []; + const moduleUrl = opts?.moduleUrl ?? import.meta.url; + try { + const here = dirname(fileURLToPath(moduleUrl)); + // src/changelog → repo root; dist/changelog → package root + candidates.push(join(here, "..", "..", "CHANGELOG.md")); + candidates.push(join(here, "..", "CHANGELOG.md")); + } catch { + // ignore + } + const execPath = opts?.execPath ?? process.execPath; + if (execPath.length > 0) { + candidates.push(join(dirname(execPath), "CHANGELOG.md")); + candidates.push(join(dirname(execPath), "..", "share", "doc", "corbits", "CHANGELOG.md")); + candidates.push(join(dirname(execPath), "..", "share", "doc", "corbits-code", "CHANGELOG.md")); + } + const cwd = opts?.cwd ?? process.cwd(); + candidates.push(join(cwd, "CHANGELOG.md")); + + for (const path of candidates) { + if (existsSync(path)) return path; + } + return undefined; +} + +export function loadStartupChangelogMarkdown(input: { + lastChangelogVersion: string | undefined; + packageVersion: string; + changelogPath?: string; + maxEntries?: number; + maxBytes?: number; +}): ChangelogDisplayDecision { + const path = input.changelogPath ?? resolveChangelogPath(); + const entries = path !== undefined ? parseChangelog(path) : []; + return decideStartupChangelog({ + entries, + lastChangelogVersion: input.lastChangelogVersion, + packageVersion: input.packageVersion, + ...(input.maxEntries !== undefined ? { maxEntries: input.maxEntries } : {}), + ...(input.maxBytes !== undefined ? { maxBytes: input.maxBytes } : {}), + }); +} diff --git a/src/config/settings.ts b/src/config/settings.ts index 3629582c3..e448e7d84 100644 --- a/src/config/settings.ts +++ b/src/config/settings.ts @@ -86,6 +86,9 @@ export type Settings = { // Set after the first launch's welcome animation + provider modal has been // shown. Controls whether subsequent launches show "Welcome to" vs "Welcome back". onboarded?: boolean; + // Last package version whose release notes were shown (or stamped on first + // interactive install). Drives the one-shot post-upgrade notes banner. + lastChangelogVersion?: string; // Controls the context-compaction strategy used when the context window fills. // "llm" (default) generates a structured handoff summary via LLM call. // "pruning" uses fast deterministic pruning with no LLM call. @@ -378,6 +381,7 @@ const SettingsSchema = type({ "web?": "string", "hiddenCommands?": "string[]", "onboarded?": "boolean", + "lastChangelogVersion?": "string", "compactionMode?": "'llm' | 'pruning'", "maxConcurrentSubAgents?": "number", "subagentMaxTurns?": "number", @@ -552,6 +556,7 @@ export const GLOBAL_SETTINGS_OPTIONAL_KEYS = [ "web", "hiddenCommands", "onboarded", + "lastChangelogVersion", "compactionMode", "maxConcurrentSubAgents", "subagentMaxTurns", @@ -614,6 +619,10 @@ export async function loadSettings(path: string): Promise { web: s.web as string | undefined, hiddenCommands: s.hiddenCommands as string[] | undefined, onboarded: s.onboarded !== undefined ? Boolean(s.onboarded) : undefined, + lastChangelogVersion: + typeof s.lastChangelogVersion === "string" && s.lastChangelogVersion.trim().length > 0 + ? s.lastChangelogVersion.trim() + : undefined, compactionMode: s.compactionMode === "llm" || s.compactionMode === "pruning" ? s.compactionMode : undefined, maxConcurrentSubAgents: @@ -726,6 +735,16 @@ export async function markOnboarded(path: string): Promise { await saveGlobalSettings(path, { ...base, onboarded: true }); } +/** Persist the package version whose release notes were last shown (or stamped on first install). */ +export async function markLastChangelogVersion(path: string, version: string): Promise { + const trimmed = version.trim(); + if (trimmed.length === 0) return; + const onDisk = await loadSettings(path); + const base: Settings = onDisk ?? { providers: {} }; + if (base.lastChangelogVersion === trimmed) return; + await saveGlobalSettings(path, { ...base, lastChangelogVersion: trimmed }); +} + // Ensure a persisted telemetry installationId exists, generating and saving // one on first use. Reads the on-disk global settings fresh (same rationale // as markOnboarded: never trust an in-memory Settings that may carry injected diff --git a/src/settings.test.ts b/src/settings.test.ts index 61a736ecd..64f315f9a 100644 --- a/src/settings.test.ts +++ b/src/settings.test.ts @@ -24,6 +24,7 @@ import { validateTaskMaxTurns, toolWatchdogFromSettings, loadGlobalSettingsWriteBase, + markLastChangelogVersion, } from "./config/settings.js"; const firepass: Settings = { @@ -475,6 +476,55 @@ describe("maxConcurrentSubAgents", () => { }); }); +describe("lastChangelogVersion", () => { + test("isSettings accepts a version string", () => { + expect( + isSettings({ + providers: firepass.providers, + lastChangelogVersion: "0.2.86", + }), + ).toBe(true); + }); + + test("loadSettings round-trips lastChangelogVersion", async () => { + const dir = await mkdtemp(join(tmpdir(), "ic-settings-")); + try { + const path = join(dir, ".corbits", "settings.json"); + await saveGlobalSettings(path, { ...firepass, lastChangelogVersion: "0.2.85" }); + expect(await loadSettings(path)).toEqual({ ...firepass, lastChangelogVersion: "0.2.85" }); + } finally { + await rm(dir, { recursive: true, force: true }); + } + }); + + test("markLastChangelogVersion stamps without clobbering other fields", async () => { + const dir = await mkdtemp(join(tmpdir(), "ic-settings-")); + try { + const path = join(dir, ".corbits", "settings.json"); + await saveGlobalSettings(path, { ...firepass, onboarded: true }); + await markLastChangelogVersion(path, "0.2.86"); + const loaded = await loadSettings(path); + expect(loaded?.lastChangelogVersion).toBe("0.2.86"); + expect(loaded?.onboarded).toBe(true); + expect(loaded?.defaultProvider).toBe("firepass"); + } finally { + await rm(dir, { recursive: true, force: true }); + } + }); + + test("markLastChangelogVersion ignores empty versions", async () => { + const dir = await mkdtemp(join(tmpdir(), "ic-settings-")); + try { + const path = join(dir, ".corbits", "settings.json"); + await saveGlobalSettings(path, firepass); + await markLastChangelogVersion(path, " "); + expect(await loadSettings(path)).toEqual(firepass); + } finally { + await rm(dir, { recursive: true, force: true }); + } + }); +}); + describe("subagentMaxTurns", () => { test("defaults to 30 when unset", () => { expect(resolveDefaultSubAgentMaxTurns(null)).toBe(DEFAULT_SUBAGENT_MAX_TURNS); diff --git a/src/tui/app.tsx b/src/tui/app.tsx index d6d1bf4bb..9e14aae27 100644 --- a/src/tui/app.tsx +++ b/src/tui/app.tsx @@ -200,6 +200,8 @@ export type AppProps = { /** One-line passive notice shown once in the top-of-scrollback banner on * the first run telemetry is active. Undefined/empty renders nothing. */ telemetryNotice?: string; + /** Markdown release notes shown once after upgrade in the session banner. */ + whatsNewMarkdown?: string; /** Whether anonymous telemetry is currently enabled, for the settings toggle. */ telemetryEnabled?: boolean; /** Persists the settings Telemetry on|off toggle to global settings. */ @@ -269,6 +271,7 @@ export function App({ subAgentSessions, goalApi, telemetryNotice, + whatsNewMarkdown, telemetryEnabled = false, onChangeTelemetryEnabled, waitForApproval: waitForApprovalProp, @@ -610,6 +613,7 @@ export function App({ activePlugins, cwd, telemetryNotice, + whatsNewMarkdown, enteredSession, }); diff --git a/src/tui/commands/built-in.ts b/src/tui/commands/built-in.ts index b21b01e9c..a5ee7f470 100644 --- a/src/tui/commands/built-in.ts +++ b/src/tui/commands/built-in.ts @@ -2,6 +2,11 @@ import { registerCommand } from "./registry.js"; import { PROVIDER_TIERS, type ProviderTier, type TierConfig } from "../../config/settings.js"; import { formatGoalStatus, type GoalSetOpts } from "../../agent/goal.js"; import { formatCostCommandOutput } from "../../cost/cost-summary.js"; +import { + formatStartupChangelog, + parseChangelog, + resolveChangelogPath, +} from "../../changelog/index.js"; // Which tiers are currently assigned. Defaults to empty so /fast, /standard, // /clever stay out of the slash menu until the user configures one; app.tsx @@ -208,6 +213,38 @@ registerCommand({ }, }); +registerCommand({ + name: "changelog", + description: "Show recent release notes (or full history)", + argumentHint: "[full]", + subcommands: [{ name: "full", description: "Show the complete changelog" }], + handler: (args) => { + const path = resolveChangelogPath(); + if (path === undefined) { + return { + type: "message", + text: "CHANGELOG.md not found next to the install or package root.", + }; + } + const entries = parseChangelog(path); + if (entries.length === 0) { + return { type: "message", text: "No versioned release notes found in CHANGELOG.md." }; + } + const wantFull = args.trim().toLowerCase() === "full"; + if (wantFull) { + return { + type: "message", + text: entries.map((e) => e.content).join("\n\n"), + }; + } + const formatted = formatStartupChangelog(entries, { + maxEntries: 5, + fullHint: "Run /changelog full for complete history.", + }); + return { type: "message", text: formatted.markdown }; + }, +}); + // Session-scoped goal: keep working until a verifiable condition is met. // See docs/plans/v0.3-goal-mode.md. registerCommand({ diff --git a/src/tui/components/event-log-assembly.ts b/src/tui/components/event-log-assembly.ts index 6bafde96f..5b2619ee3 100644 --- a/src/tui/components/event-log-assembly.ts +++ b/src/tui/components/event-log-assembly.ts @@ -282,6 +282,7 @@ export function buildResourceBanner( width: number, cwd: string, telemetryNotice?: string, + whatsNewMarkdown?: string, ): StyledLine[] { const lines: StyledLine[] = [ [{ text: SESSION_BRAND, bold: true, color: color("brand") }], @@ -300,6 +301,11 @@ export function buildResourceBanner( lines.push([]); lines.push(...plainLines(telemetryNotice, { color: color("muted"), dim: true }, width)); } + if (whatsNewMarkdown !== undefined && whatsNewMarkdown.length > 0) { + lines.push([]); + lines.push([{ text: "[What's new]", color: color("brand") }]); + lines.push(...markdownLines(whatsNewMarkdown, width)); + } lines.push([]); return lines; } diff --git a/src/tui/components/event-log.test.ts b/src/tui/components/event-log.test.ts index 534081546..387ec112b 100644 --- a/src/tui/components/event-log.test.ts +++ b/src/tui/components/event-log.test.ts @@ -739,6 +739,27 @@ describe("flat line buffer", () => { } }); + test("buildResourceBanner shows What's new when release notes markdown is provided", () => { + const banner = buildResourceBanner( + [], + [], + 80, + "/tmp/ws", + undefined, + "## [0.2.86]\n\n- Feature A", + ); + const text = banner.map((line) => line.map((s) => s.text).join("")).join("\n"); + expect(text).toContain("[What's new]"); + expect(text).toContain("0.2.86"); + expect(text).toContain("Feature A"); + }); + + test("buildResourceBanner omits What's new when markdown is empty", () => { + const banner = buildResourceBanner([], [], 80, "/tmp/ws", undefined, ""); + const text = banner.map((line) => line.map((s) => s.text).join("")).join("\n"); + expect(text).not.toContain("[What's new]"); + }); + const planBlock: ContentBlock = { type: "plan", id: "plan-1", diff --git a/src/tui/hooks/use-transcript-layout.ts b/src/tui/hooks/use-transcript-layout.ts index bbb3fde54..52f64a8e4 100644 --- a/src/tui/hooks/use-transcript-layout.ts +++ b/src/tui/hooks/use-transcript-layout.ts @@ -39,6 +39,7 @@ export type UseTranscriptLayoutArgs = { activePlugins: readonly string[] | undefined; cwd: string; telemetryNotice: string | undefined; + whatsNewMarkdown: string | undefined; enteredSession: SubAgentSession | undefined; }; @@ -67,6 +68,7 @@ export function useTranscriptLayout({ activePlugins, cwd, telemetryNotice, + whatsNewMarkdown, enteredSession, }: UseTranscriptLayoutArgs): TranscriptLayoutController { // Cleared when layout width or thinking expand change — those affect all blocks. @@ -136,8 +138,16 @@ export function useTranscriptLayout({ ); const resourceBanner = useMemo( - () => buildResourceBanner(loadedSkills ?? [], activePlugins ?? [], contentWidth, cwd, telemetryNotice), - [loadedSkills, activePlugins, contentWidth, cwd, telemetryNotice], + () => + buildResourceBanner( + loadedSkills ?? [], + activePlugins ?? [], + contentWidth, + cwd, + telemetryNotice, + whatsNewMarkdown, + ), + [loadedSkills, activePlugins, contentWidth, cwd, telemetryNotice, whatsNewMarkdown], ); const prefixLineCount = diff --git a/src/tui/runner.tsx b/src/tui/runner.tsx index 5b9912b82..da13bf0ba 100644 --- a/src/tui/runner.tsx +++ b/src/tui/runner.tsx @@ -27,6 +27,7 @@ import { saveLocalSettings, shellTimeoutFromSettings, toolWatchdogFromSettings, + markLastChangelogVersion, type Settings, type LocalSettings, type PluginConfig, @@ -65,6 +66,8 @@ import { activateHeldTelemetry, telemetryFirstRunPending } from "../telemetry/fi import { TELEMETRY_NOTICE } from "../telemetry/index.js"; import { getTelemetry, setTelemetry } from "../telemetry/singleton.js"; import { createTelemetryToggleHandler } from "../telemetry/toggle.js"; +import { loadStartupChangelogMarkdown } from "../changelog/index.js"; +import pkg from "../../package.json" with { type: "json" }; import { seedPricingMetadataFromCache } from "../cost/pricing-metadata.js"; import { defaultPricingCachePath } from "../cost/pricing-fetcher.js"; import { @@ -1306,6 +1309,32 @@ export async function runTUI(initialConfig: Config): Promise { }); } + // Post-upgrade release notes: one-shot banner on a fresh interactive session. + // Resume skips the banner and does not stamp, so the next fresh session still + // surfaces notes. First install stamps without dumping history. + const changelogDecision = loadStartupChangelogMarkdown({ + lastChangelogVersion: globalSettingsForOnboarding?.lastChangelogVersion, + packageVersion: typeof pkg.version === "string" ? pkg.version : "0.0.0", + }); + let whatsNewMarkdown: string | undefined; + if (changelogDecision.kind === "upgrade" && !resumeSkipInitialTask) { + whatsNewMarkdown = changelogDecision.markdown; + void markLastChangelogVersion(trueGlobalSettingsPath, changelogDecision.stampVersion).catch( + () => { + // Best-effort watermark; worst case notes reappear next launch. + }, + ); + } else if (changelogDecision.kind === "first_install") { + void markLastChangelogVersion(trueGlobalSettingsPath, changelogDecision.stampVersion).catch( + () => { + // Best-effort watermark. + }, + ); + } else if (changelogDecision.kind === "upgrade" && resumeSkipInitialTask) { + // Resume with pending notes: leave watermark alone so a future fresh + // session can show them. + } + const exitAltScreen = enterAltScreen(); // Strip SGR mouse sequences before Ink's parser broadcasts input to every @@ -1334,6 +1363,7 @@ export async function runTUI(initialConfig: Config): Promise { globalOnboardingPath={trueGlobalSettingsPath} globallyOnboarded={globallyOnboarded} {...(telemetryNotice !== undefined ? { telemetryNotice } : {})} + {...(whatsNewMarkdown !== undefined ? { whatsNewMarkdown } : {})} telemetryEnabled={liveTelemetryIntent} onChangeTelemetryEnabled={(enabled) => { liveTelemetryIntent = enabled;