Skip to content

Commit 0cf1c32

Browse files
Merge pull request #1449 from corbitsdev/cl-10648-tui-program-status
feat(tui): report session state and read clipboard images
2 parents ad86ed7 + 14b43dc commit 0cf1c32

15 files changed

Lines changed: 443 additions & 56 deletions

‎docs/ARCHITECTURE.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -443,7 +443,7 @@ OpenTUI (`@opentui/core`) is the shipping shell; the Ink/React tree has been del
443443
- **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).
444444
- **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`).
445445
- **Auto mode** — Toggled by CLI flags only (`--auto` / `--no-auto`); there is currently no in-session key bound to it.
446-
- **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.
446+
- **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.
447447

448448
Known keybindings: `Ctrl+C` interrupts the in-flight run, and quits on a second press inside a two-second window.
449449

‎docs/IMPLEMENTATION.md‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -235,10 +235,10 @@ a path into the prompt (`spliceMentionCompletion`). Attachments land at send
235235
time, not at accept time.
236236

237237
Ctrl+V / Ctrl+P and `/paste-image` call `attachClipboardImage` (`shell.ts`) →
238-
`readClipboardImage` (`image-attachments.ts`), which reads a macOS clipboard
239-
PNG via osascript `«class PNGf»`. Cmd+V is terminal bracketed paste and stays
240-
text. Linux and Windows bitmap clipboard paste is a separate product gap; do
241-
not treat bracketed-paste path text as a paste-time image gesture.
238+
`readClipboardImage` (`image-attachments.ts`), which reads PNG, JPEG, GIF, or
239+
WebP through the OpenTUI clipboard service. Cmd+V is terminal bracketed paste
240+
and stays text. Do not treat bracketed-paste path text as a paste-time image
241+
gesture.
242242

243243
## Configuration
244244

‎docs/TUI.md‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -878,11 +878,11 @@ sent messages. Once a real `paste` event has fired even once, the fallback
878878
heuristic is permanently skipped for the rest of the session
879879
(`src/tui/shell/keys.ts`, the `sawBracketedPaste` guard).
880880

881-
Ctrl+V and Ctrl+P attach a PNG from the macOS clipboard
881+
Ctrl+V and Ctrl+P attach an image from the system clipboard
882882
(`attachClipboardImage` in `src/tui/shell/prompt.ts` → `readClipboardImage` in
883-
`image-attachments.ts`).
884-
Cmd+V stays text (bracketed paste above). Clipboard image attach is
885-
macOS-only; Linux/Windows bitmap clipboard paste is not supported.
883+
`image-attachments.ts`, which reads through the OpenTUI clipboard service).
884+
Cmd+V stays text (bracketed paste above). The host clipboard backend reads
885+
PNG, JPEG, GIF, and WebP where the platform supports image clipboard data.
886886
`/paste-image` is the same attach path.
887887

888888
@-mention path completion opens a popup keyed off the `@token` under the

‎src/tui/chrome-state.test.ts‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -651,6 +651,11 @@ describe("chromeFromSession", () => {
651651
expect(zones.agents?.[0]?.label).toContain("explorer");
652652
});
653653

654+
test("copies a turn-level stall flag onto the live snapshot", () => {
655+
expect(chromeFromSession({ stalled: true }).stalled).toBe(true);
656+
expect(chromeFromSession({}).stalled).toBeUndefined();
657+
});
658+
654659
test("falls back agent id; empty bags hide", () => {
655660
const state = chromeFromSession({
656661
tasks: [],

‎src/tui/chrome-state.ts‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,9 @@ export interface ChromeLiveState {
8282
readonly agentId: string;
8383
readonly description: string;
8484
} | null;
85+
/** Turn-level stall (`isStalledForDisplay`). Absent means the host reads
86+
* the live watchdog instead of this snapshot. */
87+
readonly stalled?: boolean;
8588
}
8689

8790
/** One rendered agents-panel row. `stalled` comes precomputed so the
@@ -513,6 +516,7 @@ export interface ChromeSessionInput {
513516
readonly tasks?: readonly ChromeSessionTask[] | null;
514517
readonly agents?: readonly ChromeSessionAgent[] | null;
515518
readonly observe?: ChromeLiveState["observe"];
519+
readonly stalled?: boolean;
516520
}
517521

518522
/** Map real session shapes (tasks / subagent store) into ChromeLiveState.
@@ -521,11 +525,13 @@ export function chromeFromSession(input: ChromeSessionInput): ChromeLiveState {
521525
const task = mapSessionTasks(input.tasks);
522526
const agents = mapSessionAgents(input.agents);
523527
const observe = input.observe ?? null;
528+
const stalled = input.stalled;
524529

525530
return {
526531
...(task !== undefined ? { task } : {}),
527532
...(agents !== undefined ? { agents } : {}),
528533
...(observe !== null && observe !== undefined ? { observe } : {}),
534+
...(stalled !== undefined ? { stalled } : {}),
529535
};
530536
}
531537

‎src/tui/gate-wire.test.ts‎

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -412,6 +412,47 @@ describe("operatorChoicesFromOptions / operatorResultFromSelection", () => {
412412
});
413413

414414
describe("wireGates", () => {
415+
test("notifies once when a permission gate reaches the screen", async () => {
416+
const seen: string[] = [];
417+
await withGates(
418+
async ({ emitter }) => {
419+
emitPermission(emitter, { id: "req-9" });
420+
emitPermission(emitter, { id: "req-9" });
421+
},
422+
{
423+
hooks: {
424+
notify: {
425+
notify: (message) => {
426+
seen.push(message);
427+
return true;
428+
},
429+
},
430+
},
431+
},
432+
);
433+
expect(seen).toEqual(["Approval needed"]);
434+
});
435+
436+
test("notifies when an operator gate reaches the screen", async () => {
437+
const seen: string[] = [];
438+
await withGates(
439+
async ({ emitter }) => {
440+
emitOperator(emitter);
441+
},
442+
{
443+
hooks: {
444+
notify: {
445+
notify: (message) => {
446+
seen.push(message);
447+
return true;
448+
},
449+
},
450+
},
451+
},
452+
);
453+
expect(seen).toEqual(["Input needed"]);
454+
});
455+
415456
test("subscribes exactly permission.gate and operator.gate; dispose removes both", async () => {
416457
await withGates(async ({ emitter, disposeGates }) => {
417458
expect(emitter.listenerCount("permission.gate")).toBe(1);

‎src/tui/gate-wire.ts‎

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -222,6 +222,13 @@ export interface GateLifecycleHooks {
222222
readonly onPrimaryOperatorAdmitted?: (event: OperatorGateEvent) => void;
223223
/** A marked primary ask_operator gate settled. */
224224
readonly onPrimaryOperatorSettled?: (event: OperatorGateEvent) => void;
225+
/** Desktop/terminal notification. Fired once per gate id when that gate
226+
* reaches the screen. Absent means no notification. */
227+
readonly notify?: NotifierPort;
228+
}
229+
230+
export interface NotifierPort {
231+
notify(message: string, title?: string): boolean;
225232
}
226233

227234
const NOOP_GATE_HOOKS: GateLifecycleHooks = {
@@ -275,6 +282,17 @@ export function wireGates(
275282
// tell whether the host has since opened a newer gate; comparing the
276283
// open-time capture against the current value closes only its own overlay.
277284
let overlayGeneration = 0;
285+
const notifiedGateIds = new Set<string>();
286+
287+
function notifyOnce(id: string, message: string, title: string): void {
288+
if (notifiedGateIds.has(id)) return;
289+
notifiedGateIds.add(id);
290+
try {
291+
hooks.notify?.notify(message, title);
292+
} catch {
293+
// A notifier that throws must not block the gate.
294+
}
295+
}
278296

279297
function openHost(open: () => void): void {
280298
overlayGeneration++;
@@ -388,6 +406,7 @@ export function wireGates(
388406
// screen, distinct from when it was raised (see
389407
// PermissionRequest.markDisplayed).
390408
ev.request.markDisplayed?.();
409+
notifyOnce(ev.id, "Approval needed", "corbits");
391410
if (ev.timeoutMs !== undefined) {
392411
timer = setTimeout(() => {
393412
autoDeny(ev.timeoutMessage ?? "approval timed out; request denied");
@@ -489,6 +508,7 @@ export function wireGates(
489508

490509
const open = (): void => {
491510
openedGeneration = overlayGeneration;
511+
notifyOnce(ev.id, "Input needed", "corbits");
492512
if (ev.timeoutMs !== undefined) {
493513
timer = setTimeout(() => {
494514
autoCancel();
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import { describe, expect, test } from "bun:test";
2+
3+
import { readClipboardImage } from "./image-attachments.js";
4+
5+
const PNG = Buffer.from(
6+
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==",
7+
"base64",
8+
);
9+
10+
describe("readClipboardImage", () => {
11+
test("turns clipboard image bytes into an attachment without a source path", async () => {
12+
const result = await readClipboardImage(() =>
13+
Promise.resolve({
14+
ok: true,
15+
mimeType: "image/png",
16+
bytes: PNG,
17+
}),
18+
);
19+
expect(result.ok).toBe(true);
20+
if (!result.ok) return;
21+
expect(result.attachment.path).toBeUndefined();
22+
expect(result.attachment.contentType).toBe("image/png");
23+
expect(result.attachment.name.startsWith("clipboard-")).toBe(true);
24+
expect(result.attachment.data.byteLength).toBeGreaterThan(0);
25+
});
26+
27+
test("passes a clipboard miss through", async () => {
28+
const result = await readClipboardImage(() =>
29+
Promise.resolve({ ok: false, reason: "no image found on the clipboard" }),
30+
);
31+
expect(result).toEqual({
32+
ok: false,
33+
reason: "no image found on the clipboard",
34+
});
35+
});
36+
});

‎src/tui/image-attachments.ts‎

Lines changed: 32 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,10 @@ import { homedir, tmpdir } from "node:os";
33
import { readFile, stat, unlink, writeFile } from "node:fs/promises";
44
import { spawn } from "node:child_process";
55
import type { MessageAttachment } from "@intx/types/runtime";
6+
import {
7+
readSystemClipboardImage,
8+
type ClipboardImageBytes,
9+
} from "./system-clipboard.js";
610

711
export const MAX_IMAGE_ATTACHMENT_BYTES = 10 * 1024 * 1024;
812

@@ -335,48 +339,39 @@ function replaceExtension(name: string, contentType: string): string {
335339
return `${dot === -1 ? name : name.slice(0, dot)}.${ext}`;
336340
}
337341

338-
export async function readClipboardImage(): Promise<ClipboardImageResult> {
339-
if (process.platform !== "darwin") {
342+
export async function readClipboardImage(
343+
source: () => Promise<ClipboardImageBytes> = readSystemClipboardImage,
344+
): Promise<ClipboardImageResult> {
345+
const read = await source();
346+
if (!read.ok) return read;
347+
348+
const ext = extensionForMime(read.mimeType);
349+
const tmpPath = join(
350+
tmpdir(),
351+
`corbits-clipboard-${process.pid}-${Date.now()}.${ext}`,
352+
);
353+
try {
354+
await writeFile(tmpPath, read.bytes);
355+
const attachment = await imageAttachmentFromPath(tmpPath);
356+
if (!attachment.ok) return attachment;
357+
const { path: _path, ...clipboardAttachment } = attachment.attachment;
340358
return {
341-
ok: false,
342-
reason: "clipboard image paste is currently supported on macOS",
359+
ok: true,
360+
attachment: {
361+
...clipboardAttachment,
362+
name: `clipboard-${new Date().toISOString().replace(/[:.]/g, "-")}.${ext}`,
363+
},
343364
};
344-
}
345-
346-
const tmpPath = `/tmp/corbits-clipboard-${process.pid}-${Date.now()}.png`;
347-
const script = `
348-
set outPath to POSIX file ${JSON.stringify(tmpPath)}
349-
try
350-
set pngData to the clipboard as «class PNGf»
351-
set outFile to open for access outPath with write permission
352-
set eof of outFile to 0
353-
write pngData to outFile
354-
close access outFile
355-
on error errMsg
356-
try
357-
close access outPath
358-
end try
359-
error errMsg
360-
end try
361-
`;
362-
363-
const result = await runProcess("osascript", ["-e", script]);
364-
if (result.code !== 0) {
365+
} catch {
366+
return { ok: false, reason: "could not read an image from the clipboard" };
367+
} finally {
365368
await unlink(tmpPath).catch(() => undefined);
366-
return { ok: false, reason: "no PNG image found on the clipboard" };
367369
}
370+
}
368371

369-
const attachment = await imageAttachmentFromPath(tmpPath);
370-
await unlink(tmpPath).catch(() => undefined);
371-
if (!attachment.ok) return attachment;
372-
const { path: _path, ...clipboardAttachment } = attachment.attachment;
373-
return {
374-
ok: true,
375-
attachment: {
376-
...clipboardAttachment,
377-
name: `clipboard-${new Date().toISOString().replace(/[:.]/g, "-")}.png`,
378-
},
379-
};
372+
function extensionForMime(mimeType: string): string {
373+
if (mimeType === "image/jpeg") return "jpg";
374+
return mimeType.split("/")[1] ?? "img";
380375
}
381376

382377
export function formatAttachmentSummary(

0 commit comments

Comments
 (0)