Skip to content

Commit bde2ec7

Browse files
authored
fix(cli): show the background update notice inside the TUI (#379)
## Requirement or Bug After a background auto-update, the "Pythinker Code updated to vX" notice never shows in fullscreen mode. ## Bug Reproduction Steps 1. Set `tui_mode = "fullscreen"` and `[upgrade] auto_install = true` in `tui.toml`. 2. Launch an older native build. The background check installs the new version with no message. 3. Launch again. `install.json` records `notifiedAt`, but the screen shows only the welcome banner. ## Root Cause `showPendingBackgroundInstallNotice` wrote the notice to stdout before the TUI started. Fullscreen mode enters the alternate screen and clears it (`\x1b[?1049h` + `\x1b[2J`), so the line was erased right after it was written, and `notifiedAt` was already set so it never came back. This is a fundamental fix: the notice now goes through the TUI's existing startup-notice path. ## Code Changes ```diff handleMainCommand (shell mode) - runUpdatePreflight(version, { track }) // notice -> stdout + runUpdatePreflight(version, { track, onUpdateNotice }) // notice -> variable + if result === 'exit': write the notice to stdout, then exit - runShell(options, version) + runShell(options, version, { updateNotice }) PythinkerTUI({ startupNotice: combineStartupNotice(updateNotice, configWarning) }) finishStartup -> showStatus(startupNotice) // transcript, survives fullscreen ``` - `preflight.ts`: new optional `onUpdateNotice` option. Without it the notice still goes to stdout. - `main.ts`: shell mode collects the notice and passes it to `runShell`. Print mode passes no callback, so it is unchanged. - `run-shell.ts`: merges the update notice with the existing `tui.toml` parse warning. ## Behavior Changes and Affected Users | Behavior | Before | After | Who relies on the old behavior | Escape hatch | |---|---|---|---|---| | Update notice, TUI fullscreen | Written to stdout, then erased by the alt screen | Shown in the transcript | Nobody (never visible) | n/a | | Update notice, TUI inline (`tui_mode = "regular"`) | Plain stdout line above the TUI | Dim status line in the transcript | Users who read it in scrollback | none; the text is the same | | Update notice when preflight returns `exit` | stdout | stdout (written just before exit) | Same population | n/a | | Update notice, print mode / non-TTY | Not shown (`isTTY: false`) | Not shown | Headless scripts | n/a | | `runUpdatePreflight` with no `onUpdateNotice` | stdout | stdout | Any other caller | n/a | Affected module: `apps/pythinker-code` CLI startup. Test coverage: - `preflight.test.ts`: notice goes to `onUpdateNotice` and not stdout; the existing stdout test still passes. - `main.test.ts`: shell mode hand-off to `runShell`; the `exit` path prints the notice; print mode is unchanged. - `run-shell.test.ts`: `updateNotice` becomes the TUI `startupNotice`. - `pythinker-tui-startup.test.ts`: in fullscreen, the startup notice renders in the transcript. - Live run: `pnpm dev:cli` in fullscreen with a pending `lastSuccess` shows "Pythinker Code updated to v2.7.0" and the changelog link in the transcript. ## Checklist - [x] I have read the [CONTRIBUTING](https://github.com/PyModel/pythinker-code/blob/main/CONTRIBUTING.md) document. - [ ] I have linked a related issue (external PRs: issue must have a maintainer's `/approve`). - [x] I have added tests that prove my feature works. - [x] The behavior-change table above is complete, and every removed behavior or flipped default is named in the changeset and either has an escape hatch or was explicitly approved by a maintainer in this PR. - [x] Ran `gen-changesets` skill, or this PR needs no changeset. - [x] Ran `gen-docs` skill, or this PR needs no doc update. <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Background update completion notices now appear in the TUI startup message. If the app exits before the TUI opens, the notice is shown in the terminal instead. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
1 parent 75b9548 commit bde2ec7

8 files changed

Lines changed: 141 additions & 11 deletions

File tree

‎.changeset/tui-update-notice.md‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@pymodel/pythinker-code": patch
3+
---
4+
5+
Show the "Pythinker Code updated" notice inside the TUI after a background update, so fullscreen mode no longer hides it.

‎apps/pythinker-code/src/cli/run-shell.ts‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import type { TuiConfig } from '#/tui/config';
2525
import { loadTuiConfig, TuiConfigParseError } from '#/tui/config';
2626
import { CHROME_GUTTER } from '#/tui/constant/rendering';
2727
import { PythinkerTUI } from '#/tui/index';
28+
import { combineStartupNotice } from '#/tui/utils/startup';
2829
import { startupTrace } from '#/utils/startup-trace';
2930
import { currentTheme, getColorPalette } from '#/tui/theme';
3031
import { toTerminalHyperlink } from '#/utils/terminal-hyperlink';
@@ -39,7 +40,7 @@ import { createPythinkerCodeHostIdentity } from './version';
3940
export async function runShell(
4041
opts: CLIOptions,
4142
version: string,
42-
runOptions: { readonly migrateOnly?: boolean } = {},
43+
runOptions: { readonly migrateOnly?: boolean; readonly updateNotice?: string } = {},
4344
): Promise<void> {
4445
const startedAt = Date.now();
4546
const configStartedAt = startedAt;
@@ -139,7 +140,7 @@ export async function runShell(
139140
tuiConfig,
140141
version,
141142
workDir,
142-
startupNotice: configWarning,
143+
startupNotice: combineStartupNotice(runOptions.updateNotice, configWarning),
143144
migrationPlan,
144145
migrateOnly: runOptions.migrateOnly,
145146
telemetryDisabled: config.telemetry === false,

‎apps/pythinker-code/src/cli/update/preflight.ts‎

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ export interface RunUpdatePreflightOptions {
4848
readonly isTTY?: boolean;
4949
readonly track?: (event: string, properties?: TelemetryProperties) => void;
5050
readonly logger?: UpdateLogger;
51+
readonly onUpdateNotice?: (notice: string) => void;
5152
}
5253

5354
const AUTO_INSTALL_FAILURE_PROMPT_THRESHOLD = 2;
@@ -434,13 +435,13 @@ async function hasInstallInFlight(
434435
async function showPendingBackgroundInstallNotice(
435436
state: UpdateInstallState,
436437
currentVersion: string,
437-
stdout: { write(chunk: string): boolean },
438+
emitNotice: (notice: string) => void,
438439
track: RunUpdatePreflightOptions['track'],
439440
logger: UpdateLogger,
440441
): Promise<UpdateInstallState> {
441442
const success = state.lastSuccess;
442443
if (success !== null && success.notifiedAt === null && success.version === currentVersion) {
443-
stdout.write(renderBackgroundInstallSuccessNotice(success.version));
444+
emitNotice(renderBackgroundInstallSuccessNotice(success.version));
444445
trackUpdateEvent(track, 'update_success_notice_shown', {
445446
version: success.version,
446447
inferred_from_active: false,
@@ -469,7 +470,7 @@ async function showPendingBackgroundInstallNotice(
469470
}
470471

471472
const notifiedAt = nowIso();
472-
stdout.write(renderBackgroundInstallSuccessNotice(active.version));
473+
emitNotice(renderBackgroundInstallSuccessNotice(active.version));
473474
trackUpdateEvent(track, 'update_success_notice_shown', {
474475
version: active.version,
475476
inferred_from_active: true,
@@ -804,7 +805,7 @@ export async function runUpdatePreflight(
804805
installState = await showPendingBackgroundInstallNotice(
805806
installState,
806807
currentVersion,
807-
stdout,
808+
options.onUpdateNotice ?? ((notice) => { stdout.write(notice); }),
808809
options.track,
809810
logger,
810811
);

‎apps/pythinker-code/src/main.ts‎

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -75,12 +75,18 @@ export async function handleMainCommand(
7575
}
7676

7777
startupTrace('preflight:begin');
78+
// The TUI clears the screen on start, so a notice written to stdout here
79+
// would vanish; hand it to the TUI to show in the transcript instead.
80+
let updateNotice: string | undefined;
7881
const preflightResult = await runUpdatePreflight(
7982
version,
80-
validated.uiMode === 'print' ? { track, isTTY: false } : { track },
83+
validated.uiMode === 'print'
84+
? { track, isTTY: false }
85+
: { track, onUpdateNotice: (notice) => { updateNotice = notice.trimEnd(); } },
8186
);
8287
startupTrace('preflight:end');
8388
if (preflightResult === 'exit') {
89+
if (updateNotice !== undefined) process.stdout.write(`${updateNotice}\n`);
8490
process.exit(0);
8591
}
8692

@@ -90,7 +96,7 @@ export async function handleMainCommand(
9096
}
9197

9298
startupTrace('runShell:begin');
93-
await runShell(validated.options, version);
99+
await runShell(validated.options, version, { updateNotice });
94100
return { headlessCompleted: false };
95101
}
96102

‎apps/pythinker-code/test/cli/main.test.ts‎

Lines changed: 46 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -252,11 +252,32 @@ describe('main entry command handling', () => {
252252

253253
expect(exitCode).toBeNull();
254254
expect(validateOptions).toHaveBeenCalledWith(opts);
255-
expect(runUpdatePreflight).toHaveBeenCalledWith('0.0.1-alpha.2', { track: expect.any(Function) });
255+
expect(runUpdatePreflight).toHaveBeenCalledWith('0.0.1-alpha.2', {
256+
track: expect.any(Function),
257+
onUpdateNotice: expect.any(Function),
258+
});
256259
expect(mocks.runUpdatePreflight.mock.invocationCallOrder[0]).toBeLessThan(
257260
mocks.runShell.mock.invocationCallOrder[0]!,
258261
);
259-
expect(runShell).toHaveBeenCalledWith(opts, '0.0.1-alpha.2');
262+
expect(runShell).toHaveBeenCalledWith(opts, '0.0.1-alpha.2', { updateNotice: undefined });
263+
});
264+
265+
it('passes the update notice from preflight to the shell', async () => {
266+
const opts = defaultOpts();
267+
mocks.validateOptions.mockReturnValue({ options: opts, uiMode: 'shell' });
268+
mocks.runUpdatePreflight.mockImplementation(
269+
async (_version: string, options: { onUpdateNotice?: (notice: string) => void }) => {
270+
options.onUpdateNotice?.('Pythinker Code updated to v0.0.2\nChangelog: https://example.test\n');
271+
return 'continue';
272+
},
273+
);
274+
mocks.runShell.mockResolvedValue(void 0);
275+
276+
await runHandleMainCommand(opts);
277+
278+
expect(runShell).toHaveBeenCalledWith(opts, '0.0.1-alpha.2', {
279+
updateNotice: 'Pythinker Code updated to v0.0.2\nChangelog: https://example.test',
280+
});
260281
});
261282

262283
it('runs prompt mode without interactive update preflight', async () => {
@@ -365,8 +386,9 @@ describe('main entry command handling', () => {
365386
expect(exitCode).toBeNull();
366387
expect(runUpdatePreflight).toHaveBeenCalledWith('0.0.1-alpha.2', {
367388
track: expect.any(Function),
389+
onUpdateNotice: expect.any(Function),
368390
});
369-
expect(runShell).toHaveBeenCalledWith(opts, '0.0.1-alpha.2');
391+
expect(runShell).toHaveBeenCalledWith(opts, '0.0.1-alpha.2', { updateNotice: undefined });
370392
});
371393

372394
it('installs crash handlers before parsing CLI arguments', async () => {
@@ -433,6 +455,27 @@ describe('main entry command handling', () => {
433455
expect(runShell).not.toHaveBeenCalled();
434456
});
435457

458+
it('prints the update notice to stdout when preflight requests process exit', async () => {
459+
const opts = defaultOpts();
460+
mocks.validateOptions.mockReturnValue({ options: opts, uiMode: 'shell' });
461+
mocks.runUpdatePreflight.mockImplementation(
462+
async (_version: string, options: { onUpdateNotice?: (notice: string) => void }) => {
463+
options.onUpdateNotice?.('Pythinker Code updated to v0.0.2\n');
464+
return 'exit';
465+
},
466+
);
467+
const writeSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true);
468+
try {
469+
const exitCode = await runHandleMainCommand(opts);
470+
471+
expect(exitCode).toBe(0);
472+
expect(writeSpy).toHaveBeenCalledWith('Pythinker Code updated to v0.0.2\n');
473+
expect(runShell).not.toHaveBeenCalled();
474+
} finally {
475+
writeSpy.mockRestore();
476+
}
477+
});
478+
436479
it('initializes and flushes telemetry around the upgrade command', async () => {
437480
const exitCode = await runHandleUpgradeCommand();
438481

‎apps/pythinker-code/test/cli/run-shell.test.ts‎

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -653,6 +653,38 @@ describe('runShell', () => {
653653
});
654654
});
655655

656+
it('passes the update notice to the TUI as a startup notice', async () => {
657+
mocks.loadTuiConfig.mockResolvedValue({
658+
theme: 'dark',
659+
editorCommand: null,
660+
notifications: { enabled: true, condition: 'unfocused' },
661+
});
662+
mocks.tuiStart.mockResolvedValue(undefined);
663+
664+
await runShell(
665+
{
666+
session: '',
667+
continue: false,
668+
yolo: false,
669+
auto: false,
670+
plan: false,
671+
model: undefined,
672+
outputFormat: undefined,
673+
prompt: undefined,
674+
skillsDirs: [],
675+
agent: undefined,
676+
agentFiles: [],
677+
},
678+
'1.2.3-test',
679+
{ updateNotice: 'Pythinker Code updated to v1.2.3-test' },
680+
);
681+
682+
const [, , startupInput] = mocks.pythinkerTuiConstructor.mock.calls[0]!;
683+
expect(startupInput).toMatchObject({
684+
startupNotice: 'Pythinker Code updated to v1.2.3-test',
685+
});
686+
});
687+
656688
it('flushes diagnostic logs synchronously before exiting on a runtime crash', async () => {
657689
mocks.loadTuiConfig.mockResolvedValue({
658690
theme: 'dark',

‎apps/pythinker-code/test/cli/update/preflight.test.ts‎

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -975,6 +975,32 @@ describe('runUpdatePreflight', () => {
975975
expect(detectInstallSource).not.toHaveBeenCalled();
976976
});
977977

978+
it('hands the background update success notice to onUpdateNotice instead of stdout', async () => {
979+
mocks.readUpdateCache.mockResolvedValue(emptyUpdateCache());
980+
mocks.readUpdateInstallState.mockResolvedValue(installState({
981+
lastSuccess: {
982+
version: '0.5.0',
983+
installedAt: '2026-04-23T08:00:00.000Z',
984+
notifiedAt: null,
985+
},
986+
}));
987+
mocks.refreshUpdateCache.mockResolvedValue(emptyUpdateCache());
988+
const { stdout, options } = captureOutput();
989+
const notices: string[] = [];
990+
991+
await expect(runUpdatePreflight('0.5.0', {
992+
...options,
993+
onUpdateNotice: (notice) => { notices.push(notice); },
994+
})).resolves.toBe('continue');
995+
996+
expect(stdout.join('')).toBe('');
997+
expect(notices).toHaveLength(1);
998+
expect(notices[0]).toContain('Pythinker Code updated to v0.5.0');
999+
expect(writeUpdateInstallState).toHaveBeenCalledWith(expect.objectContaining({
1000+
lastSuccess: expect.objectContaining({ notifiedAt: expect.any(String) }),
1001+
}));
1002+
});
1003+
9781004
it('infers a background update success notice when the active install version is now running', async () => {
9791005
mocks.readUpdateCache.mockResolvedValue(emptyUpdateCache());
9801006
mocks.readUpdateInstallState.mockResolvedValue(installState({

‎apps/pythinker-code/test/tui/pythinker-tui-startup.test.ts‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -327,6 +327,22 @@ describe('PythinkerTUI startup', () => {
327327
expect(transcript).toContain('No session yet — one will be created on your first message.');
328328
});
329329

330+
it('shows the startup update notice in the fullscreen transcript', async () => {
331+
const harness = makeHarness(makeSession());
332+
const driver = makeDriver(harness, {
333+
...makeStartupInput({}, { tuiMode: 'fullscreen' }),
334+
startupNotice: 'Pythinker Code updated to v9.9.9',
335+
});
336+
337+
await expect(driver.init()).resolves.toBe(false);
338+
await (
339+
driver as unknown as { finishStartup(shouldReplayHistory: boolean): Promise<void> }
340+
).finishStartup(false);
341+
342+
const transcript = driver.state.transcriptContainer.render(160).join('\n');
343+
expect(transcript).toContain('Pythinker Code updated to v9.9.9');
344+
});
345+
330346
it('shows config defaults in appState before the lazy session exists (v2)', async () => {
331347
const harness = makeHarness(makeSession(), {
332348
getConfig: vi.fn(async () => ({

0 commit comments

Comments
 (0)