Skip to content

Commit 892f563

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 fdffc63 commit 892f563

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
@@ -619,8 +619,10 @@ The prompt repaints on every keystroke (`onFrame` in `shell.ts` calls
619619
not on a debounce) — anything added to the prompt's paint path must stay
620620
cheap, because it runs at typing speed.
621621

622-
Ctrl+C interrupts a busy run (or clears a non-empty idle prompt); a second
623-
Ctrl+C within a 2-second window (`CTRL_C_EXIT_WINDOW_MS`) quits — this
622+
Ctrl+C interrupts a busy run, or clears idle prompt text and pending
623+
attachments. Clearing prompt text arms a 2-second quit window
624+
(`CTRL_C_EXIT_WINDOW_MS`); clearing attachments alone does not. A second
625+
Ctrl+C while the window is open quits — this
624626
replaced an Ink-era yes/no exit-confirm modal with the same intent (an
625627
explicit second confirmation) without adding a modal (`handleCtrlC`,
626628
`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 {
1011
clampBoardRows,
@@ -976,8 +977,22 @@ export function addPendingAttachment(shell: AppShell, attachment: PendingImageAt
976977
}
977978

978979
export function clearPendingAttachments(shell: AppShell): void {
980+
const pending = shell.pendingAttachments;
979981
shell.pendingAttachments = [];
980982
paintChrome(shell);
983+
for (const attachment of pending) {
984+
const ephemeral = attachment.ephemeralPath;
985+
if (ephemeral === undefined) continue;
986+
try {
987+
unlinkSync(ephemeral);
988+
} catch (err) {
989+
if (!isENOENT(err)) throw err;
990+
}
991+
}
992+
}
993+
994+
function isENOENT(err: unknown): boolean {
995+
return typeof err === "object" && err !== null && "code" in err && err.code === "ENOENT";
981996
}
982997

983998
/**
@@ -5411,12 +5426,20 @@ export function handleCtrlC(shell: AppShell, now = Date.now(), options?: FlashOp
54115426
return;
54125427
}
54135428
}
5429+
5430+
const idle = shell.session.run !== "busy" && badgeCount(shell.session) === 0;
5431+
const hasPromptText = shell.prompt.value.length > 0;
5432+
const hasAttachments = shell.pendingAttachments.length > 0;
5433+
if (idle && (hasPromptText || hasAttachments)) {
5434+
shell.prompt.value = "";
5435+
clearPendingAttachments(shell);
5436+
if (!hasPromptText) return;
5437+
}
5438+
54145439
ctrlCArmedAt.set(shell, now);
54155440

54165441
if (shell.session.run === "busy" || badgeCount(shell.session) > 0) {
54175442
interruptShell(shell);
5418-
} else if (shell.prompt.value.length > 0) {
5419-
shell.prompt.value = "";
54205443
}
54215444
// The notice is exactly as true as the arming window is open, so it expires
54225445
// with it rather than waiting for some later flash to overwrite it.

0 commit comments

Comments
 (0)