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
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 3 additions & 1 deletion docs/TUI.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
95 changes: 89 additions & 6 deletions src/tui/copy-wire.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -23,10 +25,22 @@ 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 () => {};
};
}

/** 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);
Expand All @@ -36,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);
Expand All @@ -46,12 +60,42 @@ 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", () => {
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",
Expand All @@ -62,9 +106,27 @@ 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 });
const shell = createAppShell(harness.renderer, { clipboard, flashSchedule: ignoreExpiry });
harness.renderer.emit(CliRenderEvents.SELECTION, {
isDragging: true,
getSelectedText: () => "partial",
Expand All @@ -76,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: () => "",
Expand All @@ -90,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) => {
Expand All @@ -105,8 +168,28 @@ 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);
const shell = createAppShell(harness.renderer, { flashSchedule: ignoreExpiry });
expect(toggleMouseCapture(shell)).toBeNull();
expect(shell.statusFlash).toContain("not controllable");
shell.dispose();
Expand Down
36 changes: 34 additions & 2 deletions src/tui/prompt-chrome.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
setStatusFlash,
submitPrompt,
} from "./shell";
import { RUNTIME_FLASH_MS } from "./runtime-notices";
import { UI } from "./theme";

async function withShell(
Expand Down Expand Up @@ -295,18 +296,49 @@ 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);
});
});

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 = [
Expand Down
46 changes: 45 additions & 1 deletion src/tui/prompt-features.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -51,14 +53,15 @@ const CLIP_OTHER: PendingImageAttachment = {

function withShell(
fn: (shell: AppShell) => Promise<void>,
opts?: { readonly wireKeys?: boolean },
opts?: { readonly wireKeys?: boolean; readonly flashSchedule?: FlashSchedule },
): Promise<void> {
return withTestRenderer(
async (h) => {
const shell = createAppShell(h.renderer, {
terminal: { columns: 80, rows: 24 },
wireKeys: opts?.wireKeys ?? true,
run: "idle",
...(opts?.flashSchedule !== undefined ? { flashSchedule: opts.flashSchedule } : {}),
});
try {
await fn(shell);
Expand Down Expand Up @@ -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) => {
Expand Down
11 changes: 10 additions & 1 deletion src/tui/prompt-slash-exit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
setStatusFlash,
type AppShell,
} from "./shell";
import { RUNTIME_FLASH_MS } from "./runtime-notices";

const CATALOG: readonly PaletteCommand[] = [
{
Expand Down Expand Up @@ -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");
});
Expand Down
9 changes: 7 additions & 2 deletions src/tui/runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,7 @@ import {
setStatusFlash,
surfaceSystemNotice,
} from "./shell.js";
import { RUNTIME_FLASH_MS } from "./runtime-notices.js";
import {
captureAuthFailure,
classifyAgentSendFailure,
Expand Down Expand Up @@ -2619,7 +2620,9 @@ export async function runTUI(initialConfig: Config): Promise<number> {
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 };
Expand All @@ -2630,7 +2633,9 @@ export async function runTUI(initialConfig: Config): Promise<number> {
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.
Expand Down
Loading
Loading