Skip to content

Commit 7c8cee1

Browse files
Auto-dismiss temporary status flashes on the notice row (#629)
* Auto-dismiss temporary status flashes on the notice row One-shot confirmations now pass a TTL at the call site so they clear themselves. Rate-limit countdown no longer parks on the bottom chrome; the durable error stays in the transcript, and only clear-and-resubmit flashes briefly. * Stop TTL flashes from painting a destroyed TUI renderer Headless tests often tear down the renderer without dispose. A TTL flash armed before that teardown must not write a freed TextBuffer.
1 parent 686012a commit 7c8cee1

11 files changed

Lines changed: 348 additions & 36 deletions

CHANGELOG.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,13 @@ matching `## [X.Y.Z]` section (plus install instructions). Do not maintain
1111
parallel copies under `docs/` or `scripts/notes/`. At cut time: rename
1212
`## [Unreleased]` to `## [X.Y.Z] - YYYY-MM-DD`, then run the release script.
1313

14+
## [Unreleased]
15+
16+
### Fixed
17+
18+
- One-shot confirmation flashes (copy, mouse toggle, attach results, reasoning effort, stall recovery) now clear themselves after a short TTL. Rate-limit waits no longer park on the bottom notice row; the durable error stays in the transcript. Live stall notice and landing hold still omit a TTL so they stay until replaced.
19+
- A TTL flash no longer paints chrome after the TUI renderer is destroyed, which crashed parallel TUI tests with `TextBuffer is destroyed`.
20+
1421
## [0.3.1] - 2026-08-24
1522

1623
### Fixed

docs/TUI.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -546,7 +546,9 @@ running its own selection. Two chords cover remaining copy needs:
546546
(`CliRenderEvents.SELECTION``copyFinishedSelection` in
547547
`selection-copy.ts`). On mouse-up, non-empty selected text is written
548548
through the system clipboard port and the highlight clears with a status
549-
flash. Empty clicks do not copy.
549+
flash. Empty clicks do not copy. Confirmation flashes pass
550+
`ttlMs: RUNTIME_FLASH_MS` so they clear themselves; omit TTL only for
551+
live conditions that stay true until replaced (stall notice, landing hold).
550552
- **Alt+M** toggles DEC mouse reporting off and back on
551553
(`toggleMouseCapture`, `shell.ts`). Off, the terminal's own drag-select
552554
and copy work exactly as in any other terminal program; the status flash

src/tui/copy-wire.test.ts

Lines changed: 89 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,10 @@ import {
88
createAppShell,
99
enterCopyMode,
1010
toggleMouseCapture,
11+
type FlashSchedule,
1112
} from "./shell";
1213
import { createRecordingClipboard } from "./copy-path";
14+
import { RUNTIME_FLASH_MS } from "./runtime-notices";
1315

1416
// One renderer for the whole file: harness renderers are a scarce native
1517
// resource and the suite exhausts them when every test claims its own.
@@ -23,10 +25,22 @@ afterAll(() => {
2325
harness.destroy();
2426
});
2527

28+
/** Capture scheduled flash expiries so tests can lapse without wall time. */
29+
function capturingSchedule(lapse: (() => void)[], expectedMs = RUNTIME_FLASH_MS): FlashSchedule {
30+
return (fn, ms) => {
31+
expect(ms).toBe(expectedMs);
32+
lapse.push(fn);
33+
return () => {};
34+
};
35+
}
36+
37+
/** Do not arm a real timer: bun test runs files in one process. */
38+
const ignoreExpiry: FlashSchedule = () => () => {};
39+
2640
describe("Alt+C reaches the injected clipboard", () => {
2741
test("confirming a copy target writes its text", () => {
2842
const clipboard = createRecordingClipboard();
29-
const shell = createAppShell(harness.renderer, { clipboard });
43+
const shell = createAppShell(harness.renderer, { clipboard, flashSchedule: ignoreExpiry });
3044
appendStreamRow(shell, { role: "assistant", text: "copy me" });
3145
expect(enterCopyMode(shell)).toBe(true);
3246
expect(confirmCopySelection(shell)).toBe(true);
@@ -36,7 +50,7 @@ describe("Alt+C reaches the injected clipboard", () => {
3650

3751
test("copy all writes every non-system row", () => {
3852
const clipboard = createRecordingClipboard();
39-
const shell = createAppShell(harness.renderer, { clipboard });
53+
const shell = createAppShell(harness.renderer, { clipboard, flashSchedule: ignoreExpiry });
4054
appendStreamRow(shell, { role: "user", text: "one" });
4155
appendStreamRow(shell, { role: "assistant", text: "two" });
4256
enterCopyMode(shell);
@@ -46,12 +60,42 @@ describe("Alt+C reaches the injected clipboard", () => {
4660
expect(clipboard.writes[0]).toContain("two");
4761
shell.dispose();
4862
});
63+
64+
test("copy confirmation clears itself when the flash window lapses", () => {
65+
const lapse: (() => void)[] = [];
66+
const clipboard = createRecordingClipboard();
67+
const shell = createAppShell(harness.renderer, {
68+
clipboard,
69+
flashSchedule: capturingSchedule(lapse),
70+
});
71+
appendStreamRow(shell, { role: "assistant", text: "copy me" });
72+
enterCopyMode(shell);
73+
expect(confirmCopySelection(shell)).toBe(true);
74+
expect(shell.statusFlash).toContain("Copied");
75+
expect(lapse).toHaveLength(1);
76+
lapse[0]?.();
77+
expect(shell.statusFlash).toBeNull();
78+
shell.dispose();
79+
});
80+
81+
test("nothing-to-copy flash clears itself when the window lapses", () => {
82+
const lapse: (() => void)[] = [];
83+
const shell = createAppShell(harness.renderer, {
84+
flashSchedule: capturingSchedule(lapse),
85+
});
86+
expect(enterCopyMode(shell)).toBe(false);
87+
expect(shell.statusFlash).toBe("nothing to copy");
88+
expect(lapse).toHaveLength(1);
89+
lapse[0]?.();
90+
expect(shell.statusFlash).toBeNull();
91+
shell.dispose();
92+
});
4993
});
5094

5195
describe("drag-select auto-copy", () => {
5296
test("SELECTION event writes finished text and flashes", () => {
5397
const clipboard = createRecordingClipboard();
54-
const shell = createAppShell(harness.renderer, { clipboard });
98+
const shell = createAppShell(harness.renderer, { clipboard, flashSchedule: ignoreExpiry });
5599
harness.renderer.emit(CliRenderEvents.SELECTION, {
56100
isDragging: false,
57101
getSelectedText: () => "dragged snippet",
@@ -62,9 +106,27 @@ describe("drag-select auto-copy", () => {
62106
shell.dispose();
63107
});
64108

109+
test("SELECTION flash clears itself when the window lapses", () => {
110+
const lapse: (() => void)[] = [];
111+
const clipboard = createRecordingClipboard();
112+
const shell = createAppShell(harness.renderer, {
113+
clipboard,
114+
flashSchedule: capturingSchedule(lapse),
115+
});
116+
harness.renderer.emit(CliRenderEvents.SELECTION, {
117+
isDragging: false,
118+
getSelectedText: () => "dragged snippet",
119+
});
120+
expect(shell.statusFlash).toContain("Copied 15 chars");
121+
expect(lapse).toHaveLength(1);
122+
lapse[0]?.();
123+
expect(shell.statusFlash).toBeNull();
124+
shell.dispose();
125+
});
126+
65127
test("SELECTION while dragging is a no-op", () => {
66128
const clipboard = createRecordingClipboard();
67-
const shell = createAppShell(harness.renderer, { clipboard });
129+
const shell = createAppShell(harness.renderer, { clipboard, flashSchedule: ignoreExpiry });
68130
harness.renderer.emit(CliRenderEvents.SELECTION, {
69131
isDragging: true,
70132
getSelectedText: () => "partial",
@@ -76,7 +138,7 @@ describe("drag-select auto-copy", () => {
76138

77139
test("empty SELECTION is a no-op", () => {
78140
const clipboard = createRecordingClipboard();
79-
const shell = createAppShell(harness.renderer, { clipboard });
141+
const shell = createAppShell(harness.renderer, { clipboard, flashSchedule: ignoreExpiry });
80142
harness.renderer.emit(CliRenderEvents.SELECTION, {
81143
isDragging: false,
82144
getSelectedText: () => "",
@@ -90,6 +152,7 @@ describe("Alt+M mouse capture", () => {
90152
test("toggles the host port and reports the new state", () => {
91153
let enabled = false;
92154
const shell = createAppShell(harness.renderer, {
155+
flashSchedule: ignoreExpiry,
93156
mouseCapture: {
94157
get: () => enabled,
95158
set: (v) => {
@@ -105,8 +168,28 @@ describe("Alt+M mouse capture", () => {
105168
shell.dispose();
106169
});
107170

171+
test("mouse-toggle flash clears itself when the window lapses", () => {
172+
const lapse: (() => void)[] = [];
173+
let enabled = false;
174+
const shell = createAppShell(harness.renderer, {
175+
flashSchedule: capturingSchedule(lapse),
176+
mouseCapture: {
177+
get: () => enabled,
178+
set: (v) => {
179+
enabled = v;
180+
},
181+
},
182+
});
183+
expect(toggleMouseCapture(shell)).toBe(true);
184+
expect(shell.statusFlash).toContain("drag text to copy");
185+
expect(lapse).toHaveLength(1);
186+
lapse[0]?.();
187+
expect(shell.statusFlash).toBeNull();
188+
shell.dispose();
189+
});
190+
108191
test("reports unavailable when the host exposes no control", () => {
109-
const shell = createAppShell(harness.renderer);
192+
const shell = createAppShell(harness.renderer, { flashSchedule: ignoreExpiry });
110193
expect(toggleMouseCapture(shell)).toBeNull();
111194
expect(shell.statusFlash).toContain("not controllable");
112195
shell.dispose();

src/tui/prompt-chrome.test.ts

Lines changed: 34 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import {
1616
setStatusFlash,
1717
submitPrompt,
1818
} from "./shell";
19+
import { RUNTIME_FLASH_MS } from "./runtime-notices";
1920
import { UI } from "./theme";
2021

2122
async function withShell(
@@ -295,18 +296,49 @@ describe("no permanent hint strip", () => {
295296

296297
test("state that is only sometimes true takes a row only while it is true", async () => {
297298
await withShell((shell) => {
298-
setStatusFlash(shell, "copied 3 lines");
299+
const lapse: (() => void)[] = [];
300+
setStatusFlash(shell, "copied 3 lines", {
301+
ttlMs: RUNTIME_FLASH_MS,
302+
schedule: (fn, ms) => {
303+
expect(ms).toBe(RUNTIME_FLASH_MS);
304+
lapse.push(fn);
305+
return () => {};
306+
},
307+
});
299308
expect(noticeText(shell)).toContain("copied 3 lines");
300309
expect(shell.layout.heights.notice).toBe(1);
301310
expect(shell.notice.visible).toBe(true);
302311

303-
setStatusFlash(shell, null);
312+
lapse[0]?.();
313+
expect(shell.statusFlash).toBeNull();
304314
expect(noticeText(shell)).toBe("");
305315
expect(shell.layout.heights.notice).toBe(0);
306316
expect(shell.notice.visible).toBe(false);
307317
});
308318
});
309319

320+
test("a lapsed flash does not paint after the renderer is torn down without dispose", async () => {
321+
await withTestRenderer(async (h) => {
322+
const lapse: (() => void)[] = [];
323+
const shell = createAppShell(h.renderer, {
324+
title: "test",
325+
cwd: "/src/corbits-code",
326+
terminal: { columns: 80, rows: 24 },
327+
wireKeys: false,
328+
flashSchedule: (fn, ms) => {
329+
expect(ms).toBe(RUNTIME_FLASH_MS);
330+
lapse.push(fn);
331+
return () => {};
332+
},
333+
});
334+
setStatusFlash(shell, "copied 3 lines", { ttlMs: RUNTIME_FLASH_MS });
335+
h.destroy();
336+
expect(h.renderer.isDestroyed).toBe(true);
337+
expect(shell.disposed).toBe(false);
338+
expect(() => lapse[0]?.()).not.toThrow();
339+
});
340+
});
341+
310342
test("the keys strip is gone from the frame entirely", async () => {
311343
await withShell((shell) => {
312344
const painted = [

src/tui/prompt-features.test.ts

Lines changed: 45 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,9 @@ import {
2121
setShellBridgeHooks,
2222
submitPrompt,
2323
type AppShell,
24+
type FlashSchedule,
2425
} from "./shell";
26+
import { RUNTIME_FLASH_MS } from "./runtime-notices";
2527

2628
const CLIP: PendingImageAttachment = {
2729
id: "clip-1",
@@ -51,14 +53,15 @@ const CLIP_OTHER: PendingImageAttachment = {
5153

5254
function withShell(
5355
fn: (shell: AppShell) => Promise<void>,
54-
opts?: { readonly wireKeys?: boolean },
56+
opts?: { readonly wireKeys?: boolean; readonly flashSchedule?: FlashSchedule },
5557
): Promise<void> {
5658
return withTestRenderer(
5759
async (h) => {
5860
const shell = createAppShell(h.renderer, {
5961
terminal: { columns: 80, rows: 24 },
6062
wireKeys: opts?.wireKeys ?? true,
6163
run: "idle",
64+
...(opts?.flashSchedule !== undefined ? { flashSchedule: opts.flashSchedule } : {}),
6265
});
6366
try {
6467
await fn(shell);
@@ -91,6 +94,47 @@ describe("image attachments", () => {
9194
});
9295
});
9396

97+
test("fail / attached / duplicate confirmation flashes expire via flashSchedule", async () => {
98+
const lapse: (() => void)[] = [];
99+
const flashSchedule: FlashSchedule = (fn, ms) => {
100+
expect(ms).toBe(RUNTIME_FLASH_MS);
101+
lapse.push(fn);
102+
return () => {};
103+
};
104+
105+
await withShell(
106+
async (shell) => {
107+
setPromptImageSource(shell, async () => ({ ok: false, reason: "no PNG" }));
108+
expect(await attachClipboardImage(shell)).toBe(false);
109+
expect(shell.statusFlash).toContain("no PNG");
110+
expect(lapse).toHaveLength(1);
111+
lapse[0]?.();
112+
expect(shell.statusFlash).toBeNull();
113+
},
114+
{ flashSchedule },
115+
);
116+
117+
lapse.length = 0;
118+
await withShell(
119+
async (shell) => {
120+
setPromptImageSource(shell, async () => ({ ok: true, attachment: CLIP }));
121+
expect(await attachClipboardImage(shell)).toBe(true);
122+
expect(shell.statusFlash).toContain("attached clipboard.png");
123+
expect(lapse).toHaveLength(1);
124+
lapse[0]?.();
125+
expect(shell.statusFlash).toBeNull();
126+
127+
setPromptImageSource(shell, async () => ({ ok: true, attachment: CLIP_SAME_CONTENT }));
128+
expect(await attachClipboardImage(shell)).toBe(false);
129+
expect(shell.statusFlash).toContain(`${CLIP.name} is already attached`);
130+
expect(lapse).toHaveLength(2);
131+
lapse[1]?.();
132+
expect(shell.statusFlash).toBeNull();
133+
},
134+
{ flashSchedule },
135+
);
136+
});
137+
94138
test("quitting mid-read does not attach into the disposed shell", async () => {
95139
await withTestRenderer(
96140
async (h) => {

src/tui/prompt-slash-exit.test.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import {
1717
setStatusFlash,
1818
type AppShell,
1919
} from "./shell";
20+
import { RUNTIME_FLASH_MS } from "./runtime-notices";
2021

2122
const CATALOG: readonly PaletteCommand[] = [
2223
{
@@ -241,7 +242,15 @@ describe("Ctrl+C exit", () => {
241242
return () => {};
242243
},
243244
});
244-
setStatusFlash(shell, "copied 3 lines");
245+
setStatusFlash(shell, "copied 3 lines", {
246+
ttlMs: RUNTIME_FLASH_MS,
247+
schedule: (fn) => {
248+
// Armed but not fired — the ctrl+c window must not clear it.
249+
return () => {
250+
void fn;
251+
};
252+
},
253+
});
245254
lapse[0]?.();
246255
expect(shell.statusFlash).toBe("copied 3 lines");
247256
});

src/tui/runner.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -198,6 +198,7 @@ import {
198198
setStatusFlash,
199199
surfaceSystemNotice,
200200
} from "./shell.js";
201+
import { RUNTIME_FLASH_MS } from "./runtime-notices.js";
201202
import {
202203
captureAuthFailure,
203204
classifyAgentSendFailure,
@@ -2619,7 +2620,9 @@ export async function runTUI(initialConfig: Config): Promise<number> {
26192620
isCodexProviderName(config.providerName),
26202621
);
26212622
if (next === undefined) {
2622-
setStatusFlash(host.shell, "this model has no reasoning effort levels");
2623+
setStatusFlash(host.shell, "this model has no reasoning effort levels", {
2624+
ttlMs: RUNTIME_FLASH_MS,
2625+
});
26232626
return;
26242627
}
26252628
config = { ...config, reasoningEffort: next };
@@ -2630,7 +2633,9 @@ export async function runTUI(initialConfig: Config): Promise<number> {
26302633
model: config.model,
26312634
effort: next,
26322635
});
2633-
setStatusFlash(host.shell, `reasoning effort: ${next}`);
2636+
setStatusFlash(host.shell, `reasoning effort: ${next}`, {
2637+
ttlMs: RUNTIME_FLASH_MS,
2638+
});
26342639
});
26352640

26362641
// Recall spans the whole session, including what was sent before a resume.

0 commit comments

Comments
 (0)