Skip to content

Commit 64a6435

Browse files
committed
Clear pending images with the first idle Ctrl+C
Idle Ctrl+C used to wipe prompt text but leave pending attachments on the notice row. Clear both, and unlink only files Corbits created (ephemeralPath) so operator path-mention files stay on disk.
1 parent 8ddc5a7 commit 64a6435

5 files changed

Lines changed: 120 additions & 5 deletions

File tree

docs/TUI.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -611,8 +611,10 @@ The prompt repaints on every keystroke (`onFrame` in `shell.ts` calls
611611
not on a debounce) — anything added to the prompt's paint path must stay
612612
cheap, because it runs at typing speed.
613613

614-
Ctrl+C interrupts a busy run (or clears a non-empty idle prompt); a second
615-
Ctrl+C within a 2-second window (`CTRL_C_EXIT_WINDOW_MS`) quits — this
614+
Ctrl+C interrupts a busy run, or clears idle prompt text and pending
615+
attachments. Clearing prompt text arms a 2-second quit window
616+
(`CTRL_C_EXIT_WINDOW_MS`); clearing attachments alone does not. A second
617+
Ctrl+C while the window is open quits — this
616618
replaced an Ink-era yes/no exit-confirm modal with the same intent (an
617619
explicit second confirmation) without adding a modal (`handleCtrlC`,
618620
`shell.ts`). See "Soft steer vs. follow-up" above for the two

src/tui/image-attachments.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,8 @@ const IMAGE_MIME_BY_EXT: Readonly<Record<string, string>> = {
2727
export type PendingImageAttachment = MessageAttachment & {
2828
id: string;
2929
path?: string;
30+
/** Set only for files Corbits created; never the operator's `path`. */
31+
ephemeralPath?: string;
3032
/** SHA-256 of the source image file's bytes, used to identify identical images. */
3133
contentHash: string;
3234
};

src/tui/keybindings.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,8 @@ export const SHELL_SHORTCUTS: readonly ShellShortcut[] = [
3232
},
3333
{
3434
keys: "Ctrl+C",
35-
description: "interrupt the run, or clear the prompt when idle; press twice to exit",
35+
description:
36+
"interrupt the run, or clear the prompt and attachments when idle; press twice to exit",
3637
},
3738
{
3839
keys: "Ctrl+G",

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

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,17 @@
33
* through the wired key path on a headless shell.
44
*/
55
import { describe, expect, test } from "bun:test";
6+
import { existsSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
7+
import { tmpdir } from "node:os";
8+
import { join } from "node:path";
69

710
import { withTestRenderer } from "./harness";
811
import type { PaletteCommand } from "./command-catalog";
12+
import type { PendingImageAttachment } from "./image-attachments.js";
913
import {
1014
CTRL_C_EXIT_WINDOW_MS,
15+
addPendingAttachment,
16+
clearPendingAttachments,
1117
createAppShell,
1218
handleCtrlC,
1319
isSlashPopupOpen,
@@ -269,4 +275,85 @@ describe("Ctrl+C exit", () => {
269275
expect(exits).toBe(1);
270276
});
271277
});
278+
279+
test("idle Ctrl+C with prompt text also drops pending attachments", async () => {
280+
await withShell(async ({ shell }) => {
281+
addPendingAttachment(shell, pendingImage("clip"));
282+
shell.prompt.value = "look at this";
283+
expect(noticeText(shell)).toContain("1 image");
284+
285+
handleCtrlC(shell, 0);
286+
287+
expect(shell.prompt.value).toBe("");
288+
expect(shell.pendingAttachments).toHaveLength(0);
289+
expect(noticeText(shell)).not.toContain("1 image");
290+
});
291+
});
292+
293+
test("idle Ctrl+C with only attachments clears them and does not arm exit", async () => {
294+
await withShell(async ({ shell }) => {
295+
let exits = 0;
296+
setShellExitHandler(shell, () => {
297+
exits += 1;
298+
});
299+
addPendingAttachment(shell, pendingImage("clip"));
300+
expect(shell.prompt.value).toBe("");
301+
expect(noticeText(shell)).toContain("1 image");
302+
303+
handleCtrlC(shell, 0);
304+
expect(shell.pendingAttachments).toHaveLength(0);
305+
expect(noticeText(shell)).not.toContain("1 image");
306+
expect(shell.statusFlash).not.toBe("press ctrl+c again to exit");
307+
expect(noticeText(shell)).not.toContain("press ctrl+c again to exit");
308+
expect(exits).toBe(0);
309+
310+
handleCtrlC(shell, 1);
311+
expect(exits).toBe(0);
312+
});
313+
});
314+
315+
test("busy Ctrl+C interrupts without dropping pending attachments", async () => {
316+
await withShell(async ({ shell }) => {
317+
setShellRunState(shell, "busy");
318+
addPendingAttachment(shell, pendingImage("clip"));
319+
320+
handleCtrlC(shell, 0);
321+
322+
expect(shell.pendingAttachments).toHaveLength(1);
323+
expect(shell.session.run).not.toBe("busy");
324+
});
325+
});
326+
327+
test("clearPendingAttachments unlinks ephemeralPath and leaves the operator path", async () => {
328+
await withShell(async ({ shell }) => {
329+
const dir = mkdtempSync(join(tmpdir(), "ctrlc-attach-"));
330+
const ephemeral = join(dir, "ours.png");
331+
const operator = join(dir, "theirs.png");
332+
writeFileSync(ephemeral, "ephemeral-bytes");
333+
writeFileSync(operator, "operator-bytes");
334+
try {
335+
addPendingAttachment(shell, pendingImage("ours", { ephemeralPath: ephemeral }));
336+
addPendingAttachment(shell, pendingImage("theirs", { path: operator }));
337+
338+
clearPendingAttachments(shell);
339+
340+
expect(shell.pendingAttachments).toHaveLength(0);
341+
expect(existsSync(ephemeral)).toBe(false);
342+
expect(existsSync(operator)).toBe(true);
343+
} finally {
344+
rmSync(dir, { recursive: true, force: true });
345+
}
346+
});
347+
});
272348
});
349+
350+
function pendingImage(id: string, extra?: Partial<PendingImageAttachment>): PendingImageAttachment {
351+
return {
352+
id,
353+
name: `${id}.png`,
354+
contentType: "image/png",
355+
data: new Uint8Array([137, 80, 78, 71]),
356+
contentHash: `hash-${id}`,
357+
...extra,
358+
};
359+
}

