Skip to content

Commit 3899e2e

Browse files
committed
feat(tui): show queued operator input
1 parent 0481351 commit 3899e2e

21 files changed

Lines changed: 1608 additions & 25 deletions

‎docs/TUI.md‎

Lines changed: 36 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -29,8 +29,9 @@ terminal with nothing optional showing, the transcript floor is 12 rows
2929
(`IDLE_TRANSCRIPT_FLOOR`); with an inset overlay open the floor drops to 8 rows (`OVERLAY_TRANSCRIPT_FLOOR`) so the log stays glanceable
3030
underneath a permission prompt. When space is scarce, collapse follows a
3131
fixed order — transient banners first, then settings/plugin notices, then
32-
task/agents strips, then progress, then the INPUT REQUIRED strip (the last
33-
optional row), then the prompt itself shrinks one
32+
task/agents strips, then progress, then the operator's own queued drafts
33+
(pending), then the QUEUED INPUT strip, then WORKER WAITING, then INPUT
34+
REQUIRED (the last optional row), then the prompt itself shrinks one
3435
row at a time down to its 3-row base — never the transcript
3536
(`COLLAPSE_ORDER` in `zones.ts`).
3637

@@ -311,10 +312,14 @@ clocks.
311312
### WORKER WAITING strip
312313

313314
While any root worker is parked on `ask_director`, a one-row **WORKER
314-
WAITING** strip sits directly on the prompt box (zone `worker_wait`):
315+
WAITING** strip sits on the prompt box (zone `worker_wait`). When the INPUT
316+
REQUIRED or QUEUED INPUT strips are also visible, they stack between it and
317+
the prompt box, in that order:
315318

316319
```
317320
◆ WORKER WAITING · director reply needed · builder (copy assets): Which destination path should I use? (+1 more)
321+
◆ INPUT REQUIRED · operator answer needed · Which environment should I deploy to?
322+
◆ QUEUED INPUT · 1
318323
```
319324

320325
It names the worker, previews its question, and says the director owns the
@@ -353,8 +358,34 @@ dispose clears every item (`src/tui/operator-input-required.ts`, fed from
353358
`gate-wire.ts` and painted by `paintChrome` in `shell/chrome.ts`). One
354359
question shows at a time, with `(+N more)` for the rest. At narrow widths the
355360
question truncates and then drops first, then the routing copy, while the
356-
label and the count stay. When WORKER WAITING is also showing, it sits directly above
357-
this strip.
361+
label and the count stay. WORKER WAITING, when showing, sits directly above
362+
this strip, and QUEUED INPUT sits directly below it.
363+
364+
### QUEUED INPUT strip
365+
366+
While an operator-originated message has been accepted by the live agent but
367+
not yet read by the director, a one-row **QUEUED INPUT** strip sits directly
368+
on the prompt box (zone `queued_input`):
369+
370+
```
371+
◆ QUEUED INPUT · 1
372+
```
373+
374+
It counts accepted-but-unread operator input (mid-run steers and sent
375+
follow-ups) while the runtime owns the message but the director has not yet
376+
dequeued it. It is display only: no routing, mailbox ordering, transcript
377+
row, echo, or recovery behavior changes. The strip reads the accepted-input
378+
ledger and nothing else (`src/tui/queued-input.ts`, painted by `paintChrome`
379+
in `shell/chrome.ts`). An item's identity is the inbound message's header
380+
`messageId`: it enters when the delivery hop reports `accepted`, and it
381+
leaves on the reactor's `message.run.started` event, which fires at the
382+
moment the message is dequeued for the director. A message whose read signal
383+
arrived before its acceptance (the send path) never paints. Worker wakes and
384+
other harness-originated text never enter the ledger, so they never paint
385+
this strip. `/clear` and `/new` clear it with the session, and a stale
386+
acceptance from a cleared session is ignored. The row is one line, never
387+
consumes or edits the prompt, and collapses before WORKER WAITING under
388+
height pressure.
358389

359390
### Transcript spawn_agent rows (history anchors)
360391

‎src/tui/delivery-queue.ts‎

Lines changed: 31 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -192,6 +192,12 @@ export interface QueueItem {
192192
readonly enqueuedAt: number;
193193
/** Images attached to this message, delivered with it at the boundary. */
194194
readonly attachments?: readonly PendingImageAttachment[];
195+
/**
196+
* False marks harness-originated text (ask wakes, mailbox mail) so it never
197+
* enters the queued-input ledger as operator input; absent means composer
198+
* input.
199+
*/
200+
readonly operatorOriginated?: boolean;
195201
}
196202

