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
41 changes: 36 additions & 5 deletions docs/TUI.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,9 @@ terminal with nothing optional showing, the transcript floor is 12 rows
(`IDLE_TRANSCRIPT_FLOOR`); with an inset overlay open the floor drops to 8 rows (`OVERLAY_TRANSCRIPT_FLOOR`) so the log stays glanceable
underneath a permission prompt. When space is scarce, collapse follows a
fixed order — transient banners first, then settings/plugin notices, then
task/agents strips, then progress, then the INPUT REQUIRED strip (the last
optional row), then the prompt itself shrinks one
task/agents strips, then progress, then the operator's own queued drafts
(pending), then the QUEUED INPUT strip, then WORKER WAITING, then INPUT
REQUIRED (the last optional row), then the prompt itself shrinks one
row at a time down to its 3-row base — never the transcript
(`COLLAPSE_ORDER` in `zones.ts`).

Expand Down Expand Up @@ -311,10 +312,14 @@ 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`):
WAITING** strip sits on the prompt box (zone `worker_wait`). When the INPUT
REQUIRED or QUEUED INPUT strips are also visible, they stack between it and
the prompt box, in that order:

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

It names the worker, previews its question, and says the director owns the
Expand Down Expand Up @@ -354,8 +359,34 @@ dispose clears every item (`src/tui/operator-input-required.ts`, fed from
question shows at a time, with `(+N more)` for the rest. At narrow widths the
question truncates and then drops first, then the routing copy. Below 26
cells the `(+N more)` count is sliced away (cut at 25, gone at 16), leaving
only the label. When WORKER WAITING is also showing, it sits directly above
this strip.
only the label. WORKER WAITING, when showing, sits directly above this
strip, and QUEUED INPUT sits directly below it.

### QUEUED INPUT strip

While an operator-originated message has been accepted by the live agent but
not yet read by the director, a one-row **QUEUED INPUT** strip sits directly
on the prompt box (zone `queued_input`):

```
◆ QUEUED INPUT · 1
```

It counts accepted-but-unread operator input (mid-run steers and sent
follow-ups) while the runtime owns the message but the director has not yet
dequeued it. It is display only: no routing, mailbox ordering, transcript
row, echo, or recovery behavior changes. The strip reads the accepted-input
ledger and nothing else (`src/tui/queued-input.ts`, painted by `paintChrome`
in `shell/chrome.ts`). An item's identity is the inbound message's header
`messageId`: it enters when the delivery hop reports `accepted`, and it
leaves on the reactor's `message.run.started` event, which fires at the
moment the message is dequeued for the director. A message whose read signal
arrived before its acceptance (the send path) never paints. Worker wakes and
other harness-originated text never enter the ledger, so they never paint
this strip. `/clear` and `/new` clear it with the session, and a stale
acceptance from a cleared session is ignored. The row is one line, never
consumes or edits the prompt, and collapses before WORKER WAITING under
height pressure.

### Transcript spawn_agent rows (history anchors)

Expand Down
39 changes: 31 additions & 8 deletions src/tui/delivery-queue.ts
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,12 @@ export interface QueueItem {
readonly enqueuedAt: number;
/** Images attached to this message, delivered with it at the boundary. */
readonly attachments?: readonly PendingImageAttachment[];
/**
* False marks harness-originated text (ask wakes, mailbox mail) so it never
* enters the queued-input ledger as operator input; absent means composer
* input.
*/
readonly operatorOriginated?: boolean;
}

export type RunState = "idle" | "busy";
Expand Down Expand Up @@ -457,11 +463,13 @@ export interface RouteQueuedDeliveryArgs {
text: string,
attachments?: readonly PendingImageAttachment[],
settle?: DeliverySettle,
operatorOriginated?: boolean,
) => void;
deliverSteer: (
text: string,
attachments?: readonly PendingImageAttachment[],
settle?: DeliverySettle,
operatorOriginated?: boolean,
) => void;
/**
* True only while the bridge is draining steers at a live parent
Expand All @@ -474,12 +482,12 @@ export interface RouteQueuedDeliveryArgs {
export function routeQueuedDelivery(
args: RouteQueuedDeliveryArgs,
): ProductHostDeliver {
return (text, kind, attachments, settle) => {
return (text, kind, attachments, settle, operatorOriginated) => {
if (kind === "steer" && args.parentCycleLive()) {
args.deliverSteer(text, attachments, settle);
args.deliverSteer(text, attachments, settle, operatorOriginated);
return;
}
args.send(text, attachments, settle);
args.send(text, attachments, settle, operatorOriginated);
};
}

Expand Down Expand Up @@ -531,6 +539,7 @@ export interface CreateLiveSteerDeliverArgs {
text: string,
attachments: readonly PendingImageAttachment[],
settle?: DeliverySettle,
operatorOriginated?: boolean,
) => void;
captureGeneration: () => () => boolean;
onFailure: (err: unknown) => void;
Expand All @@ -549,6 +558,8 @@ export interface CreateLeftoverSendArgs {
send: (
text: string,
attachments: readonly PendingImageAttachment[],
settle?: DeliverySettle,
operatorOriginated?: boolean,
) => MaybeAsyncDeliveryResult;
/**
* Up/Down recall. Called with the original text only when the hop is
Expand All @@ -569,6 +580,7 @@ interface GenerationGatedHopArgs {
text: string,
attachments: readonly PendingImageAttachment[],
settle?: DeliverySettle,
operatorOriginated?: boolean,
) => MaybeAsyncDeliveryResult;
/**
* Leftover/send settles from the send promise; live steer uses the
Expand All @@ -593,8 +605,9 @@ function createGenerationGatedHop(
text: string,
attachments?: readonly PendingImageAttachment[],
settle?: DeliverySettle,
operatorOriginated?: boolean,
) => void {
return (text, attachments, settle) => {
return (text, attachments, settle, operatorOriginated) => {
const stillCurrent = args.captureGeneration();
const pending = attachments ?? [];
let settled = false;
Expand Down Expand Up @@ -635,14 +648,24 @@ function createGenerationGatedHop(
}
args.recordSent?.(text);
if (args.settleFromHopResult === true) {
const result = await args.hop(ingested.text, ingested.attachments);
const result = await args.hop(
ingested.text,
ingested.attachments,
undefined,
operatorOriginated,
);
finish(result ?? { status: "accepted" });
return;
}
// Live steer: hop receives settle and reports the eventual result.
args.hop(ingested.text, ingested.attachments, (result) => {
finish(result);
});
args.hop(
ingested.text,
ingested.attachments,
(result) => {
finish(result);
},
operatorOriginated,
);
})
.catch((err: unknown) => {
finish({
Expand Down
176 changes: 176 additions & 0 deletions src/tui/geometry/resolve.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
/**
* QUEUED INPUT zone geometry: requested height, seating on the prompt box,
* coexistence with WORKER WAITING / pending, collapse before worker_wait and
* restore when room returns, and in-bounds no-overlap on a short terminal.
*/
import { describe, expect, test } from "bun:test";
import { defined } from "../../../testkit/defined.js";
import {
COLLAPSE_ORDER,
IDLE_TRANSCRIPT_FLOOR,
PROMPT_BASE_ROWS,
PROMPT_IDLE_ROWS,
ZONE_REGISTRY,
resolveGeometry,
type GeometryInput,
} from "./index.js";
import { PAINT_ORDER } from "./zones.js";

function idle80x24(overrides: Partial<GeometryInput> = {}) {
return resolveGeometry({
terminal: { columns: 80, rows: 24 },
...overrides,
});
}

describe("queued_input zone", () => {
test("requested visibility seats exactly one row directly on the prompt box", () => {
const baseline = idle80x24();
const layout = idle80x24({ visibility: { queuedInput: true } });
const strip = defined(layout.regions.queued_input, "queued_input");
const prompt = defined(layout.regions.prompt, "prompt");
expect(strip.height).toBe(1);
expect(strip.y + strip.height).toBe(prompt.y);
// The strip takes its row from the transcript, never the composer.
expect(layout.heights.prompt).toBe(baseline.heights.prompt);
expect(layout.transcriptHeight).toBe(baseline.transcriptHeight - 1);
});

test("zero visibility costs zero chrome", () => {
const layout = idle80x24();
expect(layout.heights.queued_input).toBe(0);
expect(layout.regions.queued_input).toBeUndefined();
});

test("sits between worker_wait and prompt in paint order", () => {
expect(PAINT_ORDER.indexOf("worker_wait")).toBeLessThan(
PAINT_ORDER.indexOf("queued_input"),
);
expect(PAINT_ORDER.indexOf("queued_input")).toBeLessThan(
PAINT_ORDER.indexOf("prompt"),
);
const layout = idle80x24({
visibility: { workerWait: true, queuedInput: true },
});
const waiting = defined(layout.regions.worker_wait, "worker_wait");
const queued = defined(layout.regions.queued_input, "queued_input");
const prompt = defined(layout.regions.prompt, "prompt");
expect(waiting.height).toBe(1);
expect(queued.height).toBe(1);
expect(waiting.y + waiting.height).toBe(queued.y);
expect(queued.y + queued.height).toBe(prompt.y);
expect(layout.heights.prompt).toBe(PROMPT_IDLE_ROWS);
});

test("coexists with pending items above worker_wait", () => {
const layout = idle80x24({
visibility: { pending: 2, workerWait: true, queuedInput: true },
});
const pending = defined(layout.regions.pending, "pending");
const waiting = defined(layout.regions.worker_wait, "worker_wait");
const queued = defined(layout.regions.queued_input, "queued_input");
const prompt = defined(layout.regions.prompt, "prompt");
expect(pending.height).toBe(2);
expect(waiting.height).toBe(1);
expect(queued.height).toBe(1);
expect(pending.y + pending.height).toBe(waiting.y);
expect(waiting.y + waiting.height).toBe(queued.y);
expect(queued.y + queued.height).toBe(prompt.y);
expect(layout.transcriptHeight).toBeGreaterThanOrEqual(
layout.transcriptFloor,
);
});

test("collapses before worker_wait under height pressure", () => {
// Pending own-input rows cut after banners but before the queued-input
// strip; the queued-input strip cuts before the worker-wait strip.
expect(COLLAPSE_ORDER.indexOf("pending")).toBeLessThan(
COLLAPSE_ORDER.indexOf("queued_input"),
);
expect(COLLAPSE_ORDER.indexOf("queued_input")).toBeLessThan(
COLLAPSE_ORDER.indexOf("worker_wait"),
);
expect(COLLAPSE_ORDER.indexOf("worker_wait")).toBeLessThan(
COLLAPSE_ORDER.indexOf("prompt"),
);
const layout = resolveGeometry({
terminal: { columns: 80, rows: 18 },
visibility: {
notice: true,
pending: 6,
agents: 11,
task: 3,
workerWait: true,
queuedInput: true,
},
});
expect(layout.heights.notice).toBe(0);
expect(layout.heights.pending).toBe(0);
expect(layout.heights.queued_input).toBe(0);
// A parked worker stays visible: its row is the last optional cut.
expect(layout.heights.worker_wait).toBe(1);
expect(layout.transcriptHeight).toBeGreaterThanOrEqual(
layout.transcriptFloor,
);
});

test("restores when room returns after a constrained-height collapse", () => {
const narrow = resolveGeometry({
terminal: { columns: 80, rows: 18 },
visibility: {
notice: true,
pending: 6,
agents: 11,
task: 3,
workerWait: true,
queuedInput: true,
},
});
expect(narrow.heights.queued_input).toBe(0);
// The ledger is untouched by collapse; the same visibility on a taller
// terminal seats the strip again.
const restored = resolveGeometry({
terminal: { columns: 80, rows: 24 },
visibility: {
notice: true,
pending: 6,
agents: 11,
task: 3,
workerWait: true,
queuedInput: true,
},
});
expect(restored.heights.queued_input).toBe(1);
expect(defined(restored.regions.queued_input).height).toBe(1);
expect(restored.transcriptHeight).toBeGreaterThanOrEqual(
IDLE_TRANSCRIPT_FLOOR,
);
});

test("short terminal stays in bounds with no overlap", () => {
const layout = resolveGeometry({
terminal: { columns: 12, rows: 10 },
visibility: { pending: 1, workerWait: true, queuedInput: true },
});
const prompt = defined(layout.regions.prompt, "prompt");
expect(prompt.y + prompt.height).toBeLessThanOrEqual(10);
expect(layout.heights.prompt).toBeGreaterThanOrEqual(PROMPT_BASE_ROWS);
// Whatever collapsed, the visible regions y-stack without overlap.
const regions = PAINT_ORDER.map((id) => layout.regions[id]).filter(
(region) => region !== undefined && region.height > 0,
);
for (let index = 1; index < regions.length; index += 1) {
const previous = regions[index - 1];
const current = regions[index];
if (previous === undefined || current === undefined) continue;
expect(previous.y + previous.height).toBe(current.y);
}
expect(layout.regions.queued_input).toBeUndefined();
});

test("registry bounds match the zone declaration", () => {
expect(ZONE_REGISTRY.queued_input.min).toBe(0);
expect(ZONE_REGISTRY.queued_input.max).toBe(1);
expect(ZONE_REGISTRY.queued_input.alwaysOn).toBe(false);
});
});
3 changes: 3 additions & 0 deletions src/tui/geometry/resolve.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,8 @@ export interface ZoneVisibility {
readonly workerWait?: boolean;
/** INPUT REQUIRED strip for marked primary ask_operator gates (default off). */
readonly inputRequired?: boolean;
/** Accepted operator input awaiting the correlated runtime echo. */
readonly queuedInput?: 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 @@ -153,6 +155,7 @@ export function desiredHeights(input: GeometryInput): MutableHeights {
pending: clamp(boolOrRows(vis.pending, 1), 0, ZONE_REGISTRY.pending.max),
worker_wait: vis.workerWait === true ? 1 : 0,
input_required: vis.inputRequired === true ? 1 : 0,
queued_input: vis.queuedInput === 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
Loading
Loading