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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 4 additions & 2 deletions docs/TUI.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions src/tui/image-attachments.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ const IMAGE_MIME_BY_EXT: Readonly<Record<string, string>> = {
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;
};
Expand Down
11 changes: 11 additions & 0 deletions src/tui/keybindings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -517,6 +518,16 @@ const PROBES: Readonly<Record<string, { readonly group: Group; readonly probe: P
shell.session = { ...shell.session, items: [] };
truncateStreamRows(shell, rowsBefore);
setShellRunState(shell, "idle");

addPendingAttachment(shell, {
id: "clip",
name: "clip.png",
contentType: "image/png",
data: new Uint8Array([1]),
contentHash: "hash-clip",
});
press(h, chords[0]);
expect(shell.pendingAttachments).toHaveLength(0);
},
},

Expand Down
3 changes: 2 additions & 1 deletion src/tui/keybindings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
179 changes: 179 additions & 0 deletions src/tui/prompt-slash-exit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -269,4 +275,177 @@ describe("Ctrl+C exit", () => {
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>): PendingImageAttachment {
return {
id,
name: `${id}.png`,
contentType: "image/png",
data: new Uint8Array([137, 80, 78, 71]),
contentHash: `hash-${id}`,
...extra,
};
}
32 changes: 30 additions & 2 deletions src/tui/shell.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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";
}

/**
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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);
Expand Down
Loading