From 08631410ed901d805f3368822ce4ea48e3ac79dc Mon Sep 17 00:00:00 2001 From: Ben Wachman Date: Sat, 10 Oct 2026 00:03:53 -0400 Subject: [PATCH 1/2] feat(tui): add a view model for parked worker questions --- src/tui/worker-wait.test.ts | 242 ++++++++++++++++++++++++++++++ src/tui/worker-wait.ts | 284 ++++++++++++++++++++++++++++++++++++ 2 files changed, 526 insertions(+) create mode 100644 src/tui/worker-wait.test.ts create mode 100644 src/tui/worker-wait.ts diff --git a/src/tui/worker-wait.test.ts b/src/tui/worker-wait.test.ts new file mode 100644 index 000000000..058a6b9eb --- /dev/null +++ b/src/tui/worker-wait.test.ts @@ -0,0 +1,242 @@ +import { describe, expect, test } from "bun:test"; + +import type { PendingAskWake } from "../subagent/fleet-report.js"; +import { stringWidth } from "./view/height.js"; +import { + composeWorkerWaitLine, + NO_WORKER_WAIT, + reduceWorkerWait, + selectedWorkerWait, + workerWaitMoreCount, + type WorkerWaitPart, + type WorkerWaitState, +} from "./worker-wait.js"; + +function ask( + sessionId: string, + questionId: string, + question = `question ${questionId}`, +): PendingAskWake { + return { + sessionId, + agentId: "builder", + description: `work ${sessionId}`, + question, + questionId, + }; +} + +function fold(...snapshots: (readonly PendingAskWake[])[]): WorkerWaitState { + return snapshots.reduce(reduceWorkerWait, NO_WORKER_WAIT); +} + +function text(parts: readonly WorkerWaitPart[]): string { + return parts.map((part) => part.text).join(""); +} + +function selectedIdentity(state: WorkerWaitState): readonly string[] | null { + const item = selectedWorkerWait(state); + return item === null ? null : [item.sessionId, item.questionId]; +} + +describe("worker wait view model", () => { + test("an empty snapshot keeps the strip inactive", () => { + const state = fold([]); + expect(state).toBe(NO_WORKER_WAIT); + expect(selectedWorkerWait(state)).toBeNull(); + expect(composeWorkerWaitLine(state, 80)).toEqual([]); + }); + + test("one pending ask activates one selected item", () => { + const state = fold([ask("s1", "q1")]); + expect(state.items).toHaveLength(1); + expect(selectedIdentity(state)).toEqual(["s1", "q1"]); + expect(workerWaitMoreCount(state)).toBe(0); + }); + + test("repeated reports of one identity produce one item and keep the same state", () => { + const first = fold([ask("s1", "q1")]); + const again = reduceWorkerWait(first, [ask("s1", "q1")]); + expect(again).toBe(first); + const doubled = fold([ask("s1", "q1"), ask("s1", "q1")]); + expect(doubled.items).toHaveLength(1); + }); + + test("a new question for the same session replaces the old item atomically", () => { + const before = fold([ask("s1", "q1", "Old preview?")]); + const after = reduceWorkerWait(before, [ask("s1", "q2", "New preview?")]); + expect(after.items).toHaveLength(1); + expect(selectedIdentity(after)).toEqual(["s1", "q2"]); + expect(text(composeWorkerWaitLine(after, 120))).toContain("New preview?"); + expect(text(composeWorkerWaitLine(after, 120))).not.toContain( + "Old preview?", + ); + }); + + test("a replaced question does not inherit the selection", () => { + const before = fold([ask("s1", "q1"), ask("s2", "q2")]); + expect(selectedIdentity(before)).toEqual(["s1", "q1"]); + // s1's new question moves behind s2 in source order; the old selected + // identity is gone, so selection falls back to the first live item. + const after = reduceWorkerWait(before, [ask("s2", "q2"), ask("s1", "q9")]); + expect(selectedIdentity(after)).toEqual(["s2", "q2"]); + }); + + test("multiple wakes keep the displayed identity while it stays live", () => { + const before = fold([ask("s1", "q1")]); + const after = reduceWorkerWait(before, [ + ask("s0", "q0"), + ask("s1", "q1"), + ask("s2", "q2"), + ]); + expect(selectedIdentity(after)).toEqual(["s1", "q1"]); + expect(workerWaitMoreCount(after)).toBe(2); + expect(after.items.map((item) => item.sessionId)).toEqual([ + "s0", + "s1", + "s2", + ]); + }); + + test("selection falls back to snapshot order when the displayed identity leaves", () => { + const before = fold([ask("s1", "q1"), ask("s2", "q2"), ask("s3", "q3")]); + const after = reduceWorkerWait(before, [ask("s3", "q3"), ask("s2", "q2")]); + expect(selectedIdentity(after)).toEqual(["s3", "q3"]); + expect(workerWaitMoreCount(after)).toBe(1); + }); + + test("the additional count tracks the live snapshot", () => { + let state = fold([ask("s1", "q1"), ask("s2", "q2"), ask("s3", "q3")]); + expect(text(composeWorkerWaitLine(state, 160))).toContain("(+2 more)"); + state = reduceWorkerWait(state, [ask("s1", "q1"), ask("s3", "q3")]); + expect(text(composeWorkerWaitLine(state, 160))).toContain("(+1 more)"); + state = reduceWorkerWait(state, [ask("s1", "q1")]); + expect(text(composeWorkerWaitLine(state, 160))).not.toContain("more"); + }); + + test("a re-surfaced wake restating the same identity never duplicates the item", () => { + const state = fold([ask("s1", "q1")]); + // The stall-abort re-surface restates the question to the director; the + // fleet snapshot it rides on still names the same identity. + const resurfaced = reduceWorkerWait(state, [ask("s1", "q1")]); + expect(resurfaced).toBe(state); + expect(resurfaced.items).toHaveLength(1); + }); + + test("an item leaves only when the snapshot omits its identity", () => { + let state = fold([ask("s1", "q1"), ask("s2", "q2")]); + state = reduceWorkerWait(state, [ask("s2", "q2")]); + expect(state.items.map((item) => item.sessionId)).toEqual(["s2"]); + state = reduceWorkerWait(state, []); + expect(state.items).toEqual([]); + expect(state.selectedKey).toBeNull(); + expect(composeWorkerWaitLine(state, 80)).toEqual([]); + }); + + test("identity halves cannot alias across sessions", () => { + const state = fold([ask("a", "b:c"), ask("a:b", "c")]); + expect(state.items).toHaveLength(2); + expect(new Set(state.items.map((item) => item.key)).size).toBe(2); + }); + + test("teardown state renders nothing", () => { + const live = fold([ask("s1", "q1")]); + expect(composeWorkerWaitLine(live, 80)).not.toEqual([]); + expect(composeWorkerWaitLine(NO_WORKER_WAIT, 80)).toEqual([]); + }); +}); + +describe("worker wait strip copy", () => { + const destination = fold([ + { + sessionId: "sess-1", + agentId: "builder", + description: "copy assets", + question: "Which destination path should I use?", + questionId: "q1", + }, + ]); + + test("full width names the waiting worker, its question, and director ownership", () => { + const line = text(composeWorkerWaitLine(destination, 120)); + expect(line).toContain("WORKER WAITING"); + expect(line).toContain("director reply needed"); + expect(line).toContain("send_input"); + expect(line).toContain("builder (copy assets)"); + expect(line).toContain("Which destination path should I use?"); + }); + + test("the waiting label and routing copy carry the meaning without color", () => { + const parts = composeWorkerWaitLine(destination, 120); + const roles = new Map(parts.map((part) => [part.role, part.text])); + expect(roles.get("label")).toBe("WORKER WAITING"); + expect(roles.get("routing")).toContain("director"); + }); + + test("every width fits the strip inside its row", () => { + const many = fold([ + ask("s1", "q1", "A long question ".repeat(12)), + ask("s2", "q2"), + ask("s3", "q3"), + ]); + for (let width = 1; width <= 160; width++) { + const parts = composeWorkerWaitLine(many, width); + expect(stringWidth(text(parts))).toBeLessThanOrEqual(width); + expect(parts.length).toBeGreaterThan(0); + } + }); + + test("narrowing truncates the question before requester metadata", () => { + const question = "Which destination path should I use for the bundle?"; + const long = fold([ask("s1", "q1", question), ask("s2", "q2")]); + const partAt = (width: number, role: WorkerWaitPart["role"]) => + composeWorkerWaitLine(long, width).find((part) => part.role === role) + ?.text; + expect(partAt(200, "question")).toBe(question); + expect(partAt(200, "requester")).toBe("builder (work s1)"); + // A standard 80-column terminal still previews the question. + expect(partAt(78, "question")).toEndWith("…"); + expect(text(composeWorkerWaitLine(long, 78))).toContain("(+1 more)"); + for (let width = 200; width >= 1; width--) { + const preview = partAt(width, "question"); + const requester = partAt(width, "requester"); + // Metadata only shrinks once the question is already cut short. + if (requester !== "builder (work s1)") { + expect(preview === undefined || preview.endsWith("…")).toBe(true); + } + // The question only drops once the description is already gone. + if (preview === undefined) { + expect(requester === undefined || requester === "builder").toBe(true); + } + } + }); + + test("narrow widths keep a compact explicit label and the additional count", () => { + const two = fold([ask("s1", "q1"), ask("s2", "q2")]); + const narrow = text(composeWorkerWaitLine(two, 30)); + expect(narrow).toContain("WAITING"); + expect(narrow).toContain("(+1 more)"); + const tiny = text(composeWorkerWaitLine(two, 20)); + expect(tiny).toContain("WAITING"); + expect(tiny).toContain("(+1 more)"); + }); + + test("worker text cannot inject terminal sequences or extra rows", () => { + const hostile = fold([ + { + sessionId: "s1", + agentId: "builder\u001b[2J", + description: "desc\nnext", + question: + "Pick\u001b[31m red\u001b[0m\r\nor \u001b]52;c;Zm9v\u0007blue?", + questionId: "q1", + }, + ]); + const line = text(composeWorkerWaitLine(hostile, 200)); + expect(line).not.toContain("\u001b"); + expect(line).not.toContain("\n"); + expect(line).not.toContain("\r"); + expect(line).toContain("Pick red or blue?"); + expect(line).toContain("builder (desc next)"); + }); +}); diff --git a/src/tui/worker-wait.ts b/src/tui/worker-wait.ts new file mode 100644 index 000000000..487960974 --- /dev/null +++ b/src/tui/worker-wait.ts @@ -0,0 +1,284 @@ +/** + * The WORKER WAITING strip: a standing, composer-adjacent line shown while a + * root worker is parked on `ask_director`. + * + * The runtime already wakes the primary agent with the question, but a wake + * turn can scroll out of a long transcript and leave the terminal looking + * idle. The strip keeps the fact on screen and says who owns the answer: the + * director, through `send_input`. It is display only. Nothing here routes + * composer text to a worker or settles an ask. + * + * Pure: pending-ask snapshots in, a view model and styled parts out. The + * snapshot is the only source of truth; an item leaves only when a later + * snapshot omits its `sessionId + questionId`. Paint lives in + * shell/chrome.ts; the row budget lives in geometry (zone `worker_wait`). + */ + +import type { PendingAskWake } from "../subagent/fleet-report.js"; +import { stripTerminalControlSequences } from "../util/control-char-strip.js"; +import { sliceToWidth, stringWidth } from "./view/height.js"; + +export interface WorkerWaitItem { + /** Identity: `sessionId + questionId`, encoded so neither half can alias. */ + readonly key: string; + readonly sessionId: string; + readonly questionId: string; + readonly agentId: string; + readonly description: string; + readonly question: string; +} + +export interface WorkerWaitState { + /** Live waits in snapshot order, one per session. */ + readonly items: readonly WorkerWaitItem[]; + /** Identity the strip shows, or null when nothing is waiting. */ + readonly selectedKey: string | null; +} + +export const NO_WORKER_WAIT: WorkerWaitState = { items: [], selectedKey: null }; + +function identityKey(sessionId: string, questionId: string): string { + return JSON.stringify([sessionId, questionId]); +} + +function sameItem(a: WorkerWaitItem, b: WorkerWaitItem): boolean { + return ( + a.key === b.key && + a.agentId === b.agentId && + a.description === b.description && + a.question === b.question + ); +} + +/** + * Fold the next authoritative snapshot into the view model. + * + * One item per session, last report wins: a session has a single live + * question, so a new `questionId` replaces the old one in place and starts + * fresh (the old identity is gone, so it cannot keep the selection). The + * selection survives while its identity is still live; otherwise it falls + * back to the first item in snapshot order, because the source carries no + * timestamps to order by. An unchanged snapshot returns `prev` itself so a + * repeat report costs the chrome nothing. + */ +export function reduceWorkerWait( + prev: WorkerWaitState, + asks: readonly PendingAskWake[], +): WorkerWaitState { + const bySession = new Map(); + for (const ask of asks) { + bySession.set(ask.sessionId, { + key: identityKey(ask.sessionId, ask.questionId), + sessionId: ask.sessionId, + questionId: ask.questionId, + agentId: ask.agentId, + description: ask.description, + question: ask.question, + }); + } + const items = [...bySession.values()]; + const kept = + prev.selectedKey !== null && + items.some((item) => item.key === prev.selectedKey); + const selectedKey = kept ? prev.selectedKey : (items[0]?.key ?? null); + if ( + selectedKey === prev.selectedKey && + items.length === prev.items.length && + items.every((item, i) => { + const before = prev.items[i]; + return before !== undefined && sameItem(item, before); + }) + ) { + return prev; + } + return { items, selectedKey }; +} + +export function selectedWorkerWait( + state: WorkerWaitState, +): WorkerWaitItem | null { + return state.items.find((item) => item.key === state.selectedKey) ?? null; +} + +/** Live waits beyond the one on screen. */ +export function workerWaitMoreCount(state: WorkerWaitState): number { + return Math.max(0, state.items.length - 1); +} + +export type WorkerWaitRole = + | "mark" + | "label" + | "separator" + | "routing" + | "requester" + | "question" + | "more"; + +export interface WorkerWaitPart { + readonly text: string; + readonly role: WorkerWaitRole; +} + +/** + * Worker-supplied text on one row. Terminal sequences are stripped so a + * worker cannot restyle chrome or move the cursor; line breaks fold into + * single spaces so the preview never claims a second row. + */ +export function oneLinePreview(text: string): string { + return stripTerminalControlSequences(text).replace(/\s+/g, " ").trim(); +} + +const MARK = "◆"; +const LABEL = "WORKER WAITING"; +const LABEL_COMPACT = "WAITING"; +const ROUTING_FULL = "director reply needed (send_input)"; +const ROUTING = "director reply needed"; +const ROUTING_COMPACT = "director reply"; +const SEPARATOR = " · "; +const ELLIPSIS = "…"; + +/** + * Narrowest question preview worth keeping. Below this the cells say more as + * requester metadata than as a few characters of question. + */ +const QUESTION_MIN_CELLS = 12; + +function partsWidth(parts: readonly WorkerWaitPart[]): number { + return parts.reduce((sum, part) => sum + stringWidth(part.text), 0); +} + +function fitQuestion(question: string, cells: number): string { + if (stringWidth(question) <= cells) return question; + if (cells <= stringWidth(ELLIPSIS)) return ""; + return sliceToWidth(question, cells - stringWidth(ELLIPSIS)) + ELLIPSIS; +} + +interface Tier { + readonly label: string; + readonly routing: string | null; + readonly requester: string | null; + readonly question: boolean; +} + +const COMPACT_TIER: Tier = { + label: LABEL_COMPACT, + routing: null, + requester: null, + question: false, +}; + +function head(tier: Tier): WorkerWaitPart[] { + const parts: WorkerWaitPart[] = [ + { text: `${MARK} `, role: "mark" }, + { text: tier.label, role: "label" }, + ]; + if (tier.routing !== null) { + parts.push({ text: SEPARATOR, role: "separator" }); + parts.push({ text: tier.routing, role: "routing" }); + } + if (tier.requester !== null) { + parts.push({ text: SEPARATOR, role: "separator" }); + parts.push({ text: tier.requester, role: "requester" }); + } + return parts; +} + +/** + * The strip's styled parts for a content width, or [] when nothing waits. + * + * Degrades in a fixed order so the meaning that matters most survives + * longest: the question truncates first, down to a short preview; then the + * requester loses its description and the routing copy compacts; then the + * preview drops, then the requester id, then the routing copy, then the + * label shortens. The `(+N more)` count stays until only a hard slice of the + * compact label is left. + */ +export function composeWorkerWaitLine( + state: WorkerWaitState, + width: number, +): readonly WorkerWaitPart[] { + const item = selectedWorkerWait(state); + if (item === null || width <= 0) return []; + const agentId = oneLinePreview(item.agentId); + const description = oneLinePreview(item.description); + const question = oneLinePreview(item.question); + const requesterFull = + description.length > 0 && description !== agentId + ? agentId.length > 0 + ? `${agentId} (${description})` + : description + : agentId; + const requesterId = agentId.length > 0 ? agentId : null; + const more = workerWaitMoreCount(state); + const moreParts: WorkerWaitPart[] = + more > 0 ? [{ text: ` (+${more} more)`, role: "more" }] : []; + const moreCost = partsWidth(moreParts); + const hasQuestion = question.length > 0; + const fullRequester = requesterFull.length > 0 ? requesterFull : null; + + const q = hasQuestion; + const tiers: readonly Tier[] = [ + { + label: LABEL, + routing: ROUTING_FULL, + requester: fullRequester, + question: q, + }, + { label: LABEL, routing: ROUTING, requester: fullRequester, question: q }, + { label: LABEL, routing: ROUTING, requester: requesterId, question: q }, + { + label: LABEL, + routing: ROUTING_COMPACT, + requester: requesterId, + question: q, + }, + { label: LABEL, routing: ROUTING_COMPACT, requester: null, question: q }, + { + label: LABEL, + routing: ROUTING_COMPACT, + requester: requesterId, + question: false, + }, + { + label: LABEL, + routing: ROUTING_COMPACT, + requester: null, + question: false, + }, + { label: LABEL, routing: null, requester: null, question: false }, + COMPACT_TIER, + ]; + + for (const tier of tiers) { + const parts = head(tier); + if (tier.question) { + const colon: WorkerWaitPart = { + text: tier.requester !== null ? ": " : SEPARATOR, + role: "separator", + }; + const room = + width - partsWidth(parts) - stringWidth(colon.text) - moreCost; + const need = Math.min(QUESTION_MIN_CELLS, stringWidth(question)); + if (room < need) continue; + parts.push(colon, { + text: fitQuestion(question, room), + role: "question", + }); + return [...parts, ...moreParts]; + } + if (partsWidth(parts) + moreCost <= width) return [...parts, ...moreParts]; + } + + // Narrower than even the compact label and its count: slice what is left. + const last = [...head(COMPACT_TIER), ...moreParts]; + const sliced: WorkerWaitPart[] = []; + let remaining = width; + for (const part of last) { + if (remaining <= 0) break; + const text = sliceToWidth(part.text, remaining); + if (text.length === 0) break; + sliced.push({ text, role: part.role }); + remaining -= stringWidth(text); + } + return sliced; +} From 108bae64a37934e5a1929cec19d0e590d19375f5 Mon Sep 17 00:00:00 2001 From: Ben Wachman Date: Sat, 10 Oct 2026 00:04:01 -0400 Subject: [PATCH 2/2] feat(tui): show parked worker questions on the prompt box --- docs/TUI.md | 24 ++ src/tui/chrome-repaint.test.ts | 41 ++++ src/tui/geometry.test.ts | 26 +++ src/tui/geometry/resolve.ts | 3 + src/tui/geometry/zones.ts | 19 +- src/tui/runner/wiring.ask-wake.test.ts | 289 ++++++++++++++++++++++++- src/tui/runtime-bridge.ts | 9 + src/tui/shell/chrome.ts | 94 +++++++- src/tui/shell/index.ts | 13 ++ src/tui/shell/internals.ts | 11 + src/tui/worker-wait-chrome.test.ts | 246 +++++++++++++++++++++ 11 files changed, 768 insertions(+), 7 deletions(-) create mode 100644 src/tui/worker-wait-chrome.test.ts diff --git a/docs/TUI.md b/docs/TUI.md index a2a7a9497..a41c2f226 100644 --- a/docs/TUI.md +++ b/docs/TUI.md @@ -307,6 +307,30 @@ poll uses needed it **does not** call `bridge.syncAgentProgress` — chrome owns the live clocks. +### WORKER WAITING strip + +While any root worker is parked on `ask_director`, a one-row **WORKER +WAITING** strip sits directly on the prompt box (zone `worker_wait`): + +``` +◆ WORKER WAITING · director reply needed (send_input) · builder (copy assets): Which destination path should I use? (+1 more) +``` + +It names the worker, previews its question, and says the director owns the +reply. It is display only: composer text still goes to the primary director, +and only the director's `send_input` to the worker's session answers the +worker. The strip reads the `agent-ask` pending snapshot and nothing else +(`src/tui/worker-wait.ts`, painted by `paintChrome` in `shell/chrome.ts`). An +item's identity is `sessionId + questionId`, and it leaves only when a later +snapshot omits that identity. Delivering the wake, director inference, +typing, submitting, scrolling, and redraws do not clear it. Session rotation, +stop teardown, and bridge dispose clear it. One question shows at a time, +with `(+N more)` for the rest; the displayed identity stays while it is live, +and otherwise the first snapshot entry shows. At narrow widths the question +truncates first, then requester metadata and routing copy shorten, while the +waiting label and the count stay. Primary-session `ask_operator` prompts keep +their own overlay and never use this strip. + ### Transcript spawn_agent rows (history anchors) `runtime-bridge` paints each `spawn_agent` call as a transcript stream row for diff --git a/src/tui/chrome-repaint.test.ts b/src/tui/chrome-repaint.test.ts index 96d836bf9..81b5e9176 100644 --- a/src/tui/chrome-repaint.test.ts +++ b/src/tui/chrome-repaint.test.ts @@ -6,9 +6,11 @@ import { describe, expect, test } from "bun:test"; import { withTestRenderer } from "./harness"; import { chromeComposeCount, + clearWorkerWait, paintChrome, setLockupFrame, setStatusFlash, + setWorkerWaitAsks, } from "./shell/chrome"; import { createAppShell } from "./shell/index"; import type { AppShell } from "./shell/internals"; @@ -112,4 +114,43 @@ describe("chrome repaint gate", () => { expect(chromeComposeCount(shell)).toBe(baseline + 2); }); }); + + test("worker wait snapshots recompose on change and never on a repeat", async () => { + await withShell((shell) => { + const ask = { + sessionId: "sess-1", + agentId: "builder", + description: "copy assets", + question: "Which destination path should I use?", + questionId: "q1", + }; + paintChrome(shell); + const baseline = chromeComposeCount(shell); + + setWorkerWaitAsks(shell, [ask]); + const shown = chromeComposeCount(shell); + expect(shown).toBeGreaterThan(baseline); + expect(shell.workerWaitRow.visible).toBe(true); + + // Repeat reports of the same identity and idle ticks cost nothing. + setWorkerWaitAsks(shell, [ask]); + setWorkerWaitAsks(shell, [{ ...ask }]); + for (let tick = 0; tick < 5; tick++) paintChrome(shell); + expect(chromeComposeCount(shell)).toBe(shown); + + // A replacement question changes the composed strip: one recompose. + setWorkerWaitAsks(shell, [ + { ...ask, questionId: "q2", question: "Overwrite?" }, + ]); + expect(chromeComposeCount(shell)).toBe(shown + 1); + + // The final removal recomposes and hands the row back. + setWorkerWaitAsks(shell, []); + expect(chromeComposeCount(shell)).toBeGreaterThan(shown + 1); + expect(shell.workerWaitRow.visible).toBe(false); + const cleared = chromeComposeCount(shell); + clearWorkerWait(shell); + expect(chromeComposeCount(shell)).toBe(cleared); + }); + }); }); diff --git a/src/tui/geometry.test.ts b/src/tui/geometry.test.ts index c7275a3e1..5d8ed48a5 100644 --- a/src/tui/geometry.test.ts +++ b/src/tui/geometry.test.ts @@ -34,6 +34,32 @@ describe("zone registry", () => { }); }); +describe("worker wait zone", () => { + test("seats one row directly on the prompt box without shrinking it", () => { + const idle = idle80x24(); + const layout = idle80x24({ visibility: { workerWait: true } }); + const strip = defined(layout.regions.worker_wait, "worker_wait"); + const prompt = defined(layout.regions.prompt, "prompt"); + expect(strip.height).toBe(1); + expect(strip.y + strip.height).toBe(prompt.y); + expect(layout.heights.prompt).toBe(idle.heights.prompt); + expect(layout.transcriptHeight).toBe(idle.transcriptHeight - 1); + }); + + test("is the last optional row cut before prompt growth is reclaimed", () => { + expect(COLLAPSE_ORDER.indexOf("worker_wait")).toBe( + COLLAPSE_ORDER.indexOf("prompt") - 1, + ); + const layout = resolveGeometry({ + terminal: { columns: 80, rows: 18 }, + visibility: { workerWait: true, notice: true, pending: 2 }, + }); + expect(layout.heights.notice).toBe(0); + expect(layout.heights.pending).toBe(0); + expect(layout.heights.worker_wait).toBe(1); + }); +}); + describe("resolveGeometry — 80×24 idle floor", () => { test("idle default chrome yields transcript ≥ 12", () => { const layout = idle80x24(); diff --git a/src/tui/geometry/resolve.ts b/src/tui/geometry/resolve.ts index a6f4b4df6..42199a4c7 100644 --- a/src/tui/geometry/resolve.ts +++ b/src/tui/geometry/resolve.ts @@ -39,6 +39,8 @@ export interface ZoneVisibility { readonly notice?: boolean; /** Pending queue column: exact row count requested (bounded by the zone max). */ readonly pending?: boolean | number; + /** WORKER WAITING strip on (default off). */ + readonly workerWait?: boolean; /** Progress: false/omit = 0; true = 2; or explicit 1|2. */ readonly progress?: boolean | 1 | 2; /** Progress divider (0–1). Default on when progress is shown. */ @@ -147,6 +149,7 @@ export function desiredHeights(input: GeometryInput): MutableHeights { progress_divider: progressDivider, notice: vis.notice === true ? 1 : ZONE_REGISTRY.notice.idleDefault, pending: clamp(boolOrRows(vis.pending, 1), 0, ZONE_REGISTRY.pending.max), + worker_wait: vis.workerWait === true ? 1 : 0, prompt: promptRows, task: clamp(boolOrRows(vis.task, 1), 0, ZONE_REGISTRY.task.max), // The board asks for exactly the rows it paints; the fraction stops a large diff --git a/src/tui/geometry/zones.ts b/src/tui/geometry/zones.ts index 5c619eeef..171fe2daf 100644 --- a/src/tui/geometry/zones.ts +++ b/src/tui/geometry/zones.ts @@ -8,6 +8,7 @@ const ZONE_IDS = [ "progress_divider", "notice", "pending", + "worker_wait", "prompt", "task", "agents", @@ -82,6 +83,15 @@ export const ZONE_REGISTRY: Readonly> = { idleDefault: 0, alwaysOn: false, }, + // WORKER WAITING strip: one row on the prompt box while a root worker is + // parked on ask_director. Off whenever nothing is waiting. + worker_wait: { + id: "worker_wait", + min: 0, + max: 1, + idleDefault: 0, + alwaysOn: false, + }, // Grows with the draft; the resolver caps it at PROMPT_CAP_FRACTION and // collapses toward min when the transcript would breach its floor. prompt: { @@ -191,9 +201,13 @@ export const COLLAPSE_ORDER = [ "progress", "progress_divider", "notice", - // Pending items are the operator's own queued words: cut last of the - // optionals, just ahead of prompt growth reclaim. + // Pending items are the operator's own queued words: cut late, after every + // banner and strip. "pending", + // A parked worker is standing state the operator cannot see anywhere else + // once the wake turn scrolls away, so its single row is the last optional + // cut, just ahead of prompt growth reclaim. + "worker_wait", // prompt growth reclaimed next (handled specially; never below PROMPT_BASE_ROWS) "prompt", ] as const satisfies readonly ZoneId[]; @@ -212,6 +226,7 @@ export const PAINT_ORDER = [ "progress_divider", "notice", "pending", + "worker_wait", "prompt", ] as const satisfies readonly ZoneId[]; diff --git a/src/tui/runner/wiring.ask-wake.test.ts b/src/tui/runner/wiring.ask-wake.test.ts index 2d3e6e2dd..1cf7d90da 100644 --- a/src/tui/runner/wiring.ask-wake.test.ts +++ b/src/tui/runner/wiring.ask-wake.test.ts @@ -13,9 +13,14 @@ import { } from "../runtime-bridge.js"; import { createLiveSessionPort } from "../live-session-port.js"; import { createAppShell } from "../shell/index.js"; -import { withTestRenderer } from "../harness.js"; +import { withTestRenderer, type Harness } from "../harness.js"; import { resetSessionForRotation } from "./exit.js"; -import { clearTranscript } from "../shell/chrome.js"; +import { clearTranscript, setMcpNeedsAuth } from "../shell/chrome.js"; +import type { AppShell } from "../shell/internals.js"; +import { closeInsetOverlay } from "../shell/overlay-host.js"; +import { submitPrompt } from "../shell/prompt.js"; +import { wireGates } from "../gate-wire.js"; +import { ASK_DIRECTOR_WAKE_PREFIX } from "../../subagent/fleet-report.js"; import { createDeliveryGeneration, createLeftoverSend, @@ -223,6 +228,8 @@ for (const phase of ["settled", "prequeued", "deferred"] as const) { { type: "agent-ask", asks: [] }, { type: "fleet", running: 0 }, ]); + expect(shell.workerWait.items).toEqual([]); + expect(shell.workerWaitRow.visible).toBe(false); expect( store.list().every((worker) => worker.status === "cancelled"), ).toBe(true); @@ -301,6 +308,10 @@ for (const removal of [ resolve: () => undefined, reject: () => undefined, }); + expect(shell.workerWait.items.map((item) => item.sessionId)).toEqual([ + worker.id, + ]); + expect(shell.workerWaitRow.visible).toBe(true); if (removal === "answer") { const mailbox = createFleetMailbox(store); mailbox.register(worker.id); @@ -338,6 +349,8 @@ for (const removal of [ type: "agent-ask", asks: [], }); + expect(shell.workerWait.items).toEqual([]); + expect(shell.workerWaitRow.visible).toBe(false); bridge.handle({ type: "inference.done", data: {} }); expect(sends).toEqual([]); } finally { @@ -528,3 +541,275 @@ test("yield wait does not stamp; sending pendingAskWakeText gates list_agents", { width: 80, height: 24 }, ); }); + +interface StripRig { + readonly shell: AppShell; + readonly h: Harness; + readonly bridge: ReturnType; + readonly store: ReturnType; + readonly emitter: EventEmitter; + /** Everything the primary session port was handed, in order. */ + readonly toPrimary: string[]; + readonly answers: string[]; + readonly park: (id: string, questionId: string, question: string) => void; +} + +async function withStripRig(fn: (rig: StripRig) => Promise) { + await withTestRenderer( + async (h) => { + const shell = createAppShell(h.renderer, { + terminal: { columns: 120, rows: 30 }, + wireKeys: false, + }); + const toPrimary: string[] = []; + const answers: string[] = []; + const bridge = attachSessionBridge( + shell, + createLiveSessionPort({ + send: (text) => { + toPrimary.push(text); + }, + deliver: (text) => { + toPrimary.push(text); + }, + interrupt: () => undefined, + }), + ); + const store = createSubAgentSessionStore(); + const emitter = new EventEmitter(); + emitter.on("event", (event: BridgeInboundEvent) => bridge.handle(event)); + const publisher = createFleetWakePublisher(store, emitter); + const unsubscribe = store.subscribe(publisher.publish); + const disposeGates = wireGates(emitter, shell, { + onGateOpened: () => bridge.gateOpened(), + onGateClosed: () => bridge.gateClosed(), + }); + const park = (id: string, questionId: string, question: string) => { + if (store.list().every((lane) => lane.id !== id)) { + store.start({ id, agentId: "builder", description: id, brief: "b" }); + store.markRunning(id); + } + store.registerAsk(id, { + question, + questionId, + resolve: (answer) => { + answers.push(`${id}:${answer}`); + }, + reject: () => undefined, + }); + }; + try { + await fn({ + shell, + h, + bridge, + store, + emitter, + toPrimary, + answers, + park, + }); + } finally { + disposeGates(); + unsubscribe(); + bridge.dispose(); + shell.dispose(); + } + }, + { width: 120, height: 30 }, + ); +} + +async function stripText(h: Harness): Promise { + await h.renderOnce(); + await h.renderOnce(); + return h + .captureCharFrame() + .split("\n") + .find((row) => row.includes("WAITING")); +} + +test("the strip follows agent-ask snapshots while the wake reaches the director and send_input resolves it", async () => { + await withStripRig( + async ({ shell, h, bridge, store, toPrimary, answers, park }) => { + bridge.handle({ type: "inference.start", data: {} }); + park("worker-session", "q1", "Which destination path should I use?"); + const parked = await stripText(h); + expect(parked).toContain("WORKER WAITING"); + expect(parked).toContain("Which destination path should I use?"); + expect(toPrimary).toEqual([]); + + // The primary settles: the wake goes to the director, the strip stays. + bridge.handle({ type: "inference.done", data: {} }); + expect(toPrimary).toHaveLength(1); + expect(toPrimary[0]).toContain("Which destination path should I use?"); + expect(toPrimary[0]).toContain("using target worker-session"); + expect(await stripText(h)).toBe(parked); + + // Director inference on the wake and its settlement are not resolution. + bridge.handle({ type: "inference.start", data: {} }); + expect(await stripText(h)).toBe(parked); + bridge.handle({ type: "inference.done", data: {} }); + expect(await stripText(h)).toBe(parked); + + // A republished snapshot of the same identity does not duplicate it. + store.wake(); + expect(shell.workerWait.items).toHaveLength(1); + expect(toPrimary).toHaveLength(1); + + // Only the director's send_input to the worker session resolves it. + expect(store.sendInputOne("worker-session", "/srv/out").ok).toBe(true); + expect(answers).toEqual(["worker-session:/srv/out"]); + expect(await stripText(h)).toBeUndefined(); + expect(shell.workerWaitRow.visible).toBe(false); + }, + ); +}); + +test("multiple parked workers keep the displayed identity and an accurate count", async () => { + await withStripRig(async ({ shell, h, bridge, store, park }) => { + bridge.handle({ type: "inference.start", data: {} }); + park("first", "q1", "First question?"); + park("second", "q2", "Second question?"); + park("third", "q3", "Third question?"); + let strip = await stripText(h); + expect(strip).toContain("First question?"); + expect(strip).toContain("(+2 more)"); + + // A later worker's replacement question leaves the displayed one alone. + store.cancelAsk("third"); + park("third", "q4", "Third replacement?"); + strip = await stripText(h); + expect(strip).toContain("First question?"); + expect(strip).toContain("(+2 more)"); + expect(shell.workerWait.items.map((item) => item.questionId)).toEqual([ + "q1", + "q2", + "q4", + ]); + + // The displayed identity resolves: fall back to snapshot order. + expect(store.sendInputOne("first", "ok").ok).toBe(true); + strip = await stripText(h); + expect(strip).toContain("Second question?"); + expect(strip).toContain("(+1 more)"); + }); +}); + +test("composer submission still goes to the director and never answers or clears the worker", async () => { + await withStripRig( + async ({ shell, h, bridge, store, toPrimary, answers, park }) => { + bridge.handle({ type: "inference.start", data: {} }); + park("worker-session", "q1", "Which destination path should I use?"); + bridge.handle({ type: "inference.done", data: {} }); + expect(toPrimary).toHaveLength(1); + const parked = await stripText(h); + + // Mid wake turn: Enter queues a steer for the primary session. + bridge.handle({ type: "inference.start", data: {} }); + shell.prompt.value = "try /srv/out"; + submitPrompt(shell, "steer"); + expect(shell.session.items.map((item) => item.text)).toEqual([ + "try /srv/out", + ]); + expect(answers).toEqual([]); + expect(store.hasPendingAsk("worker-session")).toBe(true); + expect(await stripText(h)).toBe(parked); + bridge.handle({ type: "inference.done", data: {} }); + + // Idle with the worker still live: Enter is a new primary turn and the + // operator's words reach the director verbatim. + shell.prompt.value = "/srv/out"; + submitPrompt(shell, "steer"); + expect(toPrimary.at(-1)).toBe("/srv/out"); + expect(answers).toEqual([]); + expect(store.hasPendingAsk("worker-session")).toBe(true); + expect(await stripText(h)).toBe(parked); + + // Typing alone changes nothing either. + shell.prompt.value = "still thinking"; + expect(await stripText(h)).toBe(parked); + expect(store.hasPendingAsk("worker-session")).toBe(true); + }, + ); +}); + +test("ask_operator and permission gates never activate the strip", async () => { + await withStripRig(async ({ shell, h, bridge, emitter }) => { + bridge.handle({ type: "inference.start", data: {} }); + let operatorAnswer: unknown; + emitter.emit("operator.gate", { + id: "ask-1", + question: "Which destination path should I use?", + options: ["Cancel", "Continue"], + resolve: (value: unknown) => { + operatorAnswer = value; + }, + }); + expect(shell.overlayList).not.toBeNull(); + expect(shell.workerWait.items).toEqual([]); + expect(shell.layout.heights.worker_wait).toBe(0); + expect(await stripText(h)).toBeUndefined(); + closeInsetOverlay(shell); + expect(operatorAnswer).toBeDefined(); + + emitter.emit("permission.gate", { + id: "req-1", + request: { + tool: "run_shell", + action: "Run shell command", + subject: "rm -rf build", + scopes: [], + }, + resolve: () => undefined, + }); + expect(shell.overlayKind).toBe("permissions"); + expect(shell.workerWait.items).toEqual([]); + expect(await stripText(h)).toBeUndefined(); + }); +}); + +test("transcript wake text, status events and MCP attention never activate the strip", async () => { + await withStripRig(async ({ shell, h, bridge }) => { + bridge.handle({ + type: "assistant", + text: `${ASK_DIRECTOR_WAKE_PREFIX} worker builder parked question q1`, + }); + bridge.handle({ type: "system", text: "worker builder is waiting" }); + bridge.handle({ type: "fleet", running: 2 }); + setMcpNeedsAuth(shell, ["granola"]); + expect(shell.workerWait.items).toEqual([]); + expect(shell.layout.heights.worker_wait).toBe(0); + expect(await stripText(h)).toBeUndefined(); + }); +}); + +test("a live strip and an ask_operator overlay stay separate", async () => { + await withStripRig(async ({ shell, h, bridge, emitter, store, park }) => { + bridge.handle({ type: "inference.start", data: {} }); + park("worker-session", "q1", "Which destination path should I use?"); + const parked = await stripText(h); + emitter.emit("operator.gate", { + id: "ask-2", + question: "Ship it?", + options: ["No", "Yes"], + resolve: () => undefined, + }); + expect(shell.overlayList).not.toBeNull(); + expect(await stripText(h)).toBe(parked); + closeInsetOverlay(shell); + expect(await stripText(h)).toBe(parked); + expect(store.hasPendingAsk("worker-session")).toBe(true); + }); +}); + +test("bridge teardown clears the strip", async () => { + await withStripRig(async ({ shell, h, bridge, park }) => { + bridge.handle({ type: "inference.start", data: {} }); + park("worker-session", "q1", "Which destination path should I use?"); + expect(await stripText(h)).toContain("WORKER WAITING"); + bridge.dispose(); + expect(shell.workerWait.items).toEqual([]); + expect(await stripText(h)).toBeUndefined(); + }); +}); diff --git a/src/tui/runtime-bridge.ts b/src/tui/runtime-bridge.ts index 89f90d49b..9cc19b71f 100644 --- a/src/tui/runtime-bridge.ts +++ b/src/tui/runtime-bridge.ts @@ -21,11 +21,13 @@ import { } from "./delivery-queue.js"; import { appendStreamRow, + clearWorkerWait, paintChrome, paintLanding, replaceStreamRowAt, setLockupFrame, setStatusFlash, + setWorkerWaitAsks, truncateStreamRows, } from "./shell/chrome.js"; import { @@ -1281,6 +1283,9 @@ function applyInbound( bag.askWakeResurface.delete(sessionId); } } + // The strip reads the same reconciled snapshot the wake path does, never + // `deliveredAskWake`: delivery to the director is not resolution. + setWorkerWaitAsks(shell, [...bag.pendingAskWake.values()]); // Occupancy first on the idle-parent subscribe path; an open gate defers // both flushes to gateClosed. A still-armed silent turn stays armed // until the poll aborts it. @@ -1923,6 +1928,9 @@ export function attachSessionBridge( bag.pendingPromptRecoveries.length = 0; bag.liveFleet = 0; bag.pendingAskWake.clear(); + // Session rotation and stop teardown: the waits belonged to workers that + // session owned, so the strip goes with them. + clearWorkerWait(shell); bag.deliveredAskWake.clear(); // Stop teardown: queue gone, no wake turn owed — disarm the bound and // drop escalation counts. @@ -2193,6 +2201,7 @@ export function attachSessionBridge( bag.pendingDeliveries.clear(); bag.pendingPromptRecoveries.length = 0; bag.pendingAskWake.clear(); + clearWorkerWait(shell); bag.deliveredAskWake.clear(); bag.askWakeTurnArmed = false; bag.askWakeResurface.clear(); diff --git a/src/tui/shell/chrome.ts b/src/tui/shell/chrome.ts index 51fe4adc5..3964b26d3 100644 --- a/src/tui/shell/chrome.ts +++ b/src/tui/shell/chrome.ts @@ -89,6 +89,14 @@ import { overlayChromeRows, } from "../overlay-view.js"; import { DECISION_CHOICE_ROWS } from "../overlay-body.js"; +import type { PendingAskWake } from "../../subagent/fleet-report.js"; +import { + composeWorkerWaitLine, + NO_WORKER_WAIT, + reduceWorkerWait, + type WorkerWaitPart, + type WorkerWaitRole, +} from "../worker-wait.js"; import { type AppShell, @@ -165,13 +173,66 @@ export function setPluginNeedsAttention(shell: AppShell, needs: boolean): void { paintChrome(shell); } +/** + * Fold the latest pending-ask snapshot into the WORKER WAITING strip. + * + * The snapshot is authoritative: an identity leaves only when a later + * snapshot omits it, never because its wake reached the director, a turn + * settled, or the operator typed. Repaints only when the view model moved. + */ +export function setWorkerWaitAsks( + shell: AppShell, + asks: readonly PendingAskWake[], +): void { + const next = reduceWorkerWait(shell.workerWait, asks); + if (next === shell.workerWait) return; + shell.workerWait = next; + paintChrome(shell); +} + +/** Teardown: drop every wait so the prompt box returns to normal chrome. */ +export function clearWorkerWait(shell: AppShell): void { + if (shell.workerWait === NO_WORKER_WAIT) return; + shell.workerWait = NO_WORKER_WAIT; + paintChrome(shell); +} + +/** + * Wording carries the state (mark, label, routing copy); colour only lifts + * the label to the action emphasis the shell already uses for "act here". + */ +function workerWaitFg(role: WorkerWaitRole): string { + switch (role) { + case "mark": + case "label": + return UI.action; + case "routing": + case "question": + return UI.text; + case "requester": + case "more": + return UI.textDim; + case "separator": + return UI.textFaint; + } +} + +function workerWaitChunks(parts: readonly WorkerWaitPart[]): TextChunk[] { + return parts.map((part) => fgChunk(workerWaitFg(part.role))(part.text)); +} + /** * Key over every input a chrome paint reads; a miss leaves stale chrome. */ -function chromeComposeKey(shell: AppShell, notice: string): string { +function chromeComposeKey( + shell: AppShell, + notice: string, + workerWait: string, +): string { const meter = shell.costContext; return [ notice, + workerWait, pendingColumnRows(shell.session.items) .map((row) => `${row.tag ?? ""}:${row.text}`) .join("\u0001"), @@ -216,7 +277,15 @@ export function paintChrome( if (shell.renderer.isDestroyed || shell.notice.isDestroyed) return; syncPending(shell); const notice = noticeText(shell); - const key = chromeComposeKey(shell, notice); + const workerWait = composeWorkerWaitLine( + shell.workerWait, + shell.layout.contentWidth, + ); + const key = chromeComposeKey( + shell, + notice, + workerWait.map((part) => part.text).join(""), + ); if (!opts?.force && paintedChromeKey.get(shell) === key) return; paintedChromeKey.set(shell, key); chromeComposeCounts.set(shell, chromeComposeCount(shell) + 1); @@ -228,6 +297,20 @@ export function paintChrome( syncLandingSuggestions(shell); syncNoticeRow(shell, notice); syncPendingColumn(shell); + shell.workerWaitRow.content = new StyledText(workerWaitChunks(workerWait)); + syncWorkerWaitRow(shell, workerWait.length > 0); +} + +/** + * Give the WORKER WAITING strip its row while any worker is parked, and take + * it back once the final identity leaves the snapshot. Same transient + * contract as the notice row; the resolver seats it, so it never overlaps. + */ +function syncWorkerWaitRow(shell: AppShell, wanted: boolean): void { + const bag = shellInternals(shell); + if (bag === undefined) return; + if ((bag.visibility.workerWait ?? false) === wanted) return; + relayout(shell, { visibility: { ...bag.visibility, workerWait: wanted } }); } /** Rebuild pendingBox rows only when the painted content moved; unchanged @@ -777,6 +860,10 @@ export function applyLayout(shell: AppShell, layout: GeometryLayout): void { shell.pendingBox.height = pendingH > 0 ? pendingH : 1; shell.pendingBox.visible = pendingH > 0; + const workerWaitH = Math.max(0, h.worker_wait); + shell.workerWaitRow.height = workerWaitH > 0 ? workerWaitH : 1; + shell.workerWaitRow.visible = workerWaitH > 0; + const promptH = Math.max(0, h.prompt); shell.promptBox.height = promptH > 0 ? promptH : 1; shell.promptBox.visible = promptH > 0; @@ -796,7 +883,8 @@ export function applyLayout(shell: AppShell, layout: GeometryLayout): void { const floating = landing !== null && overlayH > 0; // A floated host's bottom edge must land above the mid-screen landing box; // covering it would hide what the operator types. Stack: topPad, transcript, - // agents, task, prompt (notice and pending are transient). + // agents, task, prompt (notice, pending and the worker-wait strip + // are transient). const promptTop = padH + transcriptBody + agentsH + taskH; const hostH = floating ? Math.min(overlayH, Math.max(1, promptTop)) diff --git a/src/tui/shell/index.ts b/src/tui/shell/index.ts index 8cb36f00e..d734adffa 100644 --- a/src/tui/shell/index.ts +++ b/src/tui/shell/index.ts @@ -73,6 +73,7 @@ import { syncPromptHighlights, } from "./prompt.js"; import { armMarkdownLinks } from "../url-links.js"; +import { NO_WORKER_WAIT } from "../worker-wait.js"; import { createShellKeyHandlers, routePromptWheelToTranscript, @@ -259,6 +260,15 @@ export function createAppShell( visible: layout.heights.pending > 0, }); + // Off until a pending-ask snapshot names a parked root worker. + const workerWaitRow = new TextRenderable(ctx, { + id: "shell-worker-wait", + height: Math.max(1, layout.heights.worker_wait), + content: "", + fg: UI.textDim, + visible: layout.heights.worker_wait > 0, + }); + const promptBox = new BoxRenderable(ctx, { id: "shell-prompt-region", width: "100%", @@ -318,6 +328,7 @@ export function createAppShell( root.add(taskBox); root.add(notice); root.add(pendingBox); + root.add(workerWaitRow); root.add(promptBox); root.add(landingBelow); root.add(bottomPad); @@ -422,6 +433,7 @@ export function createAppShell( promptBottomRule, notice, pendingBox, + workerWaitRow, layout, focus: createFocusState(), session, @@ -446,6 +458,7 @@ export function createAppShell( statusFlash: null, mcpNeedsAuth: [], pluginNeedsAttention: false, + workerWait: NO_WORKER_WAIT, lockupNowMs: 0, inFlightTool: null, lockupAnimating: false, diff --git a/src/tui/shell/internals.ts b/src/tui/shell/internals.ts index fd955a383..bd021ea3a 100644 --- a/src/tui/shell/internals.ts +++ b/src/tui/shell/internals.ts @@ -34,6 +34,7 @@ import { type RunState, type SessionQueueState } from "../delivery-queue.js"; import { type StreamRow } from "../stream.js"; import { createOverlayView } from "../overlay-view.js"; import { type KillRing } from "../prompt-kill-ring.js"; +import { type WorkerWaitState } from "../worker-wait.js"; export const shellExitHandlers = new WeakMap void>(); @@ -364,6 +365,11 @@ export interface AppShell { /** Queued steer/follow-up items above the prompt box, one row each, hidden * when empty; geometry owns the row budget (zone `pending`). */ readonly pendingBox: BoxRenderable; + /** + * WORKER WAITING strip on the prompt box. Hidden while no root worker is + * parked on ask_director; geometry owns its row (zone `worker_wait`). + */ + readonly workerWaitRow: TextRenderable; /** Latest geometry resolution (updated on resize / relayout). */ layout: GeometryLayout; /** Focus tree + scroll lease (updated by shell helpers). */ @@ -415,6 +421,11 @@ export interface AppShell { /** Standing plugin warnings (skill misses, failed tool starts); the top rule * carries `plugin !` (`mcp ! · plugin !` with MCP). */ pluginNeedsAttention: boolean; + /** + * Live parked worker questions, from the latest pending-ask snapshot only. + * Display state: nothing reads it to route input or settle an ask. + */ + workerWait: WorkerWaitState; /** Clock and motion state for the bottom-left status slot; the bridge pushes * it off its monitor tick. */ lockupNowMs: number; diff --git a/src/tui/worker-wait-chrome.test.ts b/src/tui/worker-wait-chrome.test.ts new file mode 100644 index 000000000..91a6e639f --- /dev/null +++ b/src/tui/worker-wait-chrome.test.ts @@ -0,0 +1,246 @@ +/** + * WORKER WAITING strip in the shell chrome: placement on the prompt box, + * width-safe paint, theme roles, persistence through ordinary shell activity, + * and normal chrome once the last wait leaves the snapshot. + */ +import { afterEach, describe, expect, test } from "bun:test"; +import { rgbToHex, type CapturedSpan, type RGBA } from "@opentui/core"; + +import type { PendingAskWake } from "../subagent/fleet-report.js"; +import { withTestRenderer, type Harness } from "./harness.js"; +import { BORDER } from "./prompt-border.js"; +import { repaintShellForTheme } from "./runner/settings.js"; +import { + appendStreamRow, + clearWorkerWait, + paintChrome, + relayout, + setStatusFlash, + setWorkerWaitAsks, +} from "./shell/chrome.js"; +import { createAppShell } from "./shell/index.js"; +import type { AppShell } from "./shell/internals.js"; +import { setTheme, UI } from "./theme.js"; + +afterEach(() => { + setTheme("corbits-dark"); +}); + +const DESTINATION: PendingAskWake = { + sessionId: "sess-builder", + agentId: "builder", + description: "copy assets", + question: "Which destination path should I use?", + questionId: "q1", +}; + +const SECOND: PendingAskWake = { + sessionId: "sess-explore", + agentId: "explore", + description: "map callers", + question: "Include tests?", + questionId: "q7", +}; + +async function withShell( + fn: (shell: AppShell, h: Harness) => Promise, + size: { columns: number; rows: number } = { columns: 120, rows: 30 }, +): Promise { + await withTestRenderer( + async (h) => { + const shell = createAppShell(h.renderer, { + title: "test", + cwd: "/src/corbits-code", + terminal: size, + wireKeys: false, + }); + try { + await fn(shell, h); + } finally { + shell.dispose(); + } + }, + { width: size.columns, height: size.rows }, + ); +} + +async function frameRows(h: Harness): Promise { + await h.renderOnce(); + await h.renderOnce(); + return h.captureCharFrame().split("\n"); +} + +function stripRowIndex(rows: readonly string[]): number { + return rows.findIndex((row) => row.includes("WAITING")); +} + +/** The strip owns exactly its own row, directly on the prompt's top rule. */ +function expectSeatedOnPrompt(shell: AppShell, rows: readonly string[]): void { + const strip = shell.layout.regions.worker_wait; + const prompt = shell.layout.regions.prompt; + expect(strip?.height).toBe(1); + expect(strip !== undefined && prompt !== undefined).toBe(true); + if (strip === undefined || prompt === undefined) return; + expect(strip.y + strip.height).toBe(prompt.y); + const index = stripRowIndex(rows); + expect(index).toBeGreaterThanOrEqual(0); + expect(rows.filter((row) => row.includes("WAITING"))).toHaveLength(1); + expect(rows[index + 1]?.trimStart().startsWith(BORDER.topLeft)).toBe(true); +} + +function hex(color: RGBA): string { + return rgbToHex(color).toLowerCase().slice(0, 7); +} + +function spanWith(h: Harness, needle: string): CapturedSpan | undefined { + for (const line of h.captureSpans().lines) { + const span = line.spans.find((s: CapturedSpan) => s.text.includes(needle)); + if (span !== undefined) return span; + } + return undefined; +} + +describe("worker waiting strip", () => { + test("no live wait paints normal chrome with no strip row", async () => { + await withShell(async (shell, h) => { + const rows = await frameRows(h); + expect(shell.layout.heights.worker_wait).toBe(0); + expect(shell.workerWaitRow.visible).toBe(false); + expect(rows.some((row) => row.includes("WAITING"))).toBe(false); + }); + }); + + test("full width seats one strip on the prompt box naming worker, question and director", async () => { + await withShell(async (shell, h) => { + const promptBefore = shell.layout.heights.prompt; + setWorkerWaitAsks(shell, [DESTINATION]); + const rows = await frameRows(h); + expectSeatedOnPrompt(shell, rows); + const strip = rows[stripRowIndex(rows)] ?? ""; + expect(strip).toContain("WORKER WAITING"); + expect(strip).toContain("director reply needed"); + expect(strip).toContain("builder (copy assets)"); + expect(strip).toContain("Which destination path should I use?"); + // The strip takes its row from the transcript, never the composer. + expect(shell.layout.heights.prompt).toBe(promptBefore); + }); + }); + + test("narrow width keeps the waiting label and count inside the frame", async () => { + await withShell( + async (shell, h) => { + setWorkerWaitAsks(shell, [DESTINATION, SECOND]); + const rows = await frameRows(h); + expectSeatedOnPrompt(shell, rows); + const strip = rows[stripRowIndex(rows)] ?? ""; + expect(strip).toContain("WAITING"); + expect(strip).toContain("(+1 more)"); + expect(strip.trimEnd().length).toBeLessThanOrEqual(32); + }, + { columns: 32, rows: 24 }, + ); + }); + + test("resizing narrower re-fits the strip without dropping it", async () => { + await withShell(async (shell, h) => { + setWorkerWaitAsks(shell, [DESTINATION, SECOND]); + const wide = await frameRows(h); + expect(wide[stripRowIndex(wide)]).toContain("copy assets"); + h.resize(48, 30); + relayout(shell, { columns: 48, rows: 30 }); + const narrow = await frameRows(h); + expectSeatedOnPrompt(shell, narrow); + const strip = narrow[stripRowIndex(narrow)] ?? ""; + expect(strip).toContain("WORKER WAITING"); + expect(strip).toContain("(+1 more)"); + expect(strip).not.toContain("copy assets"); + }); + }); + + for (const theme of ["corbits-dark", "corbits-light"] as const) { + test(`${theme} paints the label with the action role and plain-text meaning`, async () => { + setTheme(theme); + await withShell(async (shell, h) => { + setWorkerWaitAsks(shell, [DESTINATION]); + await frameRows(h); + const label = spanWith(h, "WORKER WAITING"); + expect(label).toBeDefined(); + if (label === undefined) return; + expect(hex(label.fg)).toBe(UI.action.toLowerCase()); + const routing = spanWith(h, "director reply needed"); + expect(routing).toBeDefined(); + if (routing === undefined) return; + expect(hex(routing.fg)).toBe(UI.text.toLowerCase()); + }); + }); + } + + test("a live theme switch repaints the strip in the new palette", async () => { + setTheme("corbits-dark"); + await withShell(async (shell, h) => { + setWorkerWaitAsks(shell, [DESTINATION]); + await frameRows(h); + setTheme("corbits-light"); + repaintShellForTheme(shell); + await frameRows(h); + const label = spanWith(h, "WORKER WAITING"); + expect(label === undefined ? null : hex(label.fg)).toBe( + UI.action.toLowerCase(), + ); + }); + }); + + test("the strip persists through transcript growth, scrolling, typing, flashes and redraws", async () => { + await withShell(async (shell, h) => { + setWorkerWaitAsks(shell, [DESTINATION]); + const before = await frameRows(h); + const painted = before[stripRowIndex(before)]; + for (let i = 0; i < 60; i++) { + appendStreamRow(shell, { role: "assistant", text: `row ${i}` }); + } + shell.transcript.scrollTo(0); + shell.prompt.value = "hold on, let me check the path"; + setStatusFlash(shell, "copied"); + paintChrome(shell); + paintChrome(shell, { force: true }); + relayout(shell); + const after = await frameRows(h); + expectSeatedOnPrompt(shell, after); + expect(after[stripRowIndex(after)]).toBe(painted); + // A repeat snapshot of the same identity changes nothing. + setWorkerWaitAsks(shell, [DESTINATION]); + const repeated = await frameRows(h); + expect(repeated[stripRowIndex(repeated)]).toBe(painted); + }); + }); + + test("the final removal restores normal chrome", async () => { + await withShell(async (shell, h) => { + const baseline = await frameRows(h); + const baselinePrompt = shell.layout.regions.prompt; + setWorkerWaitAsks(shell, [DESTINATION, SECOND]); + await frameRows(h); + setWorkerWaitAsks(shell, [SECOND]); + const one = await frameRows(h); + expect(one[stripRowIndex(one)]).toContain("Include tests?"); + expect(one[stripRowIndex(one)]).not.toContain("more"); + setWorkerWaitAsks(shell, []); + const restored = await frameRows(h); + expect(shell.layout.heights.worker_wait).toBe(0); + expect(shell.workerWaitRow.visible).toBe(false); + expect(shell.layout.regions.prompt).toEqual(baselinePrompt); + expect(restored).toEqual(baseline); + }); + }); + + test("teardown clears the strip without a snapshot", async () => { + await withShell(async (shell, h) => { + setWorkerWaitAsks(shell, [DESTINATION]); + await frameRows(h); + clearWorkerWait(shell); + const rows = await frameRows(h); + expect(shell.layout.heights.worker_wait).toBe(0); + expect(rows.some((row) => row.includes("WAITING"))).toBe(false); + }); + }); +});