197203
export type RunState = "idle" | "busy";
@@ -457,11 +463,13 @@ export interface RouteQueuedDeliveryArgs {
457463
text: string,
458464
attachments?: readonly PendingImageAttachment[],
459465
settle?: DeliverySettle,
466+
operatorOriginated?: boolean,
460467
) => void;
461468
deliverSteer: (
462469
text: string,
463470
attachments?: readonly PendingImageAttachment[],
464471
settle?: DeliverySettle,
472+
operatorOriginated?: boolean,
465473
) => void;
466474
/**
467475
* True only while the bridge is draining steers at a live parent
@@ -474,12 +482,12 @@ export interface RouteQueuedDeliveryArgs {
474482
export function routeQueuedDelivery(
475483
args: RouteQueuedDeliveryArgs,
476484
): ProductHostDeliver {
477-
return (text, kind, attachments, settle) => {
485+
return (text, kind, attachments, settle, operatorOriginated) => {
478486
if (kind === "steer" && args.parentCycleLive()) {
479-
args.deliverSteer(text, attachments, settle);
487+
args.deliverSteer(text, attachments, settle, operatorOriginated);
480488
return;
481489
}
482-
args.send(text, attachments, settle);
490+
args.send(text, attachments, settle, operatorOriginated);
483491
};
484492
}
485493

@@ -531,6 +539,7 @@ export interface CreateLiveSteerDeliverArgs {
531539
text: string,
532540
attachments: readonly PendingImageAttachment[],
533541
settle?: DeliverySettle,
542+
operatorOriginated?: boolean,
534543
) => void;
535544
captureGeneration: () => () => boolean;
536545
onFailure: (err: unknown) => void;
@@ -549,6 +558,8 @@ export interface CreateLeftoverSendArgs {
549558
send: (
550559
text: string,
551560
attachments: readonly PendingImageAttachment[],
561+
settle?: DeliverySettle,
562+
operatorOriginated?: boolean,
552563
) => MaybeAsyncDeliveryResult;
553564
/**
554565
* Up/Down recall. Called with the original text only when the hop is
@@ -569,6 +580,7 @@ interface GenerationGatedHopArgs {
569580
text: string,
570581
attachments: readonly PendingImageAttachment[],
571582
settle?: DeliverySettle,
583+
operatorOriginated?: boolean,
572584
) => MaybeAsyncDeliveryResult;
573585
/**
574586
* Leftover/send settles from the send promise; live steer uses the
@@ -593,8 +605,9 @@ function createGenerationGatedHop(
593605
text: string,
594606
attachments?: readonly PendingImageAttachment[],
595607
settle?: DeliverySettle,
608+
operatorOriginated?: boolean,
596609
) => void {
597-
return (text, attachments, settle) => {
610+
return (text, attachments, settle, operatorOriginated) => {
598611
const stillCurrent = args.captureGeneration();
599612
const pending = attachments ?? [];
600613
let settled = false;
@@ -635,14 +648,24 @@ function createGenerationGatedHop(
635648
}
636649
args.recordSent?.(text);
637650
if (args.settleFromHopResult === true) {
638-
const result = await args.hop(ingested.text, ingested.attachments);
651+
const result = await args.hop(
652+
ingested.text,
653+
ingested.attachments,
654+
undefined,
655+
operatorOriginated,
656+
);
639657
finish(result ?? { status: "accepted" });
640658
return;
641659
}
642660
// Live steer: hop receives settle and reports the eventual result.
643-
args.hop(ingested.text, ingested.attachments, (result) => {
644-
finish(result);
645-
});
661+
args.hop(
662+
ingested.text,
663+
ingested.attachments,
664+
(result) => {
665+
finish(result);
666+
},
667+
operatorOriginated,
668+
);
646669
})
647670
.catch((err: unknown) => {
648671
finish({

‎src/tui/geometry/resolve.test.ts‎

Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,176 @@
1+
/**
2+
* QUEUED INPUT zone geometry: requested height, seating on the prompt box,
3+
* coexistence with WORKER WAITING / pending, collapse before worker_wait and
4+
* restore when room returns, and in-bounds no-overlap on a short terminal.
5+
*/
6+
import { describe, expect, test } from "bun:test";
7+
import { defined } from "../../../testkit/defined.js";
8+
import {
9+
COLLAPSE_ORDER,
10+
IDLE_TRANSCRIPT_FLOOR,
11+
PROMPT_BASE_ROWS,
12+
PROMPT_IDLE_ROWS,
13+
ZONE_REGISTRY,
14+
resolveGeometry,
15+
type GeometryInput,
16+
} from "./index.js";
17+
import { PAINT_ORDER } from "./zones.js";
18+
19+
function idle80x24(overrides: Partial<GeometryInput> = {}) {
20+
return resolveGeometry({
21+
terminal: { columns: 80, rows: 24 },
22+
...overrides,
23+
});
24+
}
25+
26+
describe("queued_input zone", () => {
27+
test("requested visibility seats exactly one row directly on the prompt box", () => {
28+
const baseline = idle80x24();
29+
const layout = idle80x24({ visibility: { queuedInput: true } });
30+
const strip = defined(layout.regions.queued_input, "queued_input");
31+
const prompt = defined(layout.regions.prompt, "prompt");
32+
expect(strip.height).toBe(1);
33+
expect(strip.y + strip.height).toBe(prompt.y);
34+
// The strip takes its row from the transcript, never the composer.
35+
expect(layout.heights.prompt).toBe(baseline.heights.prompt);
36+
expect(layout.transcriptHeight).toBe(baseline.transcriptHeight - 1);
37+
});
38+
39+
test("zero visibility costs zero chrome", () => {
40+
const layout = idle80x24();
41+
expect(layout.heights.queued_input).toBe(0);
42+
expect(layout.regions.queued_input).toBeUndefined();
43+
});
44+
45+
test("sits between worker_wait and prompt in paint order", () => {
46+
expect(PAINT_ORDER.indexOf("worker_wait")).toBeLessThan(
47+
PAINT_ORDER.indexOf("queued_input"),
48+
);
49+
expect(PAINT_ORDER.indexOf("queued_input")).toBeLessThan(
50+
PAINT_ORDER.indexOf("prompt"),
51+
);
52+
const layout = idle80x24({
53+
visibility: { workerWait: true, queuedInput: true },
54+
});
55+
const waiting = defined(layout.regions.worker_wait, "worker_wait");
56+
const queued = defined(layout.regions.queued_input, "queued_input");
57+
const prompt = defined(layout.regions.prompt, "prompt");
58+
expect(waiting.height).toBe(1);
59+
expect(queued.height).toBe(1);
60+
expect(waiting.y + waiting.height).toBe(queued.y);
61+
expect(queued.y + queued.height).toBe(prompt.y);
62+
expect(layout.heights.prompt).toBe(PROMPT_IDLE_ROWS);
63+
});
64+
65+
test("coexists with pending items above worker_wait", () => {
66+
const layout = idle80x24({
67+
visibility: { pending: 2, workerWait: true, queuedInput: true },
68+
});
69+
const pending = defined(layout.regions.pending, "pending");
70+
const waiting = defined(layout.regions.worker_wait, "worker_wait");
71+
const queued = defined(layout.regions.queued_input, "queued_input");
72+
const prompt = defined(layout.regions.prompt, "prompt");
73+
expect(pending.height).toBe(2);
74+
expect(waiting.height).toBe(1);
75+
expect(queued.height).toBe(1);
76+
expect(pending.y + pending.height).toBe(waiting.y);
77+
expect(waiting.y + waiting.height).toBe(queued.y);
78+
expect(queued.y + queued.height).toBe(prompt.y);
79+
expect(layout.transcriptHeight).toBeGreaterThanOrEqual(
80+
layout.transcriptFloor,
81+
);
82+
});
83+
84+
test("collapses before worker_wait under height pressure", () => {
85+
// Pending own-input rows cut after banners but before the queued-input
86+
// strip; the queued-input strip cuts before the worker-wait strip.
87+
expect(COLLAPSE_ORDER.indexOf("pending")).toBeLessThan(
88+
COLLAPSE_ORDER.indexOf("queued_input"),
89+
);
90+
expect(COLLAPSE_ORDER.indexOf("queued_input")).toBeLessThan(
91+
COLLAPSE_ORDER.indexOf("worker_wait"),
92+
);
93+
expect(COLLAPSE_ORDER.indexOf("worker_wait")).toBeLessThan(
94+
COLLAPSE_ORDER.indexOf("prompt"),
95+
);
96+
const layout = resolveGeometry({
97+
terminal: { columns: 80, rows: 18 },
98+
visibility: {
99+
notice: true,
100+
pending: 6,
101+
agents: 11,
102+
task: 3,
103+
workerWait: true,
104+
queuedInput: true,
105+
},
106+
});
107+
expect(layout.heights.notice).toBe(0);
108+
expect(layout.heights.pending).toBe(0);
109+
expect(layout.heights.queued_input).toBe(0);
110+
// A parked worker stays visible: its row is the last optional cut.
111+
expect(layout.heights.worker_wait).toBe(1);
112+
expect(layout.transcriptHeight).toBeGreaterThanOrEqual(
113+
layout.transcriptFloor,
114+
);
115+
});
116+
117+
test("restores when room returns after a constrained-height collapse", () => {
118+
const narrow = resolveGeometry({
119+
terminal: { columns: 80, rows: 18 },
120+
visibility: {
121+
notice: true,
122+
pending: 6,
123+
agents: 11,
124+
task: 3,
125+
workerWait: true,
126+
queuedInput: true,
127+
},
128+
});
129+
expect(narrow.heights.queued_input).toBe(0);
130+
// The ledger is untouched by collapse; the same visibility on a taller
131+
// terminal seats the strip again.
132+
const restored = resolveGeometry({
133+
terminal: { columns: 80, rows: 24 },
134+
visibility: {
135+
notice: true,
136+
pending: 6,
137+
agents: 11,
138+
task: 3,
139+
workerWait: true,
140+
queuedInput: true,
141+
},
142+
});
143+
expect(restored.heights.queued_input).toBe(1);
144+
expect(defined(restored.regions.queued_input).height).toBe(1);
145+
expect(restored.transcriptHeight).toBeGreaterThanOrEqual(
146+
IDLE_TRANSCRIPT_FLOOR,
147+
);
148+
});
149+
150+
test("short terminal stays in bounds with no overlap", () => {
151+
const layout = resolveGeometry({
152+
terminal: { columns: 12, rows: 10 },
153+
visibility: { pending: 1, workerWait: true, queuedInput: true },
154+
});
155+
const prompt = defined(layout.regions.prompt, "prompt");
156+
expect(prompt.y + prompt.height).toBeLessThanOrEqual(10);
157+
expect(layout.heights.prompt).toBeGreaterThanOrEqual(PROMPT_BASE_ROWS);
158+
// Whatever collapsed, the visible regions y-stack without overlap.
159+
const regions = PAINT_ORDER.map((id) => layout.regions[id]).filter(
160+
(region) => region !== undefined && region.height > 0,
161+
);
162+
for (let index = 1; index < regions.length; index += 1) {
163+
const previous = regions[index - 1];
164+
const current = regions[index];
165+
if (previous === undefined || current === undefined) continue;
166+
expect(previous.y + previous.height).toBe(current.y);
167+
}
168+
expect(layout.regions.queued_input).toBeUndefined();
169+
});
170+
171+
test("registry bounds match the zone declaration", () => {
172+
expect(ZONE_REGISTRY.queued_input.min).toBe(0);
173+
expect(ZONE_REGISTRY.queued_input.max).toBe(1);
174+
expect(ZONE_REGISTRY.queued_input.alwaysOn).toBe(false);
175+
});
176+
});

‎src/tui/geometry/resolve.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,8 @@ export interface ZoneVisibility {
4343
readonly workerWait?: boolean;
4444
/** INPUT REQUIRED strip for marked primary ask_operator gates (default off). */
4545
readonly inputRequired?: boolean;
46+
/** Accepted operator input awaiting the correlated runtime echo. */
47+
readonly queuedInput?: boolean;
4648
/** Progress: false/omit = 0; true = 2; or explicit 1|2. */
4749
readonly progress?: boolean | 1 | 2;
4850
/** Progress divider (0–1). Default on when progress is shown. */
@@ -153,6 +155,7 @@ export function desiredHeights(input: GeometryInput): MutableHeights {
153155
pending: clamp(boolOrRows(vis.pending, 1), 0, ZONE_REGISTRY.pending.max),
154156
worker_wait: vis.workerWait === true ? 1 : 0,
155157
input_required: vis.inputRequired === true ? 1 : 0,
158+
queued_input: vis.queuedInput === true ? 1 : 0,
156159
prompt: promptRows,
157160
task: clamp(boolOrRows(vis.task, 1), 0, ZONE_REGISTRY.task.max),
158161
// The board asks for exactly the rows it paints; the fraction stops a large

0 commit comments

Comments
 (0)