src/tui/shell.ts

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
* production interactive CLI surface (Ink is no longer the live path).
66
*/
77

8+
import { unlinkSync } from "node:fs";
89
import { homedir } from "node:os";
910
import { clampBoardRows, type AgentPanelRow, type TaskPanelRow } from "./chrome-state.js";
1011

@@ -969,8 +970,22 @@ export function addPendingAttachment(shell: AppShell, attachment: PendingImageAt
969970
}
970971

971972
export function clearPendingAttachments(shell: AppShell): void {
973+
const pending = shell.pendingAttachments;
972974
shell.pendingAttachments = [];
973975
paintChrome(shell);
976+
for (const attachment of pending) {
977+
const ephemeral = attachment.ephemeralPath;
978+
if (ephemeral === undefined) continue;
979+
try {
980+
unlinkSync(ephemeral);
981+
} catch (err) {
982+
if (!isENOENT(err)) throw err;
983+
}
984+
}
985+
}
986+
987+
function isENOENT(err: unknown): boolean {
988+
return typeof err === "object" && err !== null && "code" in err && err.code === "ENOENT";
974989
}
975990

976991
/**
@@ -5692,12 +5707,20 @@ export function handleCtrlC(shell: AppShell, now = Date.now(), options?: FlashOp
56925707
return;
56935708
}
56945709
}
5710+
5711+
const idle = shell.session.run !== "busy" && badgeCount(shell.session) === 0;
5712+
const hasPromptText = shell.prompt.value.length > 0;
5713+
const hasAttachments = shell.pendingAttachments.length > 0;
5714+
if (idle && (hasPromptText || hasAttachments)) {
5715+
shell.prompt.value = "";
5716+
clearPendingAttachments(shell);
5717+
if (!hasPromptText) return;
5718+
}
5719+
56955720
ctrlCArmedAt.set(shell, now);
56965721

56975722
if (shell.session.run === "busy" || badgeCount(shell.session) > 0) {
56985723
interruptShell(shell);
5699-
} else if (shell.prompt.value.length > 0) {
5700-
shell.prompt.value = "";
57015724
}
57025725
// The notice is exactly as true as the arming window is open, so it expires
57035726
// with it rather than waiting for some later flash to overwrite it.

0 commit comments

Comments
 (0)