Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions docs/TUI.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
41 changes: 41 additions & 0 deletions src/tui/chrome-repaint.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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);
});
});
});
26 changes: 26 additions & 0 deletions src/tui/geometry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
3 changes: 3 additions & 0 deletions src/tui/geometry/resolve.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down Expand Up @@ -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
Expand Down
19 changes: 17 additions & 2 deletions src/tui/geometry/zones.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ const ZONE_IDS = [
"progress_divider",
"notice",
"pending",
"worker_wait",
"prompt",
"task",
"agents",
Expand Down Expand Up @@ -82,6 +83,15 @@ export const ZONE_REGISTRY: Readonly<Record<ZoneId, ZoneDeclaration>> = {
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: {
Expand Down Expand Up @@ -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[];
Expand All @@ -212,6 +226,7 @@ export const PAINT_ORDER = [
"progress_divider",
"notice",
"pending",
"worker_wait",
"prompt",
] as const satisfies readonly ZoneId[];

Expand Down
Loading
Loading