feat(coding-agent): add startup changelog display modes

(cherry picked from commit bed594fecd1eae1917f3f047f883da5ba83317f9)
This commit is contained in:
Wolfgang Schoenberger
2026-07-27 20:17:14 -07:00
committed by can1357
parent 091f670ea0
commit a4dc5a094a
7 changed files with 262 additions and 33 deletions
+3
View File
@@ -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
@@ -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,
@@ -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<string, unknown>) : 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<string, unknown>;
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<string, unknown>).timeout === "number") {
const oldValue = (raw.ask as Record<string, unknown>).timeout as number;
+24 -11
View File
@@ -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<string | undefined>,
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<string | undefined> {
async function getChangelogForDisplay(
parsed: Args,
mode: SettingValue<"startup.changelogMode">,
): Promise<StartupChangelogSelection | undefined> {
if (parsed.continue || parsed.resume || isForeignSessionImport(parsed)) {
return undefined;
}
@@ -694,6 +699,13 @@ async function getChangelogForDisplay(parsed: Args): Promise<string | undefined>
// 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<string | undefined>
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,
@@ -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());
}
}
+105 -4
View File
@@ -28,6 +28,101 @@ export interface StartupChangelogSelection {
persistCurrentVersion: boolean;
truncated: boolean;
selectedEntries: number;
totalUnseenEntries: number;
latestVersion: string | undefined;
changeCount: number;
categoryCounts: Record<string, number>;
}
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<string, number>;
} {
const categoryCounts: Record<string, number> = {};
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,
};
}
@@ -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<T>(callback: (agentDir: string) => Promise<T>): Promise<T> {
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);