From 3148e3e8eb275fe8f30e958b1baeda356423cce0 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Wed, 14 Oct 2026 23:13:11 -0700 Subject: [PATCH 1/3] feat(tui): surface run and gate state as terminal program status --- src/tui/chrome-state.test.ts | 5 +++ src/tui/chrome-state.ts | 6 +++ src/tui/product-host.ts | 67 ++++++++++++++++++++++++++---- src/tui/program-status.test.ts | 74 ++++++++++++++++++++++++++++++++++ src/tui/program-status.ts | 65 +++++++++++++++++++++++++++++ src/tui/runtime-bridge.ts | 4 ++ 6 files changed, 213 insertions(+), 8 deletions(-) create mode 100644 src/tui/program-status.test.ts create mode 100644 src/tui/program-status.ts diff --git a/src/tui/chrome-state.test.ts b/src/tui/chrome-state.test.ts index 11cdb81e1..5cee8321e 100644 --- a/src/tui/chrome-state.test.ts +++ b/src/tui/chrome-state.test.ts @@ -651,6 +651,11 @@ describe("chromeFromSession", () => { expect(zones.agents?.[0]?.label).toContain("explorer"); }); + test("copies a turn-level stall flag onto the live snapshot", () => { + expect(chromeFromSession({ stalled: true }).stalled).toBe(true); + expect(chromeFromSession({}).stalled).toBeUndefined(); + }); + test("falls back agent id; empty bags hide", () => { const state = chromeFromSession({ tasks: [], diff --git a/src/tui/chrome-state.ts b/src/tui/chrome-state.ts index 3fd266ea1..6988f19d3 100644 --- a/src/tui/chrome-state.ts +++ b/src/tui/chrome-state.ts @@ -82,6 +82,9 @@ export interface ChromeLiveState { readonly agentId: string; readonly description: string; } | null; + /** Turn-level stall (`isStalledForDisplay`). Absent means the host reads + * the live watchdog instead of this snapshot. */ + readonly stalled?: boolean; } /** One rendered agents-panel row. `stalled` comes precomputed so the @@ -513,6 +516,7 @@ export interface ChromeSessionInput { readonly tasks?: readonly ChromeSessionTask[] | null; readonly agents?: readonly ChromeSessionAgent[] | null; readonly observe?: ChromeLiveState["observe"]; + readonly stalled?: boolean; } /** Map real session shapes (tasks / subagent store) into ChromeLiveState. @@ -521,11 +525,13 @@ export function chromeFromSession(input: ChromeSessionInput): ChromeLiveState { const task = mapSessionTasks(input.tasks); const agents = mapSessionAgents(input.agents); const observe = input.observe ?? null; + const stalled = input.stalled; return { ...(task !== undefined ? { task } : {}), ...(agents !== undefined ? { agents } : {}), ...(observe !== null && observe !== undefined ? { observe } : {}), + ...(stalled !== undefined ? { stalled } : {}), }; } diff --git a/src/tui/product-host.ts b/src/tui/product-host.ts index 9c986ee4b..8c407ef42 100644 --- a/src/tui/product-host.ts +++ b/src/tui/product-host.ts @@ -4,7 +4,11 @@ */ import { EventEmitter } from "node:events"; -import { createCliRenderer, type CliRenderer } from "@opentui/core"; +import { + createCliRenderer, + type CliRenderer, + type ProgramStatus, +} from "@opentui/core"; import { createLiveSessionPort } from "./live-session-port.js"; import { checkWidthContract, widthContractNotice } from "./width-contract.js"; @@ -18,6 +22,7 @@ import type { ShellOutputFeed } from "../session/shell-output-feed.js"; import { openAddProviderOverlay, openModelPickerOverlay } from "./overlays.js"; import { wireGates } from "./gate-wire.js"; import { createSystemClipboard } from "./system-clipboard.js"; +import { programStatusForChrome } from "./program-status.js"; import { AGENTS_PANEL_LINGER_MS, agentsChromeNeedsSticky, @@ -260,6 +265,13 @@ export interface ProductHostConfig { * resends wheel input as arrow keys; with it, drag-select is OpenTUI-owned * and auto-copies on mouse-up (Alt+M returns to native selection). */ readonly useMouse?: boolean; + /** OSC 7501 sink. Default calls `renderer.setProgramStatus` and ignores a + * false return (terminal inactive, output full, or destroyed). */ + readonly programStatus?: ProgramStatusPort; +} + +export interface ProgramStatusPort { + set(status: ProgramStatus): boolean; } export interface ProductHost { @@ -384,17 +396,41 @@ export async function mountProductHost( const agentsPanelLingerMs = config.agentsPanelLingerMs ?? AGENTS_PANEL_LINGER_MS; let chromeState: ChromeLiveState | null = config.chrome ?? null; + const programStatus: ProgramStatusPort = config.programStatus ?? { + set: (status) => { + try { + return renderer.setProgramStatus(status); + } catch { + return false; + } + }, + }; + let openGates = 0; + let lastProgramStatus = ""; + const publishProgramStatus = (): void => { + const status = programStatusForChrome({ + tasks: chromeState?.task, + agents: chromeState?.agents, + stalled: chromeState?.stalled ?? bridge.isTurnStalled(), + gateOpen: openGates > 0, + }); + const key = JSON.stringify(status); + if (key === lastProgramStatus) return; + if (programStatus.set(status)) lastProgramStatus = key; + }; const paintChromeZones = (): void => { if (chromeState === null) { setChromeZones(shell, { task: null, agents: null }); - return; + } else { + setChromeZones( + shell, + formatChromeZones(chromeState, Date.now(), agentsPanelLingerMs), + ); } - setChromeZones( - shell, - formatChromeZones(chromeState, Date.now(), agentsPanelLingerMs), - ); + publishProgramStatus(); }; if (chromeState !== null) paintChromeZones(); + else publishProgramStatus(); let disposed = false; let resolveExit: (() => void) | undefined; @@ -443,6 +479,8 @@ export async function mountProductHost( // formatAgentsPanel returns null after linger. if (stickyNeeded || stickyWasNeeded) { paintChromeZones(); + } else { + publishProgramStatus(); } stickyWasNeeded = stickyNeeded; } catch { @@ -467,6 +505,11 @@ export async function mountProductHost( config.eventEmitter.off("compaction", onCompaction); config.eventEmitter.off("workflow", onWorkflow); bridge.dispose(); + try { + programStatus.set({ state: "clear" }); + } catch { + // renderer already gone + } // Cancels any flash still counting down: its expiry repaints into a // destroyed text buffer after teardown. setStatusFlash(shell, null); @@ -559,8 +602,16 @@ export async function mountProductHost( let disposeGates: () => void; try { disposeGates = wireGates(config.eventEmitter, shell, { - onGateOpened: () => bridge.gateOpened(), - onGateClosed: () => bridge.gateClosed(), + onGateOpened: () => { + openGates += 1; + bridge.gateOpened(); + publishProgramStatus(); + }, + onGateClosed: () => { + openGates = Math.max(0, openGates - 1); + bridge.gateClosed(); + publishProgramStatus(); + }, onPrimaryOperatorAdmitted: (event) => setOperatorInputRequiredGate(shell, event), onPrimaryOperatorSettled: (event) => diff --git a/src/tui/program-status.test.ts b/src/tui/program-status.test.ts new file mode 100644 index 000000000..5fc49b936 --- /dev/null +++ b/src/tui/program-status.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, test } from "bun:test"; + +import { programStatusForChrome } from "./program-status.js"; + +const doing = { title: "ship the mapper", status: "doing" as const }; +const todo = { title: "later", status: "todo" as const }; +const liveAgent = { + agentId: "a", + description: "read the tree", + status: "running" as const, + currentToolStartedAt: null, +}; + +describe("programStatusForChrome", () => { + test("an open gate blocks even while work is in flight", () => { + expect( + programStatusForChrome({ + tasks: [doing], + agents: [liveAgent], + stalled: true, + gateOpen: true, + }), + ).toEqual({ + state: "blocked", + app: "corbits", + title: "corbits", + message: "waiting on the operator", + }); + }); + + test("a stalled turn is an error when no gate is open", () => { + expect( + programStatusForChrome({ + tasks: [doing], + stalled: true, + gateOpen: false, + }).state, + ).toBe("error"); + }); + + test("a doing task names the working record", () => { + expect( + programStatusForChrome({ + tasks: [todo, doing], + stalled: false, + gateOpen: false, + }), + ).toMatchObject({ state: "working", message: "ship the mapper" }); + }); + + test("a live agent is working when no task is doing", () => { + expect( + programStatusForChrome({ + agents: [liveAgent], + stalled: false, + gateOpen: false, + }), + ).toMatchObject({ state: "working", message: "read the tree" }); + }); + + test("an idle session reports idle, not clear", () => { + expect( + programStatusForChrome({ stalled: false, gateOpen: false }), + ).toMatchObject({ state: "idle", message: "idle" }); + }); + + test("the report addresses the root record", () => { + const status = programStatusForChrome({ + stalled: false, + gateOpen: false, + }); + expect("id" in status).toBe(false); + }); +}); diff --git a/src/tui/program-status.ts b/src/tui/program-status.ts new file mode 100644 index 000000000..6532b4b58 --- /dev/null +++ b/src/tui/program-status.ts @@ -0,0 +1,65 @@ +/** + * Pure OSC 7501 roll-up for one session. One root record (no lane id): a + * terminal that speaks the protocol replaces that record on every report. + * Lane-keyed records stay out until a real OSC 7511 terminal confirms them. + */ + +import type { ProgramStatus } from "@opentui/core"; + +import { agentLaneIsLive } from "../subagent/agent-progress.js"; +import type { ChromeAgentSession, ChromeTaskRow } from "./chrome-state.js"; + +const MESSAGE_MAX = 80; + +export interface ProgramStatusInput { + readonly tasks?: readonly ChromeTaskRow[] | null | undefined; + readonly agents?: readonly ChromeAgentSession[] | null | undefined; + /** Turn-level stall from `isStalledForDisplay`, not a per-lane clock. */ + readonly stalled: boolean; + /** A permission or operator gate is outstanding, queued or on screen. */ + readonly gateOpen: boolean; +} + +export function programStatusForChrome( + input: ProgramStatusInput, +): ProgramStatus { + if (input.gateOpen) { + return { + state: "blocked", + app: "corbits", + title: "corbits", + message: "waiting on the operator", + }; + } + if (input.stalled) { + return { + state: "error", + app: "corbits", + title: "corbits", + message: "stalled", + }; + } + const doing = input.tasks?.find((task) => task.status === "doing"); + const live = input.agents?.find((agent) => agentLaneIsLive(agent)); + if (doing !== undefined || live !== undefined) { + const raw = doing?.title ?? live?.description ?? "working"; + return { + state: "working", + app: "corbits", + title: "corbits", + message: clip(raw.length > 0 ? raw : "working"), + }; + } + return { + state: "idle", + app: "corbits", + title: "corbits", + message: "idle", + }; +} + +function clip(text: string): string { + const flat = text.replace(/\s+/g, " ").trim(); + if (flat.length <= MESSAGE_MAX) return flat; + return `${flat.slice(0, MESSAGE_MAX - 3)}...`; +} diff --git a/src/tui/runtime-bridge.ts b/src/tui/runtime-bridge.ts index b8fa186e8..0e62f1075 100644 --- a/src/tui/runtime-bridge.ts +++ b/src/tui/runtime-bridge.ts @@ -237,6 +237,9 @@ export interface SessionBridge { dispose: () => void; /** Current derived turn phase (progress label, stall clock, quota window). */ readonly turn: TurnState; + /** Turn-level stall the program-status roll-up paints. Same predicate as + * the lockup (`isStalledForDisplay`). */ + readonly isTurnStalled: () => boolean; readonly shell: AppShell; /** Refresh `spawn_agent` rows with live progress for the worker lifetime. */ syncAgentProgress: (sessions: readonly TaskProgressSession[]) => void; @@ -2250,6 +2253,7 @@ export function attachSessionBridge( get turn() { return bag.turn; }, + isTurnStalled: () => isStalledForDisplay(stallArgsFor(now())), syncAgentProgress: (sessions) => { if (bag.disposed) return; bag.agentSessions = sessions; From f4ab1998e1e73de884141e0d474885fa9a5376fd Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Wed, 14 Oct 2026 23:13:47 -0700 Subject: [PATCH 2/3] feat(tui): notify the operator when a gate needs attention --- src/tui/gate-wire.test.ts | 41 +++++++++++++++++++++++++++++++++++++++ src/tui/gate-wire.ts | 20 +++++++++++++++++++ src/tui/product-host.ts | 13 ++++++++++++- 3 files changed, 73 insertions(+), 1 deletion(-) diff --git a/src/tui/gate-wire.test.ts b/src/tui/gate-wire.test.ts index c5cea0e94..6f79285f3 100644 --- a/src/tui/gate-wire.test.ts +++ b/src/tui/gate-wire.test.ts @@ -412,6 +412,47 @@ describe("operatorChoicesFromOptions / operatorResultFromSelection", () => { }); describe("wireGates", () => { + test("notifies once when a permission gate reaches the screen", async () => { + const seen: string[] = []; + await withGates( + async ({ emitter }) => { + emitPermission(emitter, { id: "req-9" }); + emitPermission(emitter, { id: "req-9" }); + }, + { + hooks: { + notify: { + notify: (message) => { + seen.push(message); + return true; + }, + }, + }, + }, + ); + expect(seen).toEqual(["Approval needed"]); + }); + + test("notifies when an operator gate reaches the screen", async () => { + const seen: string[] = []; + await withGates( + async ({ emitter }) => { + emitOperator(emitter); + }, + { + hooks: { + notify: { + notify: (message) => { + seen.push(message); + return true; + }, + }, + }, + }, + ); + expect(seen).toEqual(["Input needed"]); + }); + test("subscribes exactly permission.gate and operator.gate; dispose removes both", async () => { await withGates(async ({ emitter, disposeGates }) => { expect(emitter.listenerCount("permission.gate")).toBe(1); diff --git a/src/tui/gate-wire.ts b/src/tui/gate-wire.ts index 04f527bc8..0f34d3c1f 100644 --- a/src/tui/gate-wire.ts +++ b/src/tui/gate-wire.ts @@ -222,6 +222,13 @@ export interface GateLifecycleHooks { readonly onPrimaryOperatorAdmitted?: (event: OperatorGateEvent) => void; /** A marked primary ask_operator gate settled. */ readonly onPrimaryOperatorSettled?: (event: OperatorGateEvent) => void; + /** Desktop/terminal notification. Fired once per gate id when that gate + * reaches the screen. Absent means no notification. */ + readonly notify?: NotifierPort; +} + +export interface NotifierPort { + notify(message: string, title?: string): boolean; } const NOOP_GATE_HOOKS: GateLifecycleHooks = { @@ -275,6 +282,17 @@ export function wireGates( // tell whether the host has since opened a newer gate; comparing the // open-time capture against the current value closes only its own overlay. let overlayGeneration = 0; + const notifiedGateIds = new Set(); + + function notifyOnce(id: string, message: string, title: string): void { + if (notifiedGateIds.has(id)) return; + notifiedGateIds.add(id); + try { + hooks.notify?.notify(message, title); + } catch { + // A notifier that throws must not block the gate. + } + } function openHost(open: () => void): void { overlayGeneration++; @@ -388,6 +406,7 @@ export function wireGates( // screen, distinct from when it was raised (see // PermissionRequest.markDisplayed). ev.request.markDisplayed?.(); + notifyOnce(ev.id, "Approval needed", "corbits"); if (ev.timeoutMs !== undefined) { timer = setTimeout(() => { autoDeny(ev.timeoutMessage ?? "approval timed out; request denied"); @@ -489,6 +508,7 @@ export function wireGates( const open = (): void => { openedGeneration = overlayGeneration; + notifyOnce(ev.id, "Input needed", "corbits"); if (ev.timeoutMs !== undefined) { timer = setTimeout(() => { autoCancel(); diff --git a/src/tui/product-host.ts b/src/tui/product-host.ts index 8c407ef42..8fefe3d19 100644 --- a/src/tui/product-host.ts +++ b/src/tui/product-host.ts @@ -20,7 +20,7 @@ import { } from "./runtime-bridge.js"; import type { ShellOutputFeed } from "../session/shell-output-feed.js"; import { openAddProviderOverlay, openModelPickerOverlay } from "./overlays.js"; -import { wireGates } from "./gate-wire.js"; +import { wireGates, type NotifierPort } from "./gate-wire.js"; import { createSystemClipboard } from "./system-clipboard.js"; import { programStatusForChrome } from "./program-status.js"; import { @@ -268,6 +268,8 @@ export interface ProductHostConfig { /** OSC 7501 sink. Default calls `renderer.setProgramStatus` and ignores a * false return (terminal inactive, output full, or destroyed). */ readonly programStatus?: ProgramStatusPort; + /** Gate attention ping. Default calls `renderer.triggerNotification`. */ + readonly notifier?: NotifierPort; } export interface ProgramStatusPort { @@ -616,6 +618,15 @@ export async function mountProductHost( setOperatorInputRequiredGate(shell, event), onPrimaryOperatorSettled: (event) => clearOperatorInputRequired(shell, event.id), + notify: config.notifier ?? { + notify: (message, title) => { + try { + return renderer.triggerNotification(message, title); + } catch { + return false; + } + }, + }, }); } catch (err: unknown) { try { From 14b43dc7150f1c10f11f9ea6d5dbf440f42acbe7 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Wed, 14 Oct 2026 23:14:42 -0700 Subject: [PATCH 3/3] refactor(tui): read clipboard images through the clipboard service --- docs/ARCHITECTURE.md | 2 +- docs/IMPLEMENTATION.md | 8 +-- docs/TUI.md | 8 +-- src/tui/image-attachments.clipboard.test.ts | 36 +++++++++++ src/tui/image-attachments.ts | 69 ++++++++++----------- src/tui/system-clipboard.test.ts | 31 ++++++++- src/tui/system-clipboard.ts | 50 +++++++++++++++ 7 files changed, 157 insertions(+), 47 deletions(-) create mode 100644 src/tui/image-attachments.clipboard.test.ts diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 51cc275ac..b782aef89 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -443,7 +443,7 @@ OpenTUI (`@opentui/core`) is the shipping shell; the Ink/React tree has been del - **Runner host** (`runner-host.ts`) — Runner-facing mount: catalog assembly from live config, chrome pushes on session change, subagent observe resolution, and session teardown (quitting is Ctrl+C twice, owned by the shell). - **Overlays and pickers** — Resume picker (`src/tui/pick-session.ts`) uses `runListModal` (`src/tui/list-modal.ts`). Slash-command surfaces (`/model`, `/settings`, `/permissions`, `/plugins`, etc.) route through `openCommandSurface` (`src/tui/command-surfaces.ts`). - **Auto mode** — Toggled by CLI flags only (`--auto` / `--no-auto`); there is currently no in-session key bound to it. -- **Send-path ingest** — Submit, leftover send, and live steer run `ingestOperatorPrompt` (`src/tui/prompt-attachments.ts`): inline image-path mentions become attachments (`findImagePathMentions` / `imageAttachmentFromPath`); `@file` contents are inlined and `@dir` mentions become a directory summary (`resolveAtMentions` in `mention-resolution.ts`). The mention popup splices paths into the prompt (`spliceMentionCompletion`); it does not send. Ctrl+V / Ctrl+P (and `/paste-image`) attach a macOS clipboard PNG (`attachClipboardImage` in `shell.ts` → `readClipboardImage`); Cmd+V stays text (bracketed paste). Linux/Windows bitmap clipboard paste is not supported. +- **Send-path ingest** — Submit, leftover send, and live steer run `ingestOperatorPrompt` (`src/tui/prompt-attachments.ts`): inline image-path mentions become attachments (`findImagePathMentions` / `imageAttachmentFromPath`); `@file` contents are inlined and `@dir` mentions become a directory summary (`resolveAtMentions` in `mention-resolution.ts`). The mention popup splices paths into the prompt (`spliceMentionCompletion`); it does not send. Ctrl+V / Ctrl+P (and `/paste-image`) attach an image from the system clipboard (`attachClipboardImage` in `shell.ts` → `readClipboardImage`); Cmd+V stays text (bracketed paste). Image clipboard reads go through the OpenTUI clipboard service (PNG, JPEG, GIF, WebP) on platforms whose host backend supports them. Known keybindings: `Ctrl+C` interrupts the in-flight run, and quits on a second press inside a two-second window. diff --git a/docs/IMPLEMENTATION.md b/docs/IMPLEMENTATION.md index 2b1a8360b..ff25131b6 100644 --- a/docs/IMPLEMENTATION.md +++ b/docs/IMPLEMENTATION.md @@ -235,10 +235,10 @@ a path into the prompt (`spliceMentionCompletion`). Attachments land at send time, not at accept time. Ctrl+V / Ctrl+P and `/paste-image` call `attachClipboardImage` (`shell.ts`) → -`readClipboardImage` (`image-attachments.ts`), which reads a macOS clipboard -PNG via osascript `«class PNGf»`. Cmd+V is terminal bracketed paste and stays -text. Linux and Windows bitmap clipboard paste is a separate product gap; do -not treat bracketed-paste path text as a paste-time image gesture. +`readClipboardImage` (`image-attachments.ts`), which reads PNG, JPEG, GIF, or +WebP through the OpenTUI clipboard service. Cmd+V is terminal bracketed paste +and stays text. Do not treat bracketed-paste path text as a paste-time image +gesture. ## Configuration diff --git a/docs/TUI.md b/docs/TUI.md index 2556d9c25..27abcf1c5 100644 --- a/docs/TUI.md +++ b/docs/TUI.md @@ -878,11 +878,11 @@ sent messages. Once a real `paste` event has fired even once, the fallback heuristic is permanently skipped for the rest of the session (`src/tui/shell/keys.ts`, the `sawBracketedPaste` guard). -Ctrl+V and Ctrl+P attach a PNG from the macOS clipboard +Ctrl+V and Ctrl+P attach an image from the system clipboard (`attachClipboardImage` in `src/tui/shell/prompt.ts` → `readClipboardImage` in -`image-attachments.ts`). -Cmd+V stays text (bracketed paste above). Clipboard image attach is -macOS-only; Linux/Windows bitmap clipboard paste is not supported. +`image-attachments.ts`, which reads through the OpenTUI clipboard service). +Cmd+V stays text (bracketed paste above). The host clipboard backend reads +PNG, JPEG, GIF, and WebP where the platform supports image clipboard data. `/paste-image` is the same attach path. @-mention path completion opens a popup keyed off the `@token` under the diff --git a/src/tui/image-attachments.clipboard.test.ts b/src/tui/image-attachments.clipboard.test.ts new file mode 100644 index 000000000..6a390dd8e --- /dev/null +++ b/src/tui/image-attachments.clipboard.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, test } from "bun:test"; + +import { readClipboardImage } from "./image-attachments.js"; + +const PNG = Buffer.from( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==", + "base64", +); + +describe("readClipboardImage", () => { + test("turns clipboard image bytes into an attachment without a source path", async () => { + const result = await readClipboardImage(() => + Promise.resolve({ + ok: true, + mimeType: "image/png", + bytes: PNG, + }), + ); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.attachment.path).toBeUndefined(); + expect(result.attachment.contentType).toBe("image/png"); + expect(result.attachment.name.startsWith("clipboard-")).toBe(true); + expect(result.attachment.data.byteLength).toBeGreaterThan(0); + }); + + test("passes a clipboard miss through", async () => { + const result = await readClipboardImage(() => + Promise.resolve({ ok: false, reason: "no image found on the clipboard" }), + ); + expect(result).toEqual({ + ok: false, + reason: "no image found on the clipboard", + }); + }); +}); diff --git a/src/tui/image-attachments.ts b/src/tui/image-attachments.ts index 24c7962b3..37f0fa89b 100644 --- a/src/tui/image-attachments.ts +++ b/src/tui/image-attachments.ts @@ -3,6 +3,10 @@ import { homedir, tmpdir } from "node:os"; import { readFile, stat, unlink, writeFile } from "node:fs/promises"; import { spawn } from "node:child_process"; import type { MessageAttachment } from "@intx/types/runtime"; +import { + readSystemClipboardImage, + type ClipboardImageBytes, +} from "./system-clipboard.js"; export const MAX_IMAGE_ATTACHMENT_BYTES = 10 * 1024 * 1024; @@ -335,48 +339,39 @@ function replaceExtension(name: string, contentType: string): string { return `${dot === -1 ? name : name.slice(0, dot)}.${ext}`; } -export async function readClipboardImage(): Promise { - if (process.platform !== "darwin") { +export async function readClipboardImage( + source: () => Promise = readSystemClipboardImage, +): Promise { + const read = await source(); + if (!read.ok) return read; + + const ext = extensionForMime(read.mimeType); + const tmpPath = join( + tmpdir(), + `corbits-clipboard-${process.pid}-${Date.now()}.${ext}`, + ); + try { + await writeFile(tmpPath, read.bytes); + const attachment = await imageAttachmentFromPath(tmpPath); + if (!attachment.ok) return attachment; + const { path: _path, ...clipboardAttachment } = attachment.attachment; return { - ok: false, - reason: "clipboard image paste is currently supported on macOS", + ok: true, + attachment: { + ...clipboardAttachment, + name: `clipboard-${new Date().toISOString().replace(/[:.]/g, "-")}.${ext}`, + }, }; - } - - const tmpPath = `/tmp/corbits-clipboard-${process.pid}-${Date.now()}.png`; - const script = ` -set outPath to POSIX file ${JSON.stringify(tmpPath)} -try - set pngData to the clipboard as «class PNGf» - set outFile to open for access outPath with write permission - set eof of outFile to 0 - write pngData to outFile - close access outFile -on error errMsg - try - close access outPath - end try - error errMsg -end try -`; - - const result = await runProcess("osascript", ["-e", script]); - if (result.code !== 0) { + } catch { + return { ok: false, reason: "could not read an image from the clipboard" }; + } finally { await unlink(tmpPath).catch(() => undefined); - return { ok: false, reason: "no PNG image found on the clipboard" }; } +} - const attachment = await imageAttachmentFromPath(tmpPath); - await unlink(tmpPath).catch(() => undefined); - if (!attachment.ok) return attachment; - const { path: _path, ...clipboardAttachment } = attachment.attachment; - return { - ok: true, - attachment: { - ...clipboardAttachment, - name: `clipboard-${new Date().toISOString().replace(/[:.]/g, "-")}.png`, - }, - }; +function extensionForMime(mimeType: string): string { + if (mimeType === "image/jpeg") return "jpg"; + return mimeType.split("/")[1] ?? "img"; } export function formatAttachmentSummary( diff --git a/src/tui/system-clipboard.test.ts b/src/tui/system-clipboard.test.ts index 3aad53113..9e3393cdf 100644 --- a/src/tui/system-clipboard.test.ts +++ b/src/tui/system-clipboard.test.ts @@ -2,7 +2,10 @@ import { describe, expect, test } from "bun:test"; import type { ClipboardService, ClipboardWriteResult } from "@opentui/core"; import { withMockedModuleDuring } from "../../testkit/mock-module.js"; -import { createSystemClipboard } from "./system-clipboard.js"; +import { + createSystemClipboard, + readSystemClipboardImage, +} from "./system-clipboard.js"; const boundary = { capabilities: null, @@ -88,4 +91,30 @@ describe("system clipboard", () => { createSystemClipboard(boundary, service).writeText("payload"), ).rejects.toThrow(/host: failed, terminal: local-failure/); }); + + test("reads an image representation from the clipboard service", async () => { + const bytes = new Uint8Array([1, 2, 3]); + const seen: string[][] = []; + const image = await readSystemClipboardImage({ + read: (options) => { + seen.push([...options.preferredTypes]); + return Promise.resolve({ + status: "read", + representation: { mimeType: "image/png", bytes }, + }); + }, + }); + expect(seen[0]).toContain("image/png"); + expect(image).toEqual({ ok: true, mimeType: "image/png", bytes }); + }); + + test("maps an empty clipboard read to a miss", async () => { + const image = await readSystemClipboardImage({ + read: () => Promise.resolve({ status: "empty" }), + }); + expect(image).toEqual({ + ok: false, + reason: "no image found on the clipboard", + }); + }); }); diff --git a/src/tui/system-clipboard.ts b/src/tui/system-clipboard.ts index 753fcc5c6..1f516b27e 100644 --- a/src/tui/system-clipboard.ts +++ b/src/tui/system-clipboard.ts @@ -18,6 +18,56 @@ import { import type { ClipboardPort } from "./copy-path.js"; +const IMAGE_PREFERRED_TYPES = [ + "image/png", + "image/jpeg", + "image/gif", + "image/webp", +] as const; + +export type ClipboardImageBytes = + | { readonly ok: true; readonly mimeType: string; readonly bytes: Uint8Array } + | { readonly ok: false; readonly reason: string }; + +export async function readSystemClipboardImage( + service: Pick = createClipboard({ + host: createHostClipboard(), + terminal: { + remote: false, + writeText: () => ({ status: "not-attempted", capability: "unsupported" }), + clear: () => ({ status: "not-attempted", capability: "unsupported" }), + }, + }), +): Promise { + const result = await service.read({ + preferredTypes: IMAGE_PREFERRED_TYPES, + }); + if (result.status === "read") { + const mimeType = result.representation.mimeType; + if ( + !IMAGE_PREFERRED_TYPES.includes( + mimeType as (typeof IMAGE_PREFERRED_TYPES)[number], + ) + ) { + return { ok: false, reason: "clipboard did not contain an image" }; + } + return { ok: true, mimeType, bytes: result.representation.bytes }; + } + if (result.status === "empty") { + return { ok: false, reason: "no image found on the clipboard" }; + } + if (result.status === "unsupported") { + return { + ok: false, + reason: "clipboard image paste is not supported here", + }; + } + if (result.status === "failed") { + return { ok: false, reason: result.error.message }; + } + return { ok: false, reason: "could not read an image from the clipboard" }; +} + export function createSystemClipboard( renderer: RendererClipboardBoundary, service: ClipboardService = createClipboard({