From 892f563edb53bee43bd899ec9fcf70385deaafa1 Mon Sep 17 00:00:00 2001 From: Sawyer Date: Fri, 4 Sep 2026 11:46:10 -0700 Subject: [PATCH 1/4] 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. --- docs/TUI.md | 6 ++- src/tui/image-attachments.ts | 2 + src/tui/keybindings.ts | 3 +- src/tui/prompt-slash-exit.test.ts | 87 +++++++++++++++++++++++++++++++ src/tui/shell.ts | 27 +++++++++- 5 files changed, 120 insertions(+), 5 deletions(-) diff --git a/docs/TUI.md b/docs/TUI.md index 459035662..e33e39e95 100644 --- a/docs/TUI.md +++ b/docs/TUI.md @@ -619,8 +619,10 @@ The prompt repaints on every keystroke (`onFrame` in `shell.ts` calls not on a debounce) — anything added to the prompt's paint path must stay cheap, because it runs at typing speed. -Ctrl+C interrupts a busy run (or clears a non-empty idle prompt); a second -Ctrl+C within a 2-second window (`CTRL_C_EXIT_WINDOW_MS`) quits — this +Ctrl+C interrupts a busy run, or clears idle prompt text and pending +attachments. Clearing prompt text arms a 2-second quit window +(`CTRL_C_EXIT_WINDOW_MS`); clearing attachments alone does not. A second +Ctrl+C while the window is open quits — this replaced an Ink-era yes/no exit-confirm modal with the same intent (an explicit second confirmation) without adding a modal (`handleCtrlC`, `shell.ts`). See "Soft steer vs. follow-up" above for the two diff --git a/src/tui/image-attachments.ts b/src/tui/image-attachments.ts index 95c687aa9..e4974ea09 100644 --- a/src/tui/image-attachments.ts +++ b/src/tui/image-attachments.ts @@ -27,6 +27,8 @@ const IMAGE_MIME_BY_EXT: Readonly> = { export type PendingImageAttachment = MessageAttachment & { id: string; path?: string; + /** Set only for files Corbits created; never the operator's `path`. */ + ephemeralPath?: string; /** SHA-256 of the source image file's bytes, used to identify identical images. */ contentHash: string; }; diff --git a/src/tui/keybindings.ts b/src/tui/keybindings.ts index d3e4ecfe6..725e30e84 100644 --- a/src/tui/keybindings.ts +++ b/src/tui/keybindings.ts @@ -32,7 +32,8 @@ export const SHELL_SHORTCUTS: readonly ShellShortcut[] = [ }, { keys: "Ctrl+C", - description: "interrupt the run, or clear the prompt when idle; press twice to exit", + description: + "interrupt the run, or clear the prompt and attachments when idle; press twice to exit", }, { keys: "Ctrl+G", diff --git a/src/tui/prompt-slash-exit.test.ts b/src/tui/prompt-slash-exit.test.ts index a023801d1..056755dcc 100644 --- a/src/tui/prompt-slash-exit.test.ts +++ b/src/tui/prompt-slash-exit.test.ts @@ -3,11 +3,17 @@ * through the wired key path on a headless shell. */ import { describe, expect, test } from "bun:test"; +import { existsSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import { withTestRenderer } from "./harness"; import type { PaletteCommand } from "./command-catalog"; +import type { PendingImageAttachment } from "./image-attachments.js"; import { CTRL_C_EXIT_WINDOW_MS, + addPendingAttachment, + clearPendingAttachments, createAppShell, handleCtrlC, isSlashPopupOpen, @@ -269,4 +275,85 @@ describe("Ctrl+C exit", () => { expect(exits).toBe(1); }); }); + + test("idle Ctrl+C with prompt text also drops pending attachments", async () => { + await withShell(async ({ shell }) => { + addPendingAttachment(shell, pendingImage("clip")); + shell.prompt.value = "look at this"; + expect(noticeText(shell)).toContain("1 image"); + + handleCtrlC(shell, 0); + + expect(shell.prompt.value).toBe(""); + expect(shell.pendingAttachments).toHaveLength(0); + expect(noticeText(shell)).not.toContain("1 image"); + }); + }); + + test("idle Ctrl+C with only attachments clears them and does not arm exit", async () => { + await withShell(async ({ shell }) => { + let exits = 0; + setShellExitHandler(shell, () => { + exits += 1; + }); + addPendingAttachment(shell, pendingImage("clip")); + expect(shell.prompt.value).toBe(""); + expect(noticeText(shell)).toContain("1 image"); + + handleCtrlC(shell, 0); + expect(shell.pendingAttachments).toHaveLength(0); + expect(noticeText(shell)).not.toContain("1 image"); + expect(shell.statusFlash).not.toBe("press ctrl+c again to exit"); + expect(noticeText(shell)).not.toContain("press ctrl+c again to exit"); + expect(exits).toBe(0); + + handleCtrlC(shell, 1); + expect(exits).toBe(0); + }); + }); + + test("busy Ctrl+C interrupts without dropping pending attachments", async () => { + await withShell(async ({ shell }) => { + setShellRunState(shell, "busy"); + addPendingAttachment(shell, pendingImage("clip")); + + handleCtrlC(shell, 0); + + expect(shell.pendingAttachments).toHaveLength(1); + expect(shell.session.run).not.toBe("busy"); + }); + }); + + test("clearPendingAttachments unlinks ephemeralPath and leaves the operator path", async () => { + await withShell(async ({ shell }) => { + const dir = mkdtempSync(join(tmpdir(), "ctrlc-attach-")); + const ephemeral = join(dir, "ours.png"); + const operator = join(dir, "theirs.png"); + writeFileSync(ephemeral, "ephemeral-bytes"); + writeFileSync(operator, "operator-bytes"); + try { + addPendingAttachment(shell, pendingImage("ours", { ephemeralPath: ephemeral })); + addPendingAttachment(shell, pendingImage("theirs", { path: operator })); + + clearPendingAttachments(shell); + + expect(shell.pendingAttachments).toHaveLength(0); + expect(existsSync(ephemeral)).toBe(false); + expect(existsSync(operator)).toBe(true); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + }); }); + +function pendingImage(id: string, extra?: Partial): PendingImageAttachment { + return { + id, + name: `${id}.png`, + contentType: "image/png", + data: new Uint8Array([137, 80, 78, 71]), + contentHash: `hash-${id}`, + ...extra, + }; +} diff --git a/src/tui/shell.ts b/src/tui/shell.ts index dc134fa52..c8db6b90a 100644 --- a/src/tui/shell.ts +++ b/src/tui/shell.ts @@ -5,6 +5,7 @@ * production interactive CLI surface (Ink is no longer the live path). */ +import { unlinkSync } from "node:fs"; import { homedir } from "node:os"; import { clampBoardRows, @@ -976,8 +977,22 @@ export function addPendingAttachment(shell: AppShell, attachment: PendingImageAt } export function clearPendingAttachments(shell: AppShell): void { + const pending = shell.pendingAttachments; shell.pendingAttachments = []; paintChrome(shell); + for (const attachment of pending) { + const ephemeral = attachment.ephemeralPath; + if (ephemeral === undefined) continue; + try { + unlinkSync(ephemeral); + } catch (err) { + if (!isENOENT(err)) throw err; + } + } +} + +function isENOENT(err: unknown): boolean { + return typeof err === "object" && err !== null && "code" in err && err.code === "ENOENT"; } /** @@ -5411,12 +5426,20 @@ export function handleCtrlC(shell: AppShell, now = Date.now(), options?: FlashOp return; } } + + const idle = shell.session.run !== "busy" && badgeCount(shell.session) === 0; + const hasPromptText = shell.prompt.value.length > 0; + const hasAttachments = shell.pendingAttachments.length > 0; + if (idle && (hasPromptText || hasAttachments)) { + shell.prompt.value = ""; + clearPendingAttachments(shell); + if (!hasPromptText) return; + } + ctrlCArmedAt.set(shell, now); if (shell.session.run === "busy" || badgeCount(shell.session) > 0) { interruptShell(shell); - } else if (shell.prompt.value.length > 0) { - shell.prompt.value = ""; } // The notice is exactly as true as the arming window is open, so it expires // with it rather than waiting for some later flash to overwrite it. From 85e7a6208d8250377fb6077243fb44e631fd37e2 Mon Sep 17 00:00:00 2001 From: Sawyer Date: Fri, 4 Sep 2026 12:13:34 -0700 Subject: [PATCH 2/4] Pin Ctrl+C arming, ENOENT unlink, and idle attachment clear --- src/tui/keybindings.test.ts | 11 +++++++++ src/tui/prompt-slash-exit.test.ts | 41 +++++++++++++++++++++++++++++++ 2 files changed, 52 insertions(+) diff --git a/src/tui/keybindings.test.ts b/src/tui/keybindings.test.ts index d89530eca..b71445776 100644 --- a/src/tui/keybindings.test.ts +++ b/src/tui/keybindings.test.ts @@ -24,6 +24,7 @@ import { openCommandSurface } from "./command-surfaces.js"; import { focusOwner } from "./focus/focus-state.js"; import { setChromeZones } from "./shell.js"; import { + addPendingAttachment, appendStreamRow, applyShellInterrupt, createAppShell, @@ -517,6 +518,16 @@ const PROBES: Readonly { test("idle Ctrl+C with prompt text also drops pending attachments", async () => { await withShell(async ({ shell }) => { + let exits = 0; + setShellExitHandler(shell, () => { + exits += 1; + }); addPendingAttachment(shell, pendingImage("clip")); shell.prompt.value = "look at this"; expect(noticeText(shell)).toContain("1 image"); @@ -287,6 +291,10 @@ describe("Ctrl+C exit", () => { expect(shell.prompt.value).toBe(""); expect(shell.pendingAttachments).toHaveLength(0); expect(noticeText(shell)).not.toContain("1 image"); + expect(shell.statusFlash).toBe("press ctrl+c again to exit"); + + handleCtrlC(shell, 1); + expect(exits).toBe(1); }); }); @@ -345,6 +353,39 @@ describe("Ctrl+C exit", () => { } }); }); + + test("clearPendingAttachments swallows a missing ephemeralPath", async () => { + await withShell(async ({ shell }) => { + const missing = join(tmpdir(), "ctrlc-attach-missing.png"); + addPendingAttachment(shell, pendingImage("ours", { ephemeralPath: missing })); + expect(() => clearPendingAttachments(shell)).not.toThrow(); + expect(shell.pendingAttachments).toHaveLength(0); + }); + }); + + test("clearPendingAttachments unlinks ephemeralPath on an attachment that also has path", async () => { + await withShell(async ({ shell }) => { + const dir = mkdtempSync(join(tmpdir(), "ctrlc-attach-both-")); + const ephemeral = join(dir, "ours.png"); + const operator = join(dir, "theirs.png"); + writeFileSync(ephemeral, "ephemeral-bytes"); + writeFileSync(operator, "operator-bytes"); + try { + addPendingAttachment( + shell, + pendingImage("both", { ephemeralPath: ephemeral, path: operator }), + ); + + clearPendingAttachments(shell); + + expect(shell.pendingAttachments).toHaveLength(0); + expect(existsSync(ephemeral)).toBe(false); + expect(existsSync(operator)).toBe(true); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + }); }); function pendingImage(id: string, extra?: Partial): PendingImageAttachment { From e1a87e00d9c2a5fd2b6f1c7df152533119689480 Mon Sep 17 00:00:00 2001 From: Sawyer Date: Fri, 4 Sep 2026 22:39:49 -0700 Subject: [PATCH 3/4] Unlink leftover clipboard files on dispose and armed quit Idle Ctrl+C already unlinks Corbits-created ephemeralPath files. Quit via dispose or a second Ctrl+C skipped that path, so clipboard temp files stayed on disk. Operator-owned path mentions stay untouched. --- src/tui/prompt-slash-exit.test.ts | 51 +++++++++++++++++++++++++++++++ src/tui/shell.ts | 5 +++ 2 files changed, 56 insertions(+) diff --git a/src/tui/prompt-slash-exit.test.ts b/src/tui/prompt-slash-exit.test.ts index 2561e604d..2f9041246 100644 --- a/src/tui/prompt-slash-exit.test.ts +++ b/src/tui/prompt-slash-exit.test.ts @@ -386,6 +386,57 @@ describe("Ctrl+C exit", () => { } }); }); + + test("dispose unlinks ephemeralPath and leaves the operator path", async () => { + await withShell(async ({ shell }) => { + const dir = mkdtempSync(join(tmpdir(), "ctrlc-attach-dispose-")); + const ephemeral = join(dir, "ours.png"); + const operator = join(dir, "theirs.png"); + writeFileSync(ephemeral, "ephemeral-bytes"); + writeFileSync(operator, "operator-bytes"); + try { + addPendingAttachment(shell, pendingImage("ours", { ephemeralPath: ephemeral })); + addPendingAttachment(shell, pendingImage("theirs", { path: operator })); + + shell.dispose(); + + expect(existsSync(ephemeral)).toBe(false); + expect(existsSync(operator)).toBe(true); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + }); + + test("busy double-Ctrl+C quit unlinks ephemeralPath and leaves the operator path", async () => { + await withShell(async ({ shell }) => { + const dir = mkdtempSync(join(tmpdir(), "ctrlc-attach-quit-")); + const ephemeral = join(dir, "ours.png"); + const operator = join(dir, "theirs.png"); + writeFileSync(ephemeral, "ephemeral-bytes"); + writeFileSync(operator, "operator-bytes"); + try { + setShellRunState(shell, "busy"); + addPendingAttachment(shell, pendingImage("ours", { ephemeralPath: ephemeral })); + addPendingAttachment(shell, pendingImage("theirs", { path: operator })); + setShellExitHandler(shell, () => { + shell.dispose(); + }); + + handleCtrlC(shell, 0); + expect(shell.pendingAttachments).toHaveLength(2); + expect(existsSync(ephemeral)).toBe(true); + expect(existsSync(operator)).toBe(true); + + handleCtrlC(shell, 1); + + expect(existsSync(ephemeral)).toBe(false); + expect(existsSync(operator)).toBe(true); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + }); }); function pendingImage(id: string, extra?: Partial): PendingImageAttachment { diff --git a/src/tui/shell.ts b/src/tui/shell.ts index c8db6b90a..fa84c8784 100644 --- a/src/tui/shell.ts +++ b/src/tui/shell.ts @@ -5422,6 +5422,9 @@ export function handleCtrlC(shell: AppShell, now = Date.now(), options?: FlashOp ctrlCArmedAt.delete(shell); const onExit = shellExitHandlers.get(shell); if (onExit !== undefined) { + // Host teardown usually disposes; unlink here too so a stub/delayed + // onExit cannot leave Corbits-created clipboard files behind. + clearPendingAttachments(shell); onExit(); return; } @@ -6325,6 +6328,8 @@ export function createAppShell(renderer: ShellRenderer, options?: AppShellOption abortOverlayHostReservations(shell); disposed = true; shell.disposed = true; + // Quit paths that skip idle Ctrl+C still drop Corbits-created files. + clearPendingAttachments(shell); if (wireKeys) { renderer.keyInput.off("keypress", onKey); renderer.keyInput.off("paste", onPaste); From 072bcd92deb2ffb507443d2318e1cf836a7f8db6 Mon Sep 17 00:00:00 2001 From: Sawyer Date: Fri, 4 Sep 2026 22:50:23 -0700 Subject: [PATCH 4/4] Document idle Ctrl+C attachment clear and dispose unlink in Unreleased --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index a09b36ccc..c453d9758 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,10 @@ parallel copies under `docs/` or `scripts/notes/`. At cut time: rename - Headless `corbits exec` now registers the active run so SIGINT/SIGTERM/SIGHUP finalize `run.json`. +### TUI + +- First idle Ctrl+C clears pending image attachments along with prompt text. Clearing attachments only does not arm the quit window. Corbits-owned ephemeral files are unlinked; operator path-mention files stay on disk. Dispose and a second (armed) Ctrl+C also unlink leftover clipboard files. + ## [0.3.15] - 2026-09-04 ### Added