From 12b9e6e8d1a0b2b85f30f10f358b7047a9ae2bd4 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Mon, 24 Aug 2026 07:59:10 -0700 Subject: [PATCH 1/2] 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. --- docs/TUI.md | 4 +- src/tui/copy-wire.test.ts | 79 +++++++++++++++++++++++++++++++ src/tui/prompt-chrome.test.ts | 14 +++++- src/tui/prompt-features.test.ts | 46 +++++++++++++++++- src/tui/prompt-slash-exit.test.ts | 11 ++++- src/tui/runner.ts | 9 +++- src/tui/runtime-bridge.ts | 23 +++++---- src/tui/shell.ts | 57 ++++++++++++++++------ src/tui/turn-monitor.test.ts | 50 ++++++++++++++++++- src/tui/wave6.test.ts | 41 ++++++++++++++++ 10 files changed, 304 insertions(+), 30 deletions(-) diff --git a/docs/TUI.md b/docs/TUI.md index a57c17f03..4de722b6b 100644 --- a/docs/TUI.md +++ b/docs/TUI.md @@ -546,7 +546,9 @@ running its own selection. Two chords cover remaining copy needs: (`CliRenderEvents.SELECTION` → `copyFinishedSelection` in `selection-copy.ts`). On mouse-up, non-empty selected text is written through the system clipboard port and the highlight clears with a status - flash. Empty clicks do not copy. + flash. Empty clicks do not copy. Confirmation flashes pass + `ttlMs: RUNTIME_FLASH_MS` so they clear themselves; omit TTL only for + live conditions that stay true until replaced (stall notice, landing hold). - **Alt+M** toggles DEC mouse reporting off and back on (`toggleMouseCapture`, `shell.ts`). Off, the terminal's own drag-select and copy work exactly as in any other terminal program; the status flash diff --git a/src/tui/copy-wire.test.ts b/src/tui/copy-wire.test.ts index 98684ca12..93d5453a7 100644 --- a/src/tui/copy-wire.test.ts +++ b/src/tui/copy-wire.test.ts @@ -8,8 +8,10 @@ import { createAppShell, enterCopyMode, toggleMouseCapture, + type FlashSchedule, } from "./shell"; import { createRecordingClipboard } from "./copy-path"; +import { RUNTIME_FLASH_MS } from "./runtime-notices"; // One renderer for the whole file: harness renderers are a scarce native // resource and the suite exhausts them when every test claims its own. @@ -23,6 +25,15 @@ afterAll(() => { harness.destroy(); }); +/** Capture scheduled flash expiries so tests can lapse without wall time. */ +function capturingSchedule(lapse: (() => void)[], expectedMs = RUNTIME_FLASH_MS): FlashSchedule { + return (fn, ms) => { + expect(ms).toBe(expectedMs); + lapse.push(fn); + return () => {}; + }; +} + describe("Alt+C reaches the injected clipboard", () => { test("confirming a copy target writes its text", () => { const clipboard = createRecordingClipboard(); @@ -46,6 +57,36 @@ describe("Alt+C reaches the injected clipboard", () => { expect(clipboard.writes[0]).toContain("two"); shell.dispose(); }); + + test("copy confirmation clears itself when the flash window lapses", () => { + const lapse: (() => void)[] = []; + const clipboard = createRecordingClipboard(); + const shell = createAppShell(harness.renderer, { + clipboard, + flashSchedule: capturingSchedule(lapse), + }); + appendStreamRow(shell, { role: "assistant", text: "copy me" }); + enterCopyMode(shell); + expect(confirmCopySelection(shell)).toBe(true); + expect(shell.statusFlash).toContain("Copied"); + expect(lapse).toHaveLength(1); + lapse[0]?.(); + expect(shell.statusFlash).toBeNull(); + shell.dispose(); + }); + + test("nothing-to-copy flash clears itself when the window lapses", () => { + const lapse: (() => void)[] = []; + const shell = createAppShell(harness.renderer, { + flashSchedule: capturingSchedule(lapse), + }); + expect(enterCopyMode(shell)).toBe(false); + expect(shell.statusFlash).toBe("nothing to copy"); + expect(lapse).toHaveLength(1); + lapse[0]?.(); + expect(shell.statusFlash).toBeNull(); + shell.dispose(); + }); }); describe("drag-select auto-copy", () => { @@ -62,6 +103,24 @@ describe("drag-select auto-copy", () => { shell.dispose(); }); + test("SELECTION flash clears itself when the window lapses", () => { + const lapse: (() => void)[] = []; + const clipboard = createRecordingClipboard(); + const shell = createAppShell(harness.renderer, { + clipboard, + flashSchedule: capturingSchedule(lapse), + }); + harness.renderer.emit(CliRenderEvents.SELECTION, { + isDragging: false, + getSelectedText: () => "dragged snippet", + }); + expect(shell.statusFlash).toContain("Copied 15 chars"); + expect(lapse).toHaveLength(1); + lapse[0]?.(); + expect(shell.statusFlash).toBeNull(); + shell.dispose(); + }); + test("SELECTION while dragging is a no-op", () => { const clipboard = createRecordingClipboard(); const shell = createAppShell(harness.renderer, { clipboard }); @@ -105,6 +164,26 @@ describe("Alt+M mouse capture", () => { shell.dispose(); }); + test("mouse-toggle flash clears itself when the window lapses", () => { + const lapse: (() => void)[] = []; + let enabled = false; + const shell = createAppShell(harness.renderer, { + flashSchedule: capturingSchedule(lapse), + mouseCapture: { + get: () => enabled, + set: (v) => { + enabled = v; + }, + }, + }); + expect(toggleMouseCapture(shell)).toBe(true); + expect(shell.statusFlash).toContain("drag text to copy"); + expect(lapse).toHaveLength(1); + lapse[0]?.(); + expect(shell.statusFlash).toBeNull(); + shell.dispose(); + }); + test("reports unavailable when the host exposes no control", () => { const shell = createAppShell(harness.renderer); expect(toggleMouseCapture(shell)).toBeNull(); diff --git a/src/tui/prompt-chrome.test.ts b/src/tui/prompt-chrome.test.ts index c17e6fead..86a3650c8 100644 --- a/src/tui/prompt-chrome.test.ts +++ b/src/tui/prompt-chrome.test.ts @@ -16,6 +16,7 @@ import { setStatusFlash, submitPrompt, } from "./shell"; +import { RUNTIME_FLASH_MS } from "./runtime-notices"; import { UI } from "./theme"; async function withShell( @@ -295,12 +296,21 @@ describe("no permanent hint strip", () => { test("state that is only sometimes true takes a row only while it is true", async () => { await withShell((shell) => { - setStatusFlash(shell, "copied 3 lines"); + const lapse: (() => void)[] = []; + setStatusFlash(shell, "copied 3 lines", { + ttlMs: RUNTIME_FLASH_MS, + schedule: (fn, ms) => { + expect(ms).toBe(RUNTIME_FLASH_MS); + lapse.push(fn); + return () => {}; + }, + }); expect(noticeText(shell)).toContain("copied 3 lines"); expect(shell.layout.heights.notice).toBe(1); expect(shell.notice.visible).toBe(true); - setStatusFlash(shell, null); + lapse[0]?.(); + expect(shell.statusFlash).toBeNull(); expect(noticeText(shell)).toBe(""); expect(shell.layout.heights.notice).toBe(0); expect(shell.notice.visible).toBe(false); diff --git a/src/tui/prompt-features.test.ts b/src/tui/prompt-features.test.ts index 2554e3c49..415daf943 100644 --- a/src/tui/prompt-features.test.ts +++ b/src/tui/prompt-features.test.ts @@ -21,7 +21,9 @@ import { setShellBridgeHooks, submitPrompt, type AppShell, + type FlashSchedule, } from "./shell"; +import { RUNTIME_FLASH_MS } from "./runtime-notices"; const CLIP: PendingImageAttachment = { id: "clip-1", @@ -51,7 +53,7 @@ const CLIP_OTHER: PendingImageAttachment = { function withShell( fn: (shell: AppShell) => Promise, - opts?: { readonly wireKeys?: boolean }, + opts?: { readonly wireKeys?: boolean; readonly flashSchedule?: FlashSchedule }, ): Promise { return withTestRenderer( async (h) => { @@ -59,6 +61,7 @@ function withShell( terminal: { columns: 80, rows: 24 }, wireKeys: opts?.wireKeys ?? true, run: "idle", + ...(opts?.flashSchedule !== undefined ? { flashSchedule: opts.flashSchedule } : {}), }); try { await fn(shell); @@ -91,6 +94,47 @@ describe("image attachments", () => { }); }); + test("fail / attached / duplicate confirmation flashes expire via flashSchedule", async () => { + const lapse: (() => void)[] = []; + const flashSchedule: FlashSchedule = (fn, ms) => { + expect(ms).toBe(RUNTIME_FLASH_MS); + lapse.push(fn); + return () => {}; + }; + + await withShell( + async (shell) => { + setPromptImageSource(shell, async () => ({ ok: false, reason: "no PNG" })); + expect(await attachClipboardImage(shell)).toBe(false); + expect(shell.statusFlash).toContain("no PNG"); + expect(lapse).toHaveLength(1); + lapse[0]?.(); + expect(shell.statusFlash).toBeNull(); + }, + { flashSchedule }, + ); + + lapse.length = 0; + await withShell( + async (shell) => { + setPromptImageSource(shell, async () => ({ ok: true, attachment: CLIP })); + expect(await attachClipboardImage(shell)).toBe(true); + expect(shell.statusFlash).toContain("attached clipboard.png"); + expect(lapse).toHaveLength(1); + lapse[0]?.(); + expect(shell.statusFlash).toBeNull(); + + setPromptImageSource(shell, async () => ({ ok: true, attachment: CLIP_SAME_CONTENT })); + expect(await attachClipboardImage(shell)).toBe(false); + expect(shell.statusFlash).toContain(`${CLIP.name} is already attached`); + expect(lapse).toHaveLength(2); + lapse[1]?.(); + expect(shell.statusFlash).toBeNull(); + }, + { flashSchedule }, + ); + }); + test("quitting mid-read does not attach into the disposed shell", async () => { await withTestRenderer( async (h) => { diff --git a/src/tui/prompt-slash-exit.test.ts b/src/tui/prompt-slash-exit.test.ts index 493102924..a023801d1 100644 --- a/src/tui/prompt-slash-exit.test.ts +++ b/src/tui/prompt-slash-exit.test.ts @@ -17,6 +17,7 @@ import { setStatusFlash, type AppShell, } from "./shell"; +import { RUNTIME_FLASH_MS } from "./runtime-notices"; const CATALOG: readonly PaletteCommand[] = [ { @@ -241,7 +242,15 @@ describe("Ctrl+C exit", () => { return () => {}; }, }); - setStatusFlash(shell, "copied 3 lines"); + setStatusFlash(shell, "copied 3 lines", { + ttlMs: RUNTIME_FLASH_MS, + schedule: (fn) => { + // Armed but not fired — the ctrl+c window must not clear it. + return () => { + void fn; + }; + }, + }); lapse[0]?.(); expect(shell.statusFlash).toBe("copied 3 lines"); }); diff --git a/src/tui/runner.ts b/src/tui/runner.ts index fccb5c855..9eb4da9ac 100644 --- a/src/tui/runner.ts +++ b/src/tui/runner.ts @@ -198,6 +198,7 @@ import { setStatusFlash, surfaceSystemNotice, } from "./shell.js"; +import { RUNTIME_FLASH_MS } from "./runtime-notices.js"; import { captureAuthFailure, classifyAgentSendFailure, @@ -2619,7 +2620,9 @@ export async function runTUI(initialConfig: Config): Promise { isCodexProviderName(config.providerName), ); if (next === undefined) { - setStatusFlash(host.shell, "this model has no reasoning effort levels"); + setStatusFlash(host.shell, "this model has no reasoning effort levels", { + ttlMs: RUNTIME_FLASH_MS, + }); return; } config = { ...config, reasoningEffort: next }; @@ -2630,7 +2633,9 @@ export async function runTUI(initialConfig: Config): Promise { model: config.model, effort: next, }); - setStatusFlash(host.shell, `reasoning effort: ${next}`); + setStatusFlash(host.shell, `reasoning effort: ${next}`, { + ttlMs: RUNTIME_FLASH_MS, + }); }); // Recall spans the whole session, including what was sent before a resume. diff --git a/src/tui/runtime-bridge.ts b/src/tui/runtime-bridge.ts index 08ac127c2..66cadcb21 100644 --- a/src/tui/runtime-bridge.ts +++ b/src/tui/runtime-bridge.ts @@ -34,7 +34,8 @@ import { import { rampAnimating } from "./ramp.js"; import { onTurnBoundary } from "../agent/reactor-events.js"; import { resolveRampPhase, resolveTurnLabel, sendFailureText } from "./session-chrome.js"; -import { quotaWaitSeconds, shouldAutoRetryQuota } from "./quota-retry.js"; +import { shouldAutoRetryQuota } from "./quota-retry.js"; +import { RUNTIME_FLASH_MS } from "./runtime-notices.js"; import { applyStallRecovery, repetitionRecoveryMessage, @@ -1153,16 +1154,16 @@ export function attachSessionBridge( bag.quotaFired = true; const replay = bag.lastSentMessage; bag.turn = clearQuotaWait(bag.turn); - setStatusFlash(shell, "rate limit cleared — resubmitting"); + setStatusFlash(shell, "rate limit cleared — resubmitting", { + ttlMs: RUNTIME_FLASH_MS, + }); submit(replay, "immediate"); return; } if (quota !== null) { - setStatusFlash( - shell, - `rate limited — retrying in ${quotaWaitSeconds(quota.retryAt, nowMs)}s`, - ); + // Durable error already lives in the transcript; do not park a sticky + // countdown flash that outlives every other confirmation. return; } @@ -1180,7 +1181,10 @@ export function attachSessionBridge( if (bag.turn.status === "running" && bag.turn.repeating) { const repeatedTokens = bag.turn.streamTokenCount - (bag.turn.repeatingSinceTokenCount ?? 0); applyStallRecovery( - { abort: doInterrupt, notify: (message) => setStatusFlash(shell, message) }, + { + abort: doInterrupt, + notify: (message) => setStatusFlash(shell, message, { ttlMs: RUNTIME_FLASH_MS }), + }, repetitionRecoveryMessage(repeatedTokens), ); return; @@ -1190,7 +1194,10 @@ export function attachSessionBridge( if (shouldAbortForStall(stallArgs)) { applyStallRecovery( - { abort: doInterrupt, notify: (message) => setStatusFlash(shell, message) }, + { + abort: doInterrupt, + notify: (message) => setStatusFlash(shell, message, { ttlMs: RUNTIME_FLASH_MS }), + }, STALL_RECOVERY_MESSAGE, ); return; diff --git a/src/tui/shell.ts b/src/tui/shell.ts index 30b87f97a..e22fd15cb 100644 --- a/src/tui/shell.ts +++ b/src/tui/shell.ts @@ -65,6 +65,7 @@ import { promptBoxRows } from "./prompt-rows.js"; import { composeNoticeLine, resolveWaitingOn } from "./notice-line.js"; import { lockupCells, lockupText, lockupWidth, type LockupInput } from "./lockup.js"; import type { RampPhase, StallAge } from "./ramp.js"; +import { RUNTIME_FLASH_MS } from "./runtime-notices.js"; import type { ActivityState } from "./session-chrome.js"; import { BORDER, @@ -516,6 +517,11 @@ export interface AppShellOptions { * mouse-up; Alt+M hands the mouse back for native terminal selection. */ readonly mouseCapture?: MouseCapturePort; + /** + * How timed flashes arm their expiry. Injectable so tests can lapse a + * confirmation window without waiting out `RUNTIME_FLASH_MS`. + */ + readonly flashSchedule?: FlashSchedule; } /** @@ -967,24 +973,31 @@ export function clearPendingAttachments(shell: AppShell): void { */ export async function attachClipboardImage(shell: AppShell): Promise { const source = shellPromptImageSource.get(shell) ?? readClipboardImage; + // Sticky until the read resolves — mid-async progress, not a confirmation. setStatusFlash(shell, "reading clipboard image…"); const result = await source(); // Quitting while the clipboard read is pending tears down the shell's // renderables; a stale continuation must not mutate them on resume. if (shell.disposed) return false; if (!result.ok) { - setStatusFlash(shell, `image attach failed: ${result.reason}`); + setStatusFlash(shell, `image attach failed: ${result.reason}`, { + ttlMs: RUNTIME_FLASH_MS, + }); return false; } const duplicate = shell.pendingAttachments.find( (attachment) => attachment.contentHash === result.attachment.contentHash, ); if (duplicate !== undefined) { - setStatusFlash(shell, `${duplicate.name} is already attached`); + setStatusFlash(shell, `${duplicate.name} is already attached`, { + ttlMs: RUNTIME_FLASH_MS, + }); return false; } addPendingAttachment(shell, result.attachment); - setStatusFlash(shell, `attached ${result.attachment.name}`); + setStatusFlash(shell, `attached ${result.attachment.name}`, { + ttlMs: RUNTIME_FLASH_MS, + }); return true; } @@ -1021,6 +1034,9 @@ export interface FlashOptions { /** Cancel for the flash currently counting down, per shell. */ const flashTimers = new WeakMap void>(); +/** Per-shell override for how timed flashes arm their expiry (tests). */ +const shellFlashSchedules = new WeakMap(); + /** * Set a non-destructive flash and repaint (does not touch streamLog). * @@ -1028,6 +1044,8 @@ const flashTimers = new WeakMap void>(); * wording is only true for a moment ("press ctrl+c again to exit") must say so * for exactly that moment: left on screen it becomes a claim about a keypress * the operator never made, and it holds a transcript row hostage for it. + * Omit `ttlMs` for live conditions that stay true until something replaces them + * (stall notice, landing hold). */ export function setStatusFlash( shell: AppShell, @@ -1040,7 +1058,7 @@ export function setStatusFlash( paintChrome(shell); const ttlMs = options?.ttlMs; if (message === null || ttlMs === undefined || ttlMs <= 0) return; - const schedule = options?.schedule ?? defaultFlashSchedule; + const schedule = options?.schedule ?? shellFlashSchedules.get(shell) ?? defaultFlashSchedule; flashTimers.set( shell, schedule(() => { @@ -4612,7 +4630,7 @@ export function enterCopyMode(shell: AppShell): boolean { const targets = buildCopyTargets(shell.streamLog); if (targets.length === 0) { - setStatusFlash(shell, "nothing to copy"); + setStatusFlash(shell, "nothing to copy", { ttlMs: RUNTIME_FLASH_MS }); return false; } @@ -4632,14 +4650,14 @@ export function enterCopyMode(shell: AppShell): boolean { export function confirmCopySelection(shell: AppShell): boolean { const targets = shell.copyTargets; if (!targets || targets.length === 0 || !shell.overlayList) { - setStatusFlash(shell, "nothing to copy"); + setStatusFlash(shell, "nothing to copy", { ttlMs: RUNTIME_FLASH_MS }); closeInsetOverlay(shell); return false; } const idx = Math.max(0, Math.min(targets.length - 1, shell.overlayList.activeIndex)); const target = targets[idx]; if (!target) { - setStatusFlash(shell, "nothing to copy"); + setStatusFlash(shell, "nothing to copy", { ttlMs: RUNTIME_FLASH_MS }); closeInsetOverlay(shell); return false; } @@ -4647,10 +4665,12 @@ export function confirmCopySelection(shell: AppShell): boolean { target.text.length > 48 ? `${target.text.slice(0, 45).replace(/\s+/g, " ")}…` : target.text; writeClipboard(shell.clipboard, target.text, { onSuccess: () => { - setStatusFlash(shell, `Copied ${target.label} (${target.text.length} chars): ${preview}`); + setStatusFlash(shell, `Copied ${target.label} (${target.text.length} chars): ${preview}`, { + ttlMs: RUNTIME_FLASH_MS, + }); }, onFailure: () => { - setStatusFlash(shell, "Copy failed"); + setStatusFlash(shell, "Copy failed", { ttlMs: RUNTIME_FLASH_MS }); }, }); closeInsetOverlay(shell); @@ -4661,17 +4681,19 @@ export function confirmCopySelection(shell: AppShell): boolean { export function copyAllTargets(shell: AppShell): boolean { const targets = shell.copyTargets; if (!targets || targets.length === 0) { - setStatusFlash(shell, "nothing to copy"); + setStatusFlash(shell, "nothing to copy", { ttlMs: RUNTIME_FLASH_MS }); if (shell.overlayKind === "copy") closeInsetOverlay(shell); return false; } const text = streamLogMarkdown(targets); writeClipboard(shell.clipboard, text, { onSuccess: () => { - setStatusFlash(shell, `Copied all (${targets.length} items, ${text.length} chars)`); + setStatusFlash(shell, `Copied all (${targets.length} items, ${text.length} chars)`, { + ttlMs: RUNTIME_FLASH_MS, + }); }, onFailure: () => { - setStatusFlash(shell, "Copy failed"); + setStatusFlash(shell, "Copy failed", { ttlMs: RUNTIME_FLASH_MS }); }, }); closeInsetOverlay(shell); @@ -4687,7 +4709,9 @@ export function copyAllTargets(shell: AppShell): boolean { export function toggleMouseCapture(shell: AppShell): boolean | null { const port = shell.mouseCapture; if (!port) { - setStatusFlash(shell, "mouse reporting is not controllable here"); + setStatusFlash(shell, "mouse reporting is not controllable here", { + ttlMs: RUNTIME_FLASH_MS, + }); return null; } const next = !port.get(); @@ -4697,6 +4721,7 @@ export function toggleMouseCapture(shell: AppShell): boolean | null { next ? "Mouse captured · drag text to copy · click to expand · Alt+M for native select" : "Mouse released · drag to select and copy as usual · Alt+M to click rows", + { ttlMs: RUNTIME_FLASH_MS }, ); return next; } @@ -6006,7 +6031,7 @@ export function createAppShell(renderer: ShellRenderer, options?: AppShellOption copyFinishedSelection( { clipboard: shell.clipboard, - flash: (text) => setStatusFlash(shell, text), + flash: (text) => setStatusFlash(shell, text, { ttlMs: RUNTIME_FLASH_MS }), clearSelection: () => { renderer.clearSelection(); }, @@ -6096,6 +6121,10 @@ export function createAppShell(renderer: ShellRenderer, options?: AppShellOption }, }; + if (options?.flashSchedule) { + shellFlashSchedules.set(shell, options.flashSchedule); + } + internals.set(shell, { visibility, promptContentRows, diff --git a/src/tui/turn-monitor.test.ts b/src/tui/turn-monitor.test.ts index 25e146869..ce94f3ad9 100644 --- a/src/tui/turn-monitor.test.ts +++ b/src/tui/turn-monitor.test.ts @@ -8,6 +8,7 @@ import { describe, expect, test } from "bun:test"; import { attachSessionBridge, createRecordingPort } from "./runtime-bridge.js"; import { createAppShell, noticeText } from "./shell.js"; import { withTestRenderer } from "./harness.js"; +import { RUNTIME_FLASH_MS } from "./runtime-notices.js"; import { STALL_NOTICE_MESSAGE, STALL_RECOVERY_MESSAGE } from "./stall-watchdog.js"; type Harness = Awaited>; @@ -258,12 +259,15 @@ describe("quota auto-retry", () => { t.advance(10_000); t.tick(); - expect(t.shell.statusFlash).toBe("rate limited — retrying in 50s"); + // The durable error is already in the transcript; the notice row must + // not park a sticky countdown that outlives every other flash. + expect(t.shell.statusFlash).toBeNull(); expect(t.port.calls).toEqual([]); t.advance(60_000); t.tick(); expect(t.port.calls).toEqual([{ op: "sendImmediate", text: "run the build" }]); + expect(t.shell.statusFlash).toBe("rate limit cleared — resubmitting"); // Window is closed — a later tick must not replay the prompt again. t.advance(60_000); @@ -275,6 +279,50 @@ describe("quota auto-retry", () => { }); }); + test("the clear-and-resubmit flash expires on its own", async () => { + await withTestRenderer(async (h) => { + const lapse: (() => void)[] = []; + const shell = createAppShell(h.renderer, { + terminal: { columns: 80, rows: 24 }, + wireKeys: false, + run: "idle", + flashSchedule: (fn, ms) => { + expect(ms).toBe(RUNTIME_FLASH_MS); + lapse.push(fn); + return () => {}; + }, + }); + const port = createRecordingPort(); + let nowMs = 0; + let tick: (() => void) | undefined; + const bridge = attachSessionBridge(shell, port, { + now: () => nowMs, + stallTimeoutMs: 1_000, + stallNoticeMs: 400, + schedule: (fn) => { + tick = fn; + return () => { + tick = undefined; + }; + }, + }); + try { + bridge.submit("run the build", "immediate"); + port.clear(); + bridge.handle(quotaEvent(1_000)); + nowMs += 10_000; + tick?.(); + expect(shell.statusFlash).toBe("rate limit cleared — resubmitting"); + expect(lapse).toHaveLength(1); + lapse[0]?.(); + expect(shell.statusFlash).toBeNull(); + } finally { + bridge.dispose(); + shell.dispose(); + } + }); + }); + test("an interrupted turn is never replayed", async () => { await withTestRenderer(async (h) => { const t: Harness = await setup(h); diff --git a/src/tui/wave6.test.ts b/src/tui/wave6.test.ts index 4ec06fd62..dd5eacfbb 100644 --- a/src/tui/wave6.test.ts +++ b/src/tui/wave6.test.ts @@ -20,11 +20,15 @@ import { openPalette, replaceStreamRowAt, setChromeZones, + setEffortCycleHandler, + setStatusFlash, + shellFocusPrompt, streamRowAt, streamRowCount, toggleTasksPanel, } from "./shell"; import { createRecordingClipboard } from "./copy-path"; +import { RUNTIME_FLASH_MS } from "./runtime-notices"; import { stringWidth } from "./view/height"; import type { PaletteCommand } from "./command-catalog"; @@ -873,3 +877,40 @@ describe("Wave 6: keyboard copy path", () => { ); }); }); + +describe("reasoning effort flash TTL", () => { + test("effort confirmation flash expires via flashSchedule", async () => { + await withTestRenderer( + async (h) => { + const lapse: (() => void)[] = []; + const shell = createAppShell(h.renderer, { + terminal: { columns: 80, rows: 24 }, + wireKeys: true, + run: "idle", + flashSchedule: (fn, ms) => { + expect(ms).toBe(RUNTIME_FLASH_MS); + lapse.push(fn); + return () => {}; + }, + }); + try { + // Mirrors runner.ts Shift+Tab handler: confirmation flash with TTL. + setEffortCycleHandler(shell, () => { + setStatusFlash(shell, "reasoning effort: medium", { + ttlMs: RUNTIME_FLASH_MS, + }); + }); + shellFocusPrompt(shell); + h.pressKey("Tab", { shift: true }); + expect(shell.statusFlash).toBe("reasoning effort: medium"); + expect(lapse).toHaveLength(1); + lapse[0]?.(); + expect(shell.statusFlash).toBeNull(); + } finally { + shell.dispose(); + } + }, + { width: 80, height: 24 }, + ); + }); +}); From e3af2d70000aa35dc187e65ddcbc7baf37718405 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Mon, 24 Aug 2026 09:32:29 -0700 Subject: [PATCH 2/2] 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. --- CHANGELOG.md | 7 +++++++ src/tui/copy-wire.test.ts | 16 ++++++++++------ src/tui/prompt-chrome.test.ts | 22 ++++++++++++++++++++++ src/tui/shell.ts | 5 +++++ 4 files changed, 44 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6298c847c..72b9ae9ff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,13 @@ matching `## [X.Y.Z]` section (plus install instructions). Do not maintain parallel copies under `docs/` or `scripts/notes/`. At cut time: rename `## [Unreleased]` to `## [X.Y.Z] - YYYY-MM-DD`, then run the release script. +## [Unreleased] + +### Fixed + +- 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. +- A TTL flash no longer paints chrome after the TUI renderer is destroyed, which crashed parallel TUI tests with `TextBuffer is destroyed`. + ## [0.3.1] - 2026-08-24 ### Fixed diff --git a/src/tui/copy-wire.test.ts b/src/tui/copy-wire.test.ts index 93d5453a7..022a86654 100644 --- a/src/tui/copy-wire.test.ts +++ b/src/tui/copy-wire.test.ts @@ -34,10 +34,13 @@ function capturingSchedule(lapse: (() => void)[], expectedMs = RUNTIME_FLASH_MS) }; } +/** Do not arm a real timer: bun test runs files in one process. */ +const ignoreExpiry: FlashSchedule = () => () => {}; + describe("Alt+C reaches the injected clipboard", () => { test("confirming a copy target writes its text", () => { const clipboard = createRecordingClipboard(); - const shell = createAppShell(harness.renderer, { clipboard }); + const shell = createAppShell(harness.renderer, { clipboard, flashSchedule: ignoreExpiry }); appendStreamRow(shell, { role: "assistant", text: "copy me" }); expect(enterCopyMode(shell)).toBe(true); expect(confirmCopySelection(shell)).toBe(true); @@ -47,7 +50,7 @@ describe("Alt+C reaches the injected clipboard", () => { test("copy all writes every non-system row", () => { const clipboard = createRecordingClipboard(); - const shell = createAppShell(harness.renderer, { clipboard }); + const shell = createAppShell(harness.renderer, { clipboard, flashSchedule: ignoreExpiry }); appendStreamRow(shell, { role: "user", text: "one" }); appendStreamRow(shell, { role: "assistant", text: "two" }); enterCopyMode(shell); @@ -92,7 +95,7 @@ describe("Alt+C reaches the injected clipboard", () => { describe("drag-select auto-copy", () => { test("SELECTION event writes finished text and flashes", () => { const clipboard = createRecordingClipboard(); - const shell = createAppShell(harness.renderer, { clipboard }); + const shell = createAppShell(harness.renderer, { clipboard, flashSchedule: ignoreExpiry }); harness.renderer.emit(CliRenderEvents.SELECTION, { isDragging: false, getSelectedText: () => "dragged snippet", @@ -123,7 +126,7 @@ describe("drag-select auto-copy", () => { test("SELECTION while dragging is a no-op", () => { const clipboard = createRecordingClipboard(); - const shell = createAppShell(harness.renderer, { clipboard }); + const shell = createAppShell(harness.renderer, { clipboard, flashSchedule: ignoreExpiry }); harness.renderer.emit(CliRenderEvents.SELECTION, { isDragging: true, getSelectedText: () => "partial", @@ -135,7 +138,7 @@ describe("drag-select auto-copy", () => { test("empty SELECTION is a no-op", () => { const clipboard = createRecordingClipboard(); - const shell = createAppShell(harness.renderer, { clipboard }); + const shell = createAppShell(harness.renderer, { clipboard, flashSchedule: ignoreExpiry }); harness.renderer.emit(CliRenderEvents.SELECTION, { isDragging: false, getSelectedText: () => "", @@ -149,6 +152,7 @@ describe("Alt+M mouse capture", () => { test("toggles the host port and reports the new state", () => { let enabled = false; const shell = createAppShell(harness.renderer, { + flashSchedule: ignoreExpiry, mouseCapture: { get: () => enabled, set: (v) => { @@ -185,7 +189,7 @@ describe("Alt+M mouse capture", () => { }); test("reports unavailable when the host exposes no control", () => { - const shell = createAppShell(harness.renderer); + const shell = createAppShell(harness.renderer, { flashSchedule: ignoreExpiry }); expect(toggleMouseCapture(shell)).toBeNull(); expect(shell.statusFlash).toContain("not controllable"); shell.dispose(); diff --git a/src/tui/prompt-chrome.test.ts b/src/tui/prompt-chrome.test.ts index 86a3650c8..b64c94685 100644 --- a/src/tui/prompt-chrome.test.ts +++ b/src/tui/prompt-chrome.test.ts @@ -317,6 +317,28 @@ describe("no permanent hint strip", () => { }); }); + test("a lapsed flash does not paint after the renderer is torn down without dispose", async () => { + await withTestRenderer(async (h) => { + const lapse: (() => void)[] = []; + const shell = createAppShell(h.renderer, { + title: "test", + cwd: "/src/corbits-code", + terminal: { columns: 80, rows: 24 }, + wireKeys: false, + flashSchedule: (fn, ms) => { + expect(ms).toBe(RUNTIME_FLASH_MS); + lapse.push(fn); + return () => {}; + }, + }); + setStatusFlash(shell, "copied 3 lines", { ttlMs: RUNTIME_FLASH_MS }); + h.destroy(); + expect(h.renderer.isDestroyed).toBe(true); + expect(shell.disposed).toBe(false); + expect(() => lapse[0]?.()).not.toThrow(); + }); + }); + test("the keys strip is gone from the frame entirely", async () => { await withShell((shell) => { const painted = [ diff --git a/src/tui/shell.ts b/src/tui/shell.ts index e22fd15cb..2c0cdaabd 100644 --- a/src/tui/shell.ts +++ b/src/tui/shell.ts @@ -859,6 +859,10 @@ export function setPluginNeedsAttention(shell: AppShell, needs: boolean): void { /** Repaint the prompt borders and the transient notice row from live state. */ export function paintChrome(shell: AppShell): void { if (shell.disposed) return; + // Headless tests often destroy the renderer without dispose + // (`withTestRenderer` cleanup). A TTL flash armed before that teardown + // must not write a TextBuffer the harness already freed. + if (shell.renderer.isDestroyed || shell.notice.isDestroyed) return; syncPending(shell); const notice = noticeText(shell); shell.notice.content = new StyledText([ @@ -1058,6 +1062,7 @@ export function setStatusFlash( paintChrome(shell); const ttlMs = options?.ttlMs; if (message === null || ttlMs === undefined || ttlMs <= 0) return; + if (shell.disposed || shell.renderer.isDestroyed) return; const schedule = options?.schedule ?? shellFlashSchedules.get(shell) ?? defaultFlashSchedule; flashTimers.set( shell,