Skip to content

Commit a89a8d1

Browse files
committed
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.
1 parent 16e1e41 commit a89a8d1

10 files changed

Lines changed: 304 additions & 30 deletions

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: 79 additions & 0 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,6 +25,15 @@ 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+
2637
describe("Alt+C reaches the injected clipboard", () => {
2738
test("confirming a copy target writes its text", () => {
2839
const clipboard = createRecordingClipboard();
@@ -46,6 +57,36 @@ describe("Alt+C reaches the injected clipboard", () => {
4657
expect(clipboard.writes[0]).toContain("two");
4758
shell.dispose();
4859
});
60+
61+
test("copy confirmation clears itself when the flash window lapses", () => {
62+
const lapse: (() => void)[] = [];
63+
const clipboard = createRecordingClipboard();
64+
const shell = createAppShell(harness.renderer, {
65+
clipboard,
66+
flashSchedule: capturingSchedule(lapse),
67+
});
68+
appendStreamRow(shell, { role: "assistant", text: "copy me" });
69+
enterCopyMode(shell);
70+
expect(confirmCopySelection(shell)).toBe(true);
71+
expect(shell.statusFlash).toContain("Copied");
72+
expect(lapse).toHaveLength(1);
73+
lapse[0]?.();
74+
expect(shell.statusFlash).toBeNull();
75+
shell.dispose();
76+
});
77+
78+
test("nothing-to-copy flash clears itself when the window lapses", () => {
79+
const lapse: (() => void)[] = [];
80+
const shell = createAppShell(harness.renderer, {
81+
flashSchedule: capturingSchedule(lapse),
82+
});
83+
expect(enterCopyMode(shell)).toBe(false);
84+
expect(shell.statusFlash).toBe("nothing to copy");
85+
expect(lapse).toHaveLength(1);
86+
lapse[0]?.();
87+
expect(shell.statusFlash).toBeNull();
88+
shell.dispose();
89+
});
4990
});
5091

5192
describe("drag-select auto-copy", () => {
@@ -62,6 +103,24 @@ describe("drag-select auto-copy", () => {
62103
shell.dispose();
63104
});
64105

106+
test("SELECTION flash clears itself when the window lapses", () => {
107+
const lapse: (() => void)[] = [];
108+
const clipboard = createRecordingClipboard();
109+
const shell = createAppShell(harness.renderer, {
110+
clipboard,
111+
flashSchedule: capturingSchedule(lapse),
112+
});
113+
harness.renderer.emit(CliRenderEvents.SELECTION, {
114+
isDragging: false,
115+
getSelectedText: () => "dragged snippet",
116+
});
117+
expect(shell.statusFlash).toContain("Copied 15 chars");
118+
expect(lapse).toHaveLength(1);
119+
lapse[0]?.();
120+
expect(shell.statusFlash).toBeNull();
121+
shell.dispose();
122+
});
123+
65124
test("SELECTION while dragging is a no-op", () => {
66125
const clipboard = createRecordingClipboard();
67126
const shell = createAppShell(harness.renderer, { clipboard });
@@ -105,6 +164,26 @@ describe("Alt+M mouse capture", () => {
105164
shell.dispose();
106165
});
107166

167+
test("mouse-toggle flash clears itself when the window lapses", () => {
168+
const lapse: (() => void)[] = [];
169+
let enabled = false;
170+
const shell = createAppShell(harness.renderer, {
171+
flashSchedule: capturingSchedule(lapse),
172+
mouseCapture: {
173+
get: () => enabled,
174+
set: (v) => {
175+
enabled = v;
176+
},
177+
},
178+
});
179+
expect(toggleMouseCapture(shell)).toBe(true);
180+
expect(shell.statusFlash).toContain("drag text to copy");
181+
expect(lapse).toHaveLength(1);
182+
lapse[0]?.();
183+
expect(shell.statusFlash).toBeNull();
184+
shell.dispose();
185+
});
186+
108187
test("reports unavailable when the host exposes no control", () => {
109188
const shell = createAppShell(harness.renderer);
110189
expect(toggleMouseCapture(shell)).toBeNull();

src/tui/prompt-chrome.test.ts

Lines changed: 12 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,12 +296,21 @@ 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);

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.

src/tui/runtime-bridge.ts

Lines changed: 15 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,8 @@ import {
3434
import { rampAnimating } from "./ramp.js";
3535
import { onTurnBoundary } from "../agent/reactor-events.js";
3636
import { resolveRampPhase, resolveTurnLabel, sendFailureText } from "./session-chrome.js";
37-
import { quotaWaitSeconds, shouldAutoRetryQuota } from "./quota-retry.js";
37+
import { shouldAutoRetryQuota } from "./quota-retry.js";
38+
import { RUNTIME_FLASH_MS } from "./runtime-notices.js";
3839
import {
3940
applyStallRecovery,
4041
repetitionRecoveryMessage,
@@ -1153,16 +1154,16 @@ export function attachSessionBridge(
11531154
bag.quotaFired = true;
11541155
const replay = bag.lastSentMessage;
11551156
bag.turn = clearQuotaWait(bag.turn);
1156-
setStatusFlash(shell, "rate limit cleared — resubmitting");
1157+
setStatusFlash(shell, "rate limit cleared — resubmitting", {
1158+
ttlMs: RUNTIME_FLASH_MS,
1159+
});
11571160
submit(replay, "immediate");
11581161
return;
11591162
}
11601163

11611164
if (quota !== null) {
1162-
setStatusFlash(
1163-
shell,
1164-
`rate limited — retrying in ${quotaWaitSeconds(quota.retryAt, nowMs)}s`,
1165-
);
1165+
// Durable error already lives in the transcript; do not park a sticky
1166+
// countdown flash that outlives every other confirmation.
11661167
return;
11671168
}
11681169

@@ -1180,7 +1181,10 @@ export function attachSessionBridge(
11801181
if (bag.turn.status === "running" && bag.turn.repeating) {
11811182
const repeatedTokens = bag.turn.streamTokenCount - (bag.turn.repeatingSinceTokenCount ?? 0);
11821183
applyStallRecovery(
1183-
{ abort: doInterrupt, notify: (message) => setStatusFlash(shell, message) },
1184+
{
1185+
abort: doInterrupt,
1186+
notify: (message) => setStatusFlash(shell, message, { ttlMs: RUNTIME_FLASH_MS }),
1187+
},
11841188
repetitionRecoveryMessage(repeatedTokens),
11851189
);
11861190
return;
@@ -1190,7 +1194,10 @@ export function attachSessionBridge(
11901194

11911195
if (shouldAbortForStall(stallArgs)) {
11921196
applyStallRecovery(
1193-
{ abort: doInterrupt, notify: (message) => setStatusFlash(shell, message) },
1197+
{
1198+
abort: doInterrupt,
1199+
notify: (message) => setStatusFlash(shell, message, { ttlMs: RUNTIME_FLASH_MS }),
1200+
},
11941201
STALL_RECOVERY_MESSAGE,
11951202
);
11961203
return;

0 commit comments

Comments
 (0)