Skip to content

Commit 720a5f0

Browse files
committed
feat(tui): show parked worker questions on the prompt box
1 parent 3d55f9a commit 720a5f0

9 files changed

Lines changed: 460 additions & 6 deletions

File tree

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

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,11 @@ import { describe, expect, test } from "bun:test";
66
import { withTestRenderer } from "./harness";
77
import {
88
chromeComposeCount,
9+
clearWorkerWait,
910
paintChrome,
1011
setLockupFrame,
1112
setStatusFlash,
13+
setWorkerWaitAsks,
1214
} from "./shell/chrome";
1315
import { createAppShell } from "./shell/index";
1416
import type { AppShell } from "./shell/internals";
@@ -112,4 +114,43 @@ describe("chrome repaint gate", () => {
112114
expect(chromeComposeCount(shell)).toBe(baseline + 2);
113115
});
114116
});
117+
118+
test("worker wait snapshots recompose on change and never on a repeat", async () => {
119+
await withShell((shell) => {
120+
const ask = {
121+
sessionId: "sess-1",
122+
agentId: "builder",
123+
description: "copy assets",
124+
question: "Which destination path should I use?",
125+
questionId: "q1",
126+
};
127+
paintChrome(shell);
128+
const baseline = chromeComposeCount(shell);
129+
130+
setWorkerWaitAsks(shell, [ask]);
131+
const shown = chromeComposeCount(shell);
132+
expect(shown).toBeGreaterThan(baseline);
133+
expect(shell.workerWaitRow.visible).toBe(true);
134+
135+
// Repeat reports of the same identity and idle ticks cost nothing.
136+
setWorkerWaitAsks(shell, [ask]);
137+
setWorkerWaitAsks(shell, [{ ...ask }]);
138+
for (let tick = 0; tick < 5; tick++) paintChrome(shell);
139+
expect(chromeComposeCount(shell)).toBe(shown);
140+
141+
// A replacement question changes the composed strip: one recompose.
142+
setWorkerWaitAsks(shell, [
143+
{ ...ask, questionId: "q2", question: "Overwrite?" },
144+
]);
145+
expect(chromeComposeCount(shell)).toBe(shown + 1);
146+
147+
// The final removal recomposes and hands the row back.
148+
setWorkerWaitAsks(shell, []);
149+
expect(chromeComposeCount(shell)).toBeGreaterThan(shown + 1);
150+
expect(shell.workerWaitRow.visible).toBe(false);
151+
const cleared = chromeComposeCount(shell);
152+
clearWorkerWait(shell);
153+
expect(chromeComposeCount(shell)).toBe(cleared);
154+
});
155+
});
115156
});

‎src/tui/geometry.test.ts‎

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,32 @@ describe("zone registry", () => {
3434
});
3535
});
3636

37+
describe("worker wait zone", () => {
38+
test("seats one row directly on the prompt box without shrinking it", () => {
39+
const idle = idle80x24();
40+
const layout = idle80x24({ visibility: { workerWait: true } });
41+
const strip = defined(layout.regions.worker_wait, "worker_wait");
42+
const prompt = defined(layout.regions.prompt, "prompt");
43+
expect(strip.height).toBe(1);
44+
expect(strip.y + strip.height).toBe(prompt.y);
45+
expect(layout.heights.prompt).toBe(idle.heights.prompt);
46+
expect(layout.transcriptHeight).toBe(idle.transcriptHeight - 1);
47+
});
48+
49+
test("is the last optional row cut before prompt growth is reclaimed", () => {
50+
expect(COLLAPSE_ORDER.indexOf("worker_wait")).toBe(
51+
COLLAPSE_ORDER.indexOf("prompt") - 1,
52+
);
53+
const layout = resolveGeometry({
54+
terminal: { columns: 80, rows: 18 },
55+
visibility: { workerWait: true, notice: true, pending: 2 },
56+
});
57+
expect(layout.heights.notice).toBe(0);
58+
expect(layout.heights.pending).toBe(0);
59+
expect(layout.heights.worker_wait).toBe(1);
60+
});
61+
});
62+
3763
describe("resolveGeometry — 80×24 idle floor", () => {
3864
test("idle default chrome yields transcript ≥ 12", () => {
3965
const layout = idle80x24();

‎src/tui/geometry/resolve.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,8 @@ export interface ZoneVisibility {
4545
readonly notice?: boolean;
4646
/** Pending queue column: exact row count requested (bounded by the zone max). */
4747
readonly pending?: boolean | number;
48+
/** WORKER WAITING strip on (default off). */
49+
readonly workerWait?: boolean;
4850
/** Progress: false/omit = 0; true = 2; or explicit 1|2. */
4951
readonly progress?: boolean | 1 | 2;
5052
/** Progress divider (0–1). Default on when progress is shown. */
@@ -157,6 +159,7 @@ export function desiredHeights(input: GeometryInput): MutableHeights {
157159
progress_divider: progressDivider,
158160
notice: vis.notice === true ? 1 : ZONE_REGISTRY.notice.idleDefault,
159161
pending: clamp(boolOrRows(vis.pending, 1), 0, ZONE_REGISTRY.pending.max),
162+
worker_wait: vis.workerWait === true ? 1 : 0,
160163
prompt: promptRows,
161164
task: clamp(boolOrRows(vis.task, 1), 0, ZONE_REGISTRY.task.max),
162165
// The board asks for exactly the rows it will paint; the fraction is what

‎src/tui/geometry/zones.ts‎

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ const ZONE_IDS = [
88
"progress_divider",
99
"notice",
1010
"pending",
11+
"worker_wait",
1112
"prompt",
1213
"task",
1314
"agents",
@@ -104,6 +105,15 @@ export const ZONE_REGISTRY: Readonly<Record<ZoneId, ZoneDeclaration>> = {
104105
idleDefault: 0,
105106
alwaysOn: false,
106107
},
108+
// WORKER WAITING strip: one row on the prompt box while a root worker is
109+
// parked on ask_director. Off whenever nothing is waiting.
110+
worker_wait: {
111+
id: "worker_wait",
112+
min: 0,
113+
max: 1,
114+
idleDefault: 0,
115+
alwaysOn: false,
116+
},
107117
// Grows with what is being composed; the resolver caps it at PROMPT_CAP_FRACTION
108118
// and collapses it back toward min when the transcript would breach its floor.
109119
prompt: {
@@ -220,9 +230,13 @@ export const COLLAPSE_ORDER = [
220230
"progress",
221231
"progress_divider",
222232
"notice",
223-
// Pending items are the operator's own queued words: cut last of the
224-
// optionals, just ahead of prompt growth reclaim.
233+
// Pending items are the operator's own queued words: cut late, after every
234+
// banner and strip.
225235
"pending",
236+
// A parked worker is standing state the operator cannot see anywhere else
237+
// once the wake turn scrolls away, so its single row is the last optional
238+
// cut, just ahead of prompt growth reclaim.
239+
"worker_wait",
226240
// prompt growth reclaimed next (handled specially; never below PROMPT_BASE_ROWS)
227241
"prompt",
228242
] as const satisfies readonly ZoneId[];
@@ -244,6 +258,7 @@ export const PAINT_ORDER = [
244258
"progress_divider",
245259
"notice",
246260
"pending",
261+
"worker_wait",
247262
"prompt",
248263
] as const satisfies readonly ZoneId[];
249264

‎src/tui/runtime-bridge.ts‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,11 +21,13 @@ import {
2121
} from "./delivery-queue.js";
2222
import {
2323
appendStreamRow,
24+
clearWorkerWait,
2425
paintChrome,
2526
paintLanding,
2627
replaceStreamRowAt,
2728
setLockupFrame,
2829
setStatusFlash,
30+
setWorkerWaitAsks,
2931
truncateStreamRows,
3032
} from "./shell/chrome.js";
3133
import {
@@ -1534,6 +1536,9 @@ function applyInbound(
15341536
bag.askWakeResurface.delete(sessionId);
15351537
}
15361538
}
1539+
// The strip reads the same reconciled snapshot the wake path does, never
1540+
// `deliveredAskWake`: delivery to the director is not resolution.
1541+
setWorkerWaitAsks(shell, [...bag.pendingAskWake.values()]);
15371542
// Occupancy first on the idle-parent subscribe path. An open gate defers
15381543
// both flushes to gateClosed so a wake cannot send before mail.
15391544
// A fresh snapshot with nothing pending means no wake is owed, but a
@@ -2218,6 +2223,9 @@ export function attachSessionBridge(
22182223
bag.pendingPromptRecoveries.length = 0;
22192224
bag.liveFleet = 0;
22202225
bag.pendingAskWake.clear();
2226+
// Session rotation and stop teardown: the waits belonged to workers that
2227+
// session owned, so the strip goes with them.
2228+
clearWorkerWait(shell);
22212229
bag.deliveredAskWake.clear();
22222230
// Stop teardown (CL-8016): with the queue gone no wake turn is owed, so
22232231
// disarm the bound and drop the escalation counts — a later session
@@ -2503,6 +2511,7 @@ export function attachSessionBridge(
25032511
bag.pendingDeliveries.clear();
25042512
bag.pendingPromptRecoveries.length = 0;
25052513
bag.pendingAskWake.clear();
2514+
clearWorkerWait(shell);
25062515
bag.deliveredAskWake.clear();
25072516
bag.askWakeTurnArmed = false;
25082517
bag.askWakeResurface.clear();

‎src/tui/shell/chrome.ts‎

Lines changed: 94 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,14 @@ import {
8888
overlayChromeRows,
8989
} from "../overlay-view.js";
9090
import { DECISION_CHOICE_ROWS } from "../overlay-body.js";
91+
import type { PendingAskWake } from "../../subagent/fleet-report.js";
92+
import {
93+
composeWorkerWaitLine,
94+
NO_WORKER_WAIT,
95+
reduceWorkerWait,
96+
type WorkerWaitPart,
97+
type WorkerWaitRole,
98+
} from "../worker-wait.js";
9199

92100
import {
93101
type AppShell,
@@ -165,6 +173,54 @@ export function setPluginNeedsAttention(shell: AppShell, needs: boolean): void {
165173
paintChrome(shell);
166174
}
167175

176+
/**
177+
* Fold the latest pending-ask snapshot into the WORKER WAITING strip.
178+
*
179+
* The snapshot is authoritative: an identity leaves only when a later
180+
* snapshot omits it, never because its wake reached the director, a turn
181+
* settled, or the operator typed. Repaints only when the view model moved.
182+
*/
183+
export function setWorkerWaitAsks(
184+
shell: AppShell,
185+
asks: readonly PendingAskWake[],
186+
): void {
187+
const next = reduceWorkerWait(shell.workerWait, asks);
188+
if (next === shell.workerWait) return;
189+
shell.workerWait = next;
190+
paintChrome(shell);
191+
}
192+
193+
/** Teardown: drop every wait so the prompt box returns to normal chrome. */
194+
export function clearWorkerWait(shell: AppShell): void {
195+
if (shell.workerWait === NO_WORKER_WAIT) return;
196+
shell.workerWait = NO_WORKER_WAIT;
197+
paintChrome(shell);
198+
}
199+
200+
/**
201+
* Wording carries the state (mark, label, routing copy); colour only lifts
202+
* the label to the action emphasis the shell already uses for "act here".
203+
*/
204+
function workerWaitFg(role: WorkerWaitRole): string {
205+
switch (role) {
206+
case "mark":
207+
case "label":
208+
return UI.action;
209+
case "routing":
210+
case "question":
211+
return UI.text;
212+
case "requester":
213+
case "more":
214+
return UI.textDim;
215+
case "separator":
216+
return UI.textFaint;
217+
}
218+
}
219+
220+
function workerWaitChunks(parts: readonly WorkerWaitPart[]): TextChunk[] {
221+
return parts.map((part) => fgChunk(workerWaitFg(part.role))(part.text));
222+
}
223+
168224
/**
169225
* Every input the chrome compose paths read, as one comparable key. A missed
170226
* input here means stale chrome, so this list is exhaustive:
@@ -182,13 +238,20 @@ export function setPluginNeedsAttention(shell: AppShell, needs: boolean): void {
182238
* `lockupChangedMs`, `lockupRampPhase`, `lockupStalledForMs`
183239
* - cost meter: band, percent label, cost label (or absence)
184240
* - landing suggestions: whether the prompt has text
241+
* - WORKER WAITING strip: its composed text at the current width (folds in
242+
* the selected identity, its preview, and the additional count)
185243
*
186244
* Landing and zone paints read their own state and do not pass through here.
187245
*/
188-
function chromeComposeKey(shell: AppShell, notice: string): string {
246+
function chromeComposeKey(
247+
shell: AppShell,
248+
notice: string,
249+
workerWait: string,
250+
): string {
189251
const meter = shell.costContext;
190252
return [
191253
notice,
254+
workerWait,
192255
pendingColumnRows(shell.session.items)
193256
.map((row) => `${row.tag ?? ""}:${row.text}`)
194257
.join("\u0001"),
@@ -240,7 +303,15 @@ export function paintChrome(
240303
if (shell.renderer.isDestroyed || shell.notice.isDestroyed) return;
241304
syncPending(shell);
242305
const notice = noticeText(shell);
243-
const key = chromeComposeKey(shell, notice);
306+
const workerWait = composeWorkerWaitLine(
307+
shell.workerWait,
308+
shell.layout.contentWidth,
309+
);
310+
const key = chromeComposeKey(
311+
shell,
312+
notice,
313+
workerWait.map((part) => part.text).join(""),
314+
);
244315
if (!opts?.force && paintedChromeKey.get(shell) === key) return;
245316
paintedChromeKey.set(shell, key);
246317
chromeComposeCounts.set(shell, chromeComposeCount(shell) + 1);
@@ -252,6 +323,20 @@ export function paintChrome(
252323
syncLandingSuggestions(shell);
253324
syncNoticeRow(shell, notice);
254325
syncPendingColumn(shell);
326+
shell.workerWaitRow.content = new StyledText(workerWaitChunks(workerWait));
327+
syncWorkerWaitRow(shell, workerWait.length > 0);
328+
}
329+
330+
/**
331+
* Give the WORKER WAITING strip its row while any worker is parked, and take
332+
* it back once the final identity leaves the snapshot. Same transient
333+
* contract as the notice row; the resolver seats it, so it never overlaps.
334+
*/
335+
function syncWorkerWaitRow(shell: AppShell, wanted: boolean): void {
336+
const bag = shellInternals(shell);
337+
if (bag === undefined) return;
338+
if ((bag.visibility.workerWait ?? false) === wanted) return;
339+
relayout(shell, { visibility: { ...bag.visibility, workerWait: wanted } });
255340
}
256341

257342
/**
@@ -879,6 +964,10 @@ export function applyLayout(shell: AppShell, layout: GeometryLayout): void {
879964
shell.pendingBox.height = pendingH > 0 ? pendingH : 1;
880965
shell.pendingBox.visible = pendingH > 0;
881966

967+
const workerWaitH = Math.max(0, h.worker_wait);
968+
shell.workerWaitRow.height = workerWaitH > 0 ? workerWaitH : 1;
969+
shell.workerWaitRow.visible = workerWaitH > 0;
970+
882971
const promptH = Math.max(0, h.prompt);
883972
shell.promptBox.height = promptH > 0 ? promptH : 1;
884973
shell.promptBox.visible = promptH > 0;
@@ -901,8 +990,9 @@ export function applyLayout(shell: AppShell, layout: GeometryLayout): void {
901990
// Rows the flow spends before the prompt box — where a floated host's bottom
902991
// edge has to land, since the landing's box sits mid-screen rather than at
903992
// the foot and covering it would hide the thing the operator types into.
904-
// Stack: topPad, transcript, agents, task, then prompt (notice and the
905-
// pending column omitted — both are transient chrome above the prompt).
993+
// Stack: topPad, transcript, agents, task, then prompt (notice, the
994+
// pending column and the worker-wait strip omitted: all transient chrome
995+
// above the prompt, so the float ends above them rather than over them).
906996
const promptTop = padH + transcriptBody + agentsH + taskH;
907997
const hostH = floating
908998
? Math.min(overlayH, Math.max(1, promptTop))

‎src/tui/shell/index.ts‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,7 @@ import {
7373
syncPromptHighlights,
7474
} from "./prompt.js";
7575
import { armMarkdownLinks } from "../url-links.js";
76+
import { NO_WORKER_WAIT } from "../worker-wait.js";
7677
import {
7778
createShellKeyHandlers,
7879
routePromptWheelToTranscript,
@@ -264,6 +265,15 @@ export function createAppShell(
264265
visible: layout.heights.pending > 0,
265266
});
266267

268+
// Off until a pending-ask snapshot names a parked root worker.
269+
const workerWaitRow = new TextRenderable(ctx, {
270+
id: "shell-worker-wait",
271+
height: Math.max(1, layout.heights.worker_wait),
272+
content: "",
273+
fg: UI.textDim,
274+
visible: layout.heights.worker_wait > 0,
275+
});
276+
267277
const promptBox = new BoxRenderable(ctx, {
268278
id: "shell-prompt-region",
269279
width: "100%",
@@ -325,6 +335,7 @@ export function createAppShell(
325335
root.add(taskBox);
326336
root.add(notice);
327337
root.add(pendingBox);
338+
root.add(workerWaitRow);
328339
root.add(promptBox);
329340
root.add(landingBelow);
330341
root.add(bottomPad);
@@ -431,6 +442,7 @@ export function createAppShell(
431442
promptBottomRule,
432443
notice,
433444
pendingBox,
445+
workerWaitRow,
434446
layout,
435447
focus: createFocusState(),
436448
session,
@@ -455,6 +467,7 @@ export function createAppShell(
455467
statusFlash: null,
456468
mcpNeedsAuth: [],
457469
pluginNeedsAttention: false,
470+
workerWait: NO_WORKER_WAIT,
458471
lockupNowMs: 0,
459472
inFlightTool: null,
460473
lockupAnimating: false,

0 commit comments

Comments
 (0)