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 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.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 { expect(exits).toBe(1); }); }); + + 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"); + + handleCtrlC(shell, 0); + + 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); + }); + }); + + 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 }); + } + }); + }); + + 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 }); + } + }); + }); + + 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 { + 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..fa84c8784 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"; } /** @@ -5407,16 +5422,27 @@ 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; } } + + 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. @@ -6302,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);