diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 6859f52d6..c6e270fe0 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -77,6 +77,9 @@ - Fixed omp worktree clear prematurely deleting active task-isolation sandboxes owned by running subagents. - Fixed /vibe mode preventing the director from completing parent tasks after verifying worker results by keeping the built-in todo tool active. - Fixed numeric GitHub issue and pull request autocomplete being suppressed inside skill slash-command arguments. +### Changed + +- Startup release notes now default to a compact change-count summary. Use `startup.changelogMode` (`summary` | `expanded` | `hidden`) to control them; legacy `collapseChangelog` choices migrate automatically. ## [17.1.7] - 2026-07-27 diff --git a/packages/coding-agent/src/config/settings-schema.ts b/packages/coding-agent/src/config/settings-schema.ts index cf0156dd8..12ac2fbd6 100644 --- a/packages/coding-agent/src/config/settings-schema.ts +++ b/packages/coding-agent/src/config/settings-schema.ts @@ -1811,17 +1811,42 @@ export const SETTINGS_SCHEMA = { }, }, - collapseChangelog: { - type: "boolean", - default: false, + "startup.changelogMode": { + type: "enum", + values: ["summary", "expanded", "hidden"] as const, + default: "summary", ui: { tab: "interaction", group: "Startup & Updates", - label: "Collapse Changelog", - description: "Show condensed changelog after updates", + label: "Startup Changelog", + description: "Choose whether update notes start as a summary, full details, or stay hidden", + options: [ + { + value: "summary", + label: "Summary", + description: "Show release and change counts with a /changelog hint", + }, + { + value: "expanded", + label: "Expanded", + description: "Show the recent release notes in full", + }, + { + value: "hidden", + label: "Hidden", + description: "Do not show release notes on startup", + }, + ], }, }, + // Legacy startup changelog boolean kept only as migration input for + // startup.changelogMode; it is intentionally hidden from the settings UI. + collapseChangelog: { + type: "boolean", + default: undefined, + }, + "magicKeywords.enabled": { type: "boolean", default: true, diff --git a/packages/coding-agent/src/config/settings.ts b/packages/coding-agent/src/config/settings.ts index d8e391096..fe10e861b 100644 --- a/packages/coding-agent/src/config/settings.ts +++ b/packages/coding-agent/src/config/settings.ts @@ -1346,6 +1346,31 @@ export class Settings { } delete raw.lastChangelogVersion; + // collapseChangelog (boolean) -> startup.changelogMode (enum). Preserve + // every explicit legacy choice while giving new installs the schema's + // "summary" default: true -> summary, false -> expanded. A separately + // configured new mode always wins. + const startupObj = isRecord(raw.startup) ? (raw.startup as Record) : undefined; + const legacyCollapseChangelog = typeof raw.collapseChangelog === "boolean" ? raw.collapseChangelog : undefined; + const flatChangelogMode = raw["startup.changelogMode"]; + const normalizedFlatChangelogMode = + flatChangelogMode === "summary" || flatChangelogMode === "expanded" || flatChangelogMode === "hidden" + ? flatChangelogMode + : undefined; + if (legacyCollapseChangelog !== undefined || normalizedFlatChangelogMode !== undefined) { + if (!startupObj) { + raw.startup = {}; + } + const target = raw.startup as Record; + if (target.changelogMode === undefined) { + target.changelogMode = + normalizedFlatChangelogMode ?? + (legacyCollapseChangelog !== undefined ? (legacyCollapseChangelog ? "summary" : "expanded") : undefined); + } + } + delete raw.collapseChangelog; + delete raw["startup.changelogMode"]; + // ask.timeout: ms -> seconds (if value > 1000, it's old ms format) if (raw.ask && typeof (raw.ask as Record).timeout === "number") { const oldValue = (raw.ask as Record).timeout as number; diff --git a/packages/coding-agent/src/main.ts b/packages/coding-agent/src/main.ts index 7b344e040..2bf71da19 100644 --- a/packages/coding-agent/src/main.ts +++ b/packages/coding-agent/src/main.ts @@ -41,7 +41,7 @@ import { type ScopedModel, } from "./config/model-resolver"; import { ModelsConfigFile } from "./config/models-config"; -import { getDefault, type SettingPath, Settings, settings } from "./config/settings"; +import { getDefault, type SettingPath, Settings, type SettingValue, settings } from "./config/settings"; import { initializeWithSettings } from "./discovery"; import { clearPluginRootsAndCaches, @@ -91,10 +91,12 @@ import { createTelemetryExportConfig, initTelemetryExport, isTelemetryExportEnab import { concreteThinkingLevel, parseConfiguredThinkingLevel } from "./thinking"; import type { LspStartupServerInfo } from "./tools"; import { + compareVersions, getChangelogPath, parseChangelog, parseChangelogVersion, readLastChangelogVersion, + type StartupChangelogSelection, selectStartupChangelog, writeLastChangelogVersion, } from "./utils/changelog"; @@ -415,7 +417,7 @@ export function createAcpSessionFactory(args: AcpSessionFactoryOptions): AcpSess async function runInteractiveMode( session: AgentSession, version: string, - changelogMarkdown: string | undefined, + startupChangelog: StartupChangelogSelection | undefined, notifs: (InteractiveModeNotify | null)[], versionCheckPromise: Promise, initialMessages: string[], @@ -433,7 +435,7 @@ async function runInteractiveMode( const mode = new InteractiveMode( session, version, - changelogMarkdown, + startupChangelog, setExtensionUIContext, lspServers, mcpManager, @@ -679,7 +681,10 @@ async function resolveScopedModels( ); } -async function getChangelogForDisplay(parsed: Args): Promise { +async function getChangelogForDisplay( + parsed: Args, + mode: SettingValue<"startup.changelogMode">, +): Promise { if (parsed.continue || parsed.resume || isForeignSessionImport(parsed)) { return undefined; } @@ -694,6 +699,13 @@ async function getChangelogForDisplay(parsed: Args): Promise // Steady state: user already saw the current version's changelog. Skip the file read + parse. return undefined; } + if (mode === "hidden") { + const currentVersion = parseChangelogVersion(VERSION); + if (currentVersion && compareVersions(currentVersion, parsedLastVersion) > 0) { + await writeLastChangelogVersion(VERSION); + } + return undefined; + } const changelogPath = getChangelogPath(); const entries = await parseChangelog(changelogPath); @@ -701,11 +713,7 @@ async function getChangelogForDisplay(parsed: Args): Promise if (startupChangelog.persistCurrentVersion) { await writeLastChangelogVersion(VERSION); } - if (startupChangelog.markdown) { - return startupChangelog.markdown; - } - - return undefined; + return startupChangelog.markdown ? startupChangelog : undefined; } const SESSION_ID_ARG_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; @@ -1667,7 +1675,12 @@ export async function runRootCommand( await runRpcMode(session, mode === "rpc-ui" ? setToolUIContext : undefined, eventBus, rpcInput); } else if (isInteractive) { const versionCheckPromise = checkForNewVersion(VERSION).catch(() => undefined); - const changelogMarkdown = await logger.time("main:getChangelogForDisplay", getChangelogForDisplay, parsedArgs); + const startupChangelog = await logger.time( + "main:getChangelogForDisplay", + getChangelogForDisplay, + parsedArgs, + settingsInstance.get("startup.changelogMode"), + ); const modelScopeNotification = buildModelScopeNotification( scopedModels, @@ -1692,7 +1705,7 @@ export async function runRootCommand( await runInteractiveMode( session, VERSION, - changelogMarkdown, + startupChangelog, notifs, versionCheckPromise, initialArgs.messages, diff --git a/packages/coding-agent/src/modes/interactive-mode.ts b/packages/coding-agent/src/modes/interactive-mode.ts index be28c0f27..68c1180a4 100644 --- a/packages/coding-agent/src/modes/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive-mode.ts @@ -128,6 +128,7 @@ import { } from "../tools/todo"; import { vocalizer } from "../tts/vocalizer"; import { renderTreeList } from "../tui/tree-list"; +import { formatStartupChangelogSummary, type StartupChangelogSelection } from "../utils/changelog"; import { copyToClipboard } from "../utils/clipboard"; import type { EventBus } from "../utils/event-bus"; import { getEditorCommand, openInEditor } from "../utils/external-editor"; @@ -556,7 +557,7 @@ export class InteractiveMode implements InteractiveModeContext { #cleanupUnsubscribe?: () => void; #signalTeardown?: SessionTeardown; readonly #version: string; - readonly #changelogMarkdown: string | undefined; + readonly #startupChangelog: StartupChangelogSelection | undefined; #planModePreviousTools: string[] | undefined; #goalModePreviousTools: string[] | undefined; #vibeModePreviousTools: string[] | undefined; @@ -664,7 +665,7 @@ export class InteractiveMode implements InteractiveModeContext { constructor( session: AgentSession, version: string, - changelogMarkdown: string | undefined = undefined, + startupChangelog: StartupChangelogSelection | undefined = undefined, setToolUIContext: (uiContext: ExtensionUIContext, hasUI: boolean) => void = () => {}, lspServers: LspStartupServerInfo[] | undefined = undefined, mcpManager?: MCPManager, @@ -676,7 +677,7 @@ export class InteractiveMode implements InteractiveModeContext { this.keybindings = KeybindingsManager.inMemory(); this.agent = session.agent; this.#version = version; - this.#changelogMarkdown = changelogMarkdown; + this.#startupChangelog = startupChangelog; this.#toolUiContextSetter = setToolUIContext; this.lspServers = lspServers; this.mcpManager = mcpManager; @@ -943,19 +944,20 @@ export class InteractiveMode implements InteractiveModeContext { } // Add changelog if provided - if (this.#changelogMarkdown) { + if (this.#startupChangelog && settings.get("startup.changelogMode") !== "hidden") { this.ui.addChild(new DynamicBorder()); - if (settings.get("collapseChangelog")) { - const versionMatch = this.#changelogMarkdown.match(/##\s+\[?(\d+\.\d+\.\d+)\]?/); - const latestVersion = versionMatch ? versionMatch[1] : this.#version; - const condensedText = `Updated to v${latestVersion}. Use ${theme.bold("/changelog")} to view full changelog.`; - this.ui.addChild(new Text(condensedText, 1, 0)); + this.ui.addChild(new Text(theme.bold(theme.fg("accent", "What's New")), 1, 0)); + this.ui.addChild(new Spacer(1)); + if (settings.get("startup.changelogMode") === "summary") { + const summary = formatStartupChangelogSummary(this.#startupChangelog).replace( + /\/changelog(?: full)?/g, + command => theme.bold(command), + ); + this.ui.addChild(new Text(summary, 1, 0)); } else { - this.ui.addChild(new Text(theme.bold(theme.fg("accent", "What's New")), 1, 0)); - this.ui.addChild(new Spacer(1)); - this.ui.addChild(new Markdown(this.#changelogMarkdown.trim(), 1, 0, getMarkdownTheme())); - this.ui.addChild(new Spacer(1)); + this.ui.addChild(new Markdown(this.#startupChangelog.markdown?.trim() ?? "", 1, 0, getMarkdownTheme())); } + this.ui.addChild(new Spacer(1)); this.ui.addChild(new DynamicBorder()); } } diff --git a/packages/coding-agent/src/utils/changelog.ts b/packages/coding-agent/src/utils/changelog.ts index b018fda00..86735df7c 100644 --- a/packages/coding-agent/src/utils/changelog.ts +++ b/packages/coding-agent/src/utils/changelog.ts @@ -28,6 +28,101 @@ export interface StartupChangelogSelection { persistCurrentVersion: boolean; truncated: boolean; selectedEntries: number; + totalUnseenEntries: number; + latestVersion: string | undefined; + changeCount: number; + categoryCounts: Record; +} + +const CHANGELOG_CATEGORY_ORDER = [ + "Breaking Changes", + "Added", + "Changed", + "Deprecated", + "Removed", + "Fixed", + "Security", +] as const; + +function emptyStartupSelection(persistCurrentVersion: boolean): StartupChangelogSelection { + return { + markdown: undefined, + persistCurrentVersion, + truncated: false, + selectedEntries: 0, + totalUnseenEntries: 0, + latestVersion: undefined, + changeCount: 0, + categoryCounts: {}, + }; +} + +function summarizeChangelogEntries(entries: readonly ChangelogEntry[]): { + changeCount: number; + categoryCounts: Record; +} { + const categoryCounts: Record = {}; + let changeCount = 0; + + for (const entry of entries) { + let category: string | undefined; + for (const line of entry.content.split("\n")) { + const heading = line.match(/^###\s+(.+?)\s*$/); + if (heading) { + category = heading[1]; + continue; + } + if (!category || !/^-\s+\S/.test(line)) continue; + categoryCounts[category] = (categoryCounts[category] ?? 0) + 1; + changeCount++; + } + } + + return { changeCount, categoryCounts }; +} + +function categoryLabel(category: string, count: number): string { + if (category === "Breaking Changes") { + return count === 1 ? "breaking change" : "breaking changes"; + } + return category.toLowerCase(); +} + +/** Format the compact, deterministic startup update notice. */ +export function formatStartupChangelogSummary(selection: StartupChangelogSelection): string { + const latestVersion = selection.latestVersion; + if (!latestVersion || selection.selectedEntries === 0) { + return "Updated omp. Use /changelog for recent changes."; + } + + const releaseCount = selection.selectedEntries; + const changeCount = selection.changeCount; + const releaseWord = releaseCount === 1 ? "release" : "releases"; + const changeWord = changeCount === 1 ? "change" : "changes"; + const firstLine = + releaseCount === 1 + ? `Updated to v${latestVersion} · ${changeCount} ${changeWord} in 1 release` + : `Updated to v${latestVersion} · ${changeCount} ${changeWord} across ${releaseCount} ${releaseWord}`; + + const orderedCategories = [ + ...CHANGELOG_CATEGORY_ORDER.filter(category => selection.categoryCounts[category]), + ...Object.keys(selection.categoryCounts) + .filter(category => !(CHANGELOG_CATEGORY_ORDER as readonly string[]).includes(category)) + .sort(), + ]; + const breakdown = orderedCategories + .map( + category => + `${selection.categoryCounts[category]} ${categoryLabel(category, selection.categoryCounts[category])}`, + ) + .join(" · "); + const omittedReleases = selection.totalUnseenEntries - selection.selectedEntries; + const detailHint = + omittedReleases > 0 + ? `+${omittedReleases} earlier ${omittedReleases === 1 ? "release" : "releases"} · Use /changelog full for history.` + : "Use /changelog for details."; + + return breakdown ? `${firstLine}\n${breakdown} · ${detailHint}` : `${firstLine}\n${detailHint}`; } /** @@ -177,16 +272,17 @@ export function selectStartupChangelog( ): StartupChangelogSelection { const parsedLastVersion = parseChangelogVersion(lastVersion); if (!parsedLastVersion) { - return { markdown: undefined, persistCurrentVersion: true, truncated: false, selectedEntries: 0 }; + return emptyStartupSelection(true); } const markerVersion = lastVersion ?? ""; if (markerVersion === currentVersion) { - return { markdown: undefined, persistCurrentVersion: false, truncated: false, selectedEntries: 0 }; + return emptyStartupSelection(false); } - const newEntries = getNewEntries(entries, markerVersion).slice(0, RECENT_CHANGELOG_ENTRY_LIMIT); + const allNewEntries = getNewEntries(entries, markerVersion); + const newEntries = allNewEntries.slice(0, RECENT_CHANGELOG_ENTRY_LIMIT); if (newEntries.length === 0) { - return { markdown: undefined, persistCurrentVersion: false, truncated: false, selectedEntries: 0 }; + return emptyStartupSelection(false); } const rendered = renderChangelogEntries(newEntries, { @@ -194,11 +290,16 @@ export function selectStartupChangelog( truncationHint: STARTUP_CHANGELOG_FULL_HINT, oldestFirst: false, }); + const summary = summarizeChangelogEntries(newEntries); + const latestEntry = newEntries[0]; return { markdown: rendered.markdown, persistCurrentVersion: true, truncated: rendered.truncated, selectedEntries: newEntries.length, + totalUnseenEntries: allNewEntries.length, + latestVersion: latestEntry ? `${latestEntry.major}.${latestEntry.minor}.${latestEntry.patch}` : undefined, + ...summary, }; } diff --git a/packages/coding-agent/test/utils/changelog.test.ts b/packages/coding-agent/test/utils/changelog.test.ts index 3e45195e8..3d333064a 100644 --- a/packages/coding-agent/test/utils/changelog.test.ts +++ b/packages/coding-agent/test/utils/changelog.test.ts @@ -15,8 +15,10 @@ import * as fs from "node:fs/promises"; import * as os from "node:os"; import * as path from "node:path"; import { removeWithRetries, VERSION } from "@oh-my-pi/pi-utils"; +import { Settings } from "../../src/config/settings"; import { type ChangelogEntry, + formatStartupChangelogSummary, getNewEntries, parseChangelog, RECENT_CHANGELOG_ENTRY_LIMIT, @@ -44,6 +46,25 @@ function release(major: number, minor: number, patch: number, body: string): Cha return { major, minor, patch, content }; } +describe("startup changelog mode settings", () => { + test("defaults to a summary", () => { + expect(Settings.isolated().get("startup.changelogMode")).toBe("summary"); + }); + + test("preserves legacy collapsed and expanded choices", () => { + expect(Settings.isolated({ collapseChangelog: true }).get("startup.changelogMode")).toBe("summary"); + expect(Settings.isolated({ collapseChangelog: false }).get("startup.changelogMode")).toBe("expanded"); + }); + + test("an explicit new mode wins over the legacy boolean", () => { + const settings = Settings.isolated({ + collapseChangelog: false, + "startup.changelogMode": "hidden", + }); + expect(settings.get("startup.changelogMode")).toBe("hidden"); + }); +}); + async function withTempAgentDir(callback: (agentDir: string) => Promise): Promise { const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "omp-changelog-marker-")); try { @@ -106,6 +127,10 @@ describe("selectStartupChangelog", () => { expect(selection.persistCurrentVersion).toBe(true); expect(selection.truncated).toBe(false); expect(selection.selectedEntries).toBe(RECENT_CHANGELOG_ENTRY_LIMIT); + expect(selection.totalUnseenEntries).toBe(5); + expect(selection.latestVersion).toBe("1.0.5"); + expect(selection.changeCount).toBe(3); + expect(selection.categoryCounts).toEqual({ Added: 3 }); expect(selection.markdown?.match(/## \[(\d+\.\d+\.\d+)\]/)?.[1]).toBe("1.0.5"); expect(selection.markdown).toContain("## [1.0.5]"); expect(selection.markdown).toContain("## [1.0.4]"); @@ -153,6 +178,41 @@ describe("selectStartupChangelog", () => { }); }); +describe("formatStartupChangelogSummary", () => { + test("summarizes selected releases and points to omitted history", () => { + const selection = selectStartupChangelog( + [ + release(2, 0, 0, "### Added\n\n- First addition.\n- Second addition.\n\n### Fixed\n\n- A fix."), + release(1, 9, 0, "### Changed\n\n- A behavior change."), + release(1, 8, 0, "### Security\n\n- A security improvement."), + release(1, 7, 0, "### Fixed\n\n- An earlier fix."), + release(1, 6, 0, "### Added\n\n- Already seen."), + ], + "1.6.0", + "2.0.0", + ); + + expect(formatStartupChangelogSummary(selection)).toBe( + [ + "Updated to v2.0.0 · 5 changes across 3 releases", + "2 added · 1 changed · 1 fixed · 1 security · +1 earlier release · Use /changelog full for history.", + ].join("\n"), + ); + }); + + test("uses the recent-details hint when every unseen release is represented", () => { + const selection = selectStartupChangelog( + [release(2, 0, 0, "### Breaking Changes\n\n- Removed the old wire format.")], + "1.0.0", + "2.0.0", + ); + + expect(formatStartupChangelogSummary(selection)).toBe( + ["Updated to v2.0.0 · 1 change in 1 release", "1 breaking change · Use /changelog for details."].join("\n"), + ); + }); +}); + describe("parseChangelog", () => { test("reads current source release data and filters versions newer than the previous release", async () => { const entries = await parseChangelog(undefined);