Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 39 additions & 1 deletion src/changelog/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
parseChangelogText,
parseVersionString,
resolveChangelogPath,
stampVersionAfterStartup,
} from "./index.js";

const SAMPLE = `# Changelog
Expand Down Expand Up @@ -96,7 +97,7 @@ describe("decideStartupChangelog", () => {
expect(d.kind).toBe("first_install");
});

test("upgrade shows notes and stamps package version", () => {
test("upgrade yields notes with stampVersion for when shown", () => {
const d = decideStartupChangelog({
entries,
lastChangelogVersion: "0.2.85",
Expand All @@ -122,6 +123,43 @@ describe("decideStartupChangelog", () => {
});
});

describe("stampVersionAfterStartup", () => {
const entries = parseChangelogText(SAMPLE);

test("first_install always stamps (quiet, no history dump)", () => {
const d = decideStartupChangelog({
entries,
lastChangelogVersion: undefined,
packageVersion: "0.2.86",
});
expect(stampVersionAfterStartup(d, false)).toBe("0.2.86");
expect(stampVersionAfterStartup(d, true)).toBe("0.2.86");
});

test("upgrade stamps only when notes were shown (CL-5475)", () => {
const d = decideStartupChangelog({
entries,
lastChangelogVersion: "0.2.85",
packageVersion: "0.2.86",
});
expect(d.kind).toBe("upgrade");
// Dead surface / OpenTUI gap: do not consume notes without display.
expect(stampVersionAfterStartup(d, false)).toBeNull();
// When a surface restores and actually shows markdown, stamp.
expect(stampVersionAfterStartup(d, true)).toBe("0.2.86");
});

test("current never stamps", () => {
const d = decideStartupChangelog({
entries,
lastChangelogVersion: "0.2.86",
packageVersion: "0.2.86",
});
expect(stampVersionAfterStartup(d, false)).toBeNull();
expect(stampVersionAfterStartup(d, true)).toBeNull();
});
});

describe("formatStartupChangelog", () => {
test("caps entry count and marks truncated", () => {
const entries = parseChangelogText(SAMPLE);
Expand Down
23 changes: 22 additions & 1 deletion src/changelog/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -163,8 +163,11 @@ export type ChangelogDisplayDecision =
/**
* 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.
* - New versioned sections after watermark → upgrade notes (stamp only once actually shown).
* - Otherwise quiet.
*
* Persistence of the watermark is separate: see {@link stampVersionAfterStartup}.
* Callers must not stamp upgrade notes unless they rendered them (CL-5475).
*/
export function decideStartupChangelog(input: {
entries: ChangelogEntry[];
Expand Down Expand Up @@ -201,6 +204,24 @@ export function decideStartupChangelog(input: {
};
}

/**
* Version to persist as `lastChangelogVersion` after this interactive start, or
* `null` to leave the watermark alone.
*
* - first_install: always stamp (quiet; never dump history on later launches).
* - upgrade: stamp only when `notesShown` is true. A dead surface must not
* consume notes by stamping without display (CL-5475).
* - current: no write.
*/
export function stampVersionAfterStartup(
decision: ChangelogDisplayDecision,
notesShown: boolean,
): string | null {
if (decision.kind === "first_install") return decision.stampVersion;
if (decision.kind === "upgrade" && notesShown) return decision.stampVersion;
return null;
}

/**
* Resolve CHANGELOG.md for runtime: package root (dev / npm), then next to the
* executable (binary install), then cwd.
Expand Down
3 changes: 2 additions & 1 deletion src/config/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,8 @@ export type Settings = {
// 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.
// interactive install). Upgrade stamps only after notes are actually shown
// so a missing surface cannot silently swallow them (CL-5475).
lastChangelogVersion?: string;
// Controls the context-compaction strategy used when the context window fills.
// "llm" (default) generates a structured handoff summary via LLM call.
Expand Down
37 changes: 16 additions & 21 deletions src/tui/runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,10 @@ import { captureSlashCommand } from "../telemetry/product-events.js";
import { getTelemetry, liveTelemetry, setTelemetry } from "../telemetry/singleton.js";
import { createTelemetryToggleHandler } from "../telemetry/toggle.js";

import { loadStartupChangelogMarkdown } from "../changelog/index.js";
import {
loadStartupChangelogMarkdown,
stampVersionAfterStartup,
} from "../changelog/index.js";
import { scheduleUpgradeNotice } from "../upgrade/index.js";
import pkg from "../../package.json" with { type: "json" };
import { seedPricingMetadataFromCache } from "../cost/pricing-metadata.js";
Expand Down Expand Up @@ -1831,30 +1834,22 @@ export async function runTUI(initialConfig: Config): Promise<number> {
});
}

// 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.
// Post-upgrade release notes watermark policy (CL-5475):
// - first_install: stamp quietly so later launches do not dump history.
// - upgrade: stamp only when notes were actually shown. The former Ink
// whats-new banner is gone on the OpenTUI path, so notesShown is false
// until a surface is restored — never silently consume upgrade notes.
// - resume / current: leave the watermark alone.
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 notesShown = false;
const stampVersion = stampVersionAfterStartup(changelogDecision, notesShown);
if (stampVersion !== null) {
void markLastChangelogVersion(trueGlobalSettingsPath, stampVersion).catch(() => {
// Best-effort watermark.
});
}

const commandContext: CommandContext = {
Expand Down
Loading