From b1b64b2c2258c5be319a8909c2cf9947b17c6ef1 Mon Sep 17 00:00:00 2001 From: Ben Wachman Date: Sat, 10 Oct 2026 19:48:51 -0400 Subject: [PATCH] feat(tui): show required operator input at composer --- docs/TUI.md | 28 +- src/tui/gate-events.ts | 17 + src/tui/gate-wire.test.ts | 361 +++++++++++++++++- src/tui/gate-wire.ts | 14 +- src/tui/geometry.test.ts | 80 ++++ src/tui/geometry/resolve.ts | 3 + src/tui/geometry/zones.ts | 16 +- .../operator-input-required-chrome.test.ts | 342 +++++++++++++++++ src/tui/operator-input-required.test.ts | 158 ++++++++ src/tui/operator-input-required.ts | 159 ++++++++ src/tui/product-host.test.ts | 57 +++ src/tui/product-host.ts | 12 +- .../session.operator-input-required.test.ts | 163 ++++++++ src/tui/runner/session.ts | 6 +- src/tui/shell/chrome.ts | 84 +++- src/tui/shell/index.ts | 12 + src/tui/shell/internals.ts | 5 + 17 files changed, 1507 insertions(+), 10 deletions(-) create mode 100644 src/tui/operator-input-required-chrome.test.ts create mode 100644 src/tui/operator-input-required.test.ts create mode 100644 src/tui/operator-input-required.ts create mode 100644 src/tui/runner/session.operator-input-required.test.ts diff --git a/docs/TUI.md b/docs/TUI.md index a41c2f226..4a18263e7 100644 --- a/docs/TUI.md +++ b/docs/TUI.md @@ -29,7 +29,8 @@ 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 prompt itself shrinks one +task/agents strips, then progress, then the INPUT REQUIRED strip (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`). @@ -331,6 +332,31 @@ 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. +### INPUT REQUIRED strip + +While a primary-session `ask_operator` question is unanswered, a one-row +**INPUT REQUIRED** strip sits on the prompt box (zone `input_required`): + +``` +◆ INPUT REQUIRED · operator answer needed · Which environment should I deploy to? (+1 more) +``` + +It keeps the owed answer in view while the operator scrolls, types, or has +the operator modal open. It is display only: the modal stays the one place +to answer, and the strip never changes focus, key routing, overlay order, +timeouts, or cancellation. Only gates the primary session's `ask_operator` +callback marks with `source: "primary-ask-operator"` raise it; permission +gates, MCP-trust gates, and worker `ask_director` questions never do. An +item's identity is its gate id, and it leaves when that gate settles +(answered, cancelled with Esc, timed out, aborted, or torn down); host +dispose clears every item (`src/tui/operator-input-required.ts`, fed from +`gate-wire.ts` and painted by `paintChrome` in `shell/chrome.ts`). One +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. + ### Transcript spawn_agent rows (history anchors) `runtime-bridge` paints each `spawn_agent` call as a transcript stream row for diff --git a/src/tui/gate-events.ts b/src/tui/gate-events.ts index cd3b02a0a..7937dda35 100644 --- a/src/tui/gate-events.ts +++ b/src/tui/gate-events.ts @@ -4,6 +4,11 @@ import type { } from "../permission/types.js"; import type { OperatorResult } from "../agent/tools.js"; +/** Provenance minted only by the primary session's ask_operator callback. */ +export const PRIMARY_ASK_OPERATOR_SOURCE = "primary-ask-operator" as const; + +export type OperatorGateSource = typeof PRIMARY_ASK_OPERATOR_SOURCE; + /** Fail-closed settle when no approval UI can bind the operator's accept. */ export const APPROVAL_UNAVAILABLE_MESSAGE = "no approval UI available; request denied" as const; @@ -27,6 +32,18 @@ export interface OperatorGateEvent { * queue — the modal cannot outlive a tool that already finished. */ signal?: AbortSignal; + /** + * Optional display provenance minted by the session emitter, never inferred + * from an operator question by the TUI. + */ + source?: OperatorGateSource; +} + +/** Only an explicitly marked primary ask may enter input-required chrome. */ +export function isPrimaryAskOperatorEvent( + event: OperatorGateEvent, +): event is OperatorGateEvent & { readonly source: OperatorGateSource } { + return event.source === PRIMARY_ASK_OPERATOR_SOURCE; } export interface PermissionGateEvent { diff --git a/src/tui/gate-wire.test.ts b/src/tui/gate-wire.test.ts index ac5b09459..f5fb1b6e5 100644 --- a/src/tui/gate-wire.test.ts +++ b/src/tui/gate-wire.test.ts @@ -23,6 +23,7 @@ import { import { streamRowGutter } from "./stream.js"; import { APPROVAL_UNAVAILABLE_MESSAGE, + PRIMARY_ASK_OPERATOR_SOURCE, type OperatorGateEvent, type PermissionGateEvent, } from "./gate-events.js"; @@ -36,6 +37,7 @@ import { PERMISSION_ONCE_ID, permissionBodyFromRequest, permissionChoicesFromRequest, + type GateLifecycleHooks, wireGates, } from "./gate-wire.js"; @@ -60,6 +62,7 @@ type GateCtx = { type GateOpts = { readonly terminal?: { readonly columns: number; readonly rows: number }; + readonly hooks?: Partial; }; async function withGates( @@ -70,7 +73,11 @@ async function withGates( await withAppShell( async (shell, h) => { const emitter = new EventEmitter(); - const disposeGates = wireGates(emitter, shell); + const disposeGates = wireGates(emitter, shell, { + onGateOpened: () => undefined, + onGateClosed: () => undefined, + ...opts.hooks, + }); try { await fn({ h, shell, emitter, disposeGates }); } finally { @@ -463,6 +470,358 @@ describe("wireGates", () => { }); }); + test("marked primary asks notify admission and settlement exactly once", async () => { + const admitted: string[] = []; + const settled: string[] = []; + await withGates( + async ({ shell, emitter }) => { + emitOperator(emitter, { source: PRIMARY_ASK_OPERATOR_SOURCE }); + expect(admitted).toEqual(["ask-1"]); + acceptOverlaySelection(shell); + expect(settled).toEqual(["ask-1"]); + closeInsetOverlay(shell); + expect(settled).toEqual(["ask-1"]); + }, + { + hooks: { + onPrimaryOperatorAdmitted: (event) => admitted.push(event.id), + onPrimaryOperatorSettled: (event) => settled.push(event.id), + }, + }, + ); + }); + + test("re-emitting a marked primary ask queues a second gate; both resolvers settle", async () => { + const admitted: string[] = []; + const settled: string[] = []; + const resolved: unknown[] = []; + await withGates( + async ({ shell, emitter }) => { + const event = { + id: "same-ask", + source: PRIMARY_ASK_OPERATOR_SOURCE, + question: "Proceed?", + options: ["Cancel", "Continue"], + resolve: (result: unknown) => resolved.push(result), + }; + emitOperator(emitter, event); + emitOperator(emitter, event); + + // The gate-wire layer no longer drops same-id events: dedup lives in + // the input-required reducer (operator-input-required.ts), which keeps + // the display single while gate-wire preserves HEAD's queue behavior. + expect(admitted).toEqual(["same-ask", "same-ask"]); + expect(shell.overlayKind).toBe("operator"); + + acceptOverlaySelection(shell); + expect(resolved).toEqual([{ kind: "option", index: 0 }]); + // The re-emitted gate was queued, not dropped: it takes the host next. + expect(shell.overlayKind).toBe("operator"); + acceptOverlaySelection(shell); + expect(resolved).toEqual([ + { kind: "option", index: 0 }, + { kind: "option", index: 0 }, + ]); + expect(settled).toEqual(["same-ask", "same-ask"]); + expect(shell.overlayList).toBeNull(); + }, + { + hooks: { + onPrimaryOperatorAdmitted: (event) => admitted.push(event.id), + onPrimaryOperatorSettled: (event) => settled.push(event.id), + }, + }, + ); + }); + + test("two distinct events sharing an id both resolve on accept", async () => { + const admitted: string[] = []; + const settled: string[] = []; + const resolved: unknown[] = []; + await withGates( + async ({ shell, emitter }) => { + emitOperator(emitter, { + id: "dup", + source: PRIMARY_ASK_OPERATOR_SOURCE, + options: ["Cancel", "Continue"], + resolve: (result: unknown) => resolved.push(result), + }); + emitOperator(emitter, { + id: "dup", + source: PRIMARY_ASK_OPERATOR_SOURCE, + options: ["Cancel", "Continue"], + resolve: (result: unknown) => resolved.push(result), + }); + + expect(admitted).toEqual(["dup", "dup"]); + expect(shell.overlayKind).toBe("operator"); + + acceptOverlaySelection(shell); + expect(resolved).toEqual([{ kind: "option", index: 0 }]); + expect(shell.overlayKind).toBe("operator"); + acceptOverlaySelection(shell); + expect(resolved).toEqual([ + { kind: "option", index: 0 }, + { kind: "option", index: 0 }, + ]); + expect(settled).toEqual(["dup", "dup"]); + expect(shell.overlayList).toBeNull(); + }, + { + hooks: { + onPrimaryOperatorAdmitted: (event) => admitted.push(event.id), + onPrimaryOperatorSettled: (event) => settled.push(event.id), + }, + }, + ); + }); + + test("two distinct events sharing an id both settle on timeout", async () => { + const settled: string[] = []; + const resolved: unknown[] = []; + await withGates( + async ({ shell, emitter }) => { + emitOperator(emitter, { + id: "dup", + source: PRIMARY_ASK_OPERATOR_SOURCE, + options: ["Yes", "No"], + timeoutMs: 5, + resolve: (result: unknown) => resolved.push(result), + }); + emitOperator(emitter, { + id: "dup", + source: PRIMARY_ASK_OPERATOR_SOURCE, + options: ["Yes", "No"], + // Arms only once the queued duplicate opens, so the two settles are + // observable separately. + timeoutMs: 80, + resolve: (result: unknown) => resolved.push(result), + }); + expect(shell.overlayKind).toBe("operator"); + + await new Promise((r) => setTimeout(r, 20)); + expect(resolved).toEqual([operatorCancelResult()]); + // The queued duplicate opened once the first timed out. + expect(shell.overlayKind).toBe("operator"); + await new Promise((r) => setTimeout(r, 120)); + expect(resolved).toEqual([ + operatorCancelResult(), + operatorCancelResult(), + ]); + expect(settled).toEqual(["dup", "dup"]); + expect(shell.overlayList).toBeNull(); + }, + { + hooks: { + onPrimaryOperatorSettled: (event) => settled.push(event.id), + }, + }, + ); + }); + + test("a duplicate id still queued at dispose settles via the teardown sweep", async () => { + const settled: string[] = []; + const resolved: unknown[] = []; + await withGates( + async ({ shell, emitter, disposeGates }) => { + emitOperator(emitter, { + id: "dup", + source: PRIMARY_ASK_OPERATOR_SOURCE, + options: ["Yes", "No"], + resolve: (result: unknown) => resolved.push(result), + }); + emitOperator(emitter, { + id: "dup", + source: PRIMARY_ASK_OPERATOR_SOURCE, + options: ["Yes", "No"], + resolve: (result: unknown) => resolved.push(result), + }); + expect(shell.overlayKind).toBe("operator"); + + acceptOverlaySelection(shell); + expect(resolved).toHaveLength(1); + expect(shell.overlayKind).toBe("operator"); + + disposeGates(); + expect(resolved).toEqual([ + { kind: "option", index: 0 }, + operatorCancelResult(), + ]); + expect(settled).toEqual(["dup", "dup"]); + }, + { + hooks: { + onPrimaryOperatorSettled: (event) => settled.push(event.id), + }, + }, + ); + }); + + test("unmarked operator gates retain modal behavior without input-required hooks", async () => { + let admitted = 0; + let settled = 0; + await withGates( + async ({ shell, emitter }) => { + emitOperator(emitter); + expect(shell.overlayKind).toBe("operator"); + acceptOverlaySelection(shell); + expect(admitted).toBe(0); + expect(settled).toBe(0); + }, + { + hooks: { + onPrimaryOperatorAdmitted: () => admitted++, + onPrimaryOperatorSettled: () => settled++, + }, + }, + ); + }); + + test("an MCP-trust-shaped operator gate opens the modal but never fires input-required hooks", async () => { + let admitted = 0; + let settled = 0; + await withGates( + async ({ shell, emitter }) => { + emitOperator(emitter, { + question: "Trust this local MCP server?", + options: ["Trust and connect", "Cancel"], + }); + expect(shell.overlayKind).toBe("operator"); + expect(admitted).toBe(0); + acceptOverlaySelection(shell); + expect(settled).toBe(0); + }, + { + hooks: { + onPrimaryOperatorAdmitted: () => admitted++, + onPrimaryOperatorSettled: () => settled++, + }, + }, + ); + }); + + test("free-text answer settles a marked ask with one admission and one settlement", async () => { + const admitted: string[] = []; + const settled: string[] = []; + const resolved: unknown[] = []; + await withGates( + async ({ shell, emitter }) => { + emitOperator(emitter, { + source: PRIMARY_ASK_OPERATOR_SOURCE, + options: ["Continue"], + resolve: (result: unknown) => resolved.push(result), + }); + expect(admitted).toEqual(["ask-1"]); + setOverlayAnswerActive(shell, true); + for (const ch of "yes") { + handleOverlayAnswerKey(shell, { + name: ch, + sequence: ch, + ctrl: false, + meta: false, + option: false, + } as unknown as KeyEvent); + } + handleOverlayAnswerKey(shell, { + name: "return", + sequence: "", + ctrl: false, + meta: false, + option: false, + } as unknown as KeyEvent); + expect(resolved).toEqual([{ kind: "custom", text: "yes" }]); + expect(settled).toEqual(["ask-1"]); + expect(shell.overlayList).toBeNull(); + }, + { + hooks: { + onPrimaryOperatorAdmitted: (event) => admitted.push(event.id), + onPrimaryOperatorSettled: (event) => settled.push(event.id), + }, + }, + ); + }); + + test("Esc settles a marked ask with one admission and one settlement", async () => { + const admitted: string[] = []; + const settled: string[] = []; + const resolved: unknown[] = []; + await withGates( + async ({ shell, emitter }) => { + emitOperator(emitter, { + source: PRIMARY_ASK_OPERATOR_SOURCE, + resolve: (result: unknown) => resolved.push(result), + }); + expect(admitted).toEqual(["ask-1"]); + closeInsetOverlay(shell); + expect(resolved).toEqual([{ kind: "cancel" }]); + expect(settled).toEqual(["ask-1"]); + expect(shell.overlayList).toBeNull(); + }, + { + hooks: { + onPrimaryOperatorAdmitted: (event) => admitted.push(event.id), + onPrimaryOperatorSettled: (event) => settled.push(event.id), + }, + }, + ); + }); + + test("timeout settles a marked ask with one admission and one settlement", async () => { + const admitted: string[] = []; + const settled: string[] = []; + const resolved: unknown[] = []; + await withGates( + async ({ shell, emitter }) => { + emitOperator(emitter, { + source: PRIMARY_ASK_OPERATOR_SOURCE, + timeoutMs: 5, + resolve: (result: unknown) => resolved.push(result), + }); + expect(admitted).toEqual(["ask-1"]); + expect(shell.overlayKind).toBe("operator"); + await new Promise((r) => setTimeout(r, 20)); + expect(resolved).toEqual([operatorCancelResult()]); + expect(settled).toEqual(["ask-1"]); + expect(shell.overlayList).toBeNull(); + }, + { + hooks: { + onPrimaryOperatorAdmitted: (event) => admitted.push(event.id), + onPrimaryOperatorSettled: (event) => settled.push(event.id), + }, + }, + ); + }); + + test("a marked queued ask settles its own indicator on abort and disposal", async () => { + const settled: string[] = []; + await withGates( + async ({ emitter, disposeGates }) => { + emitOperator(emitter, { + id: "open", + source: PRIMARY_ASK_OPERATOR_SOURCE, + }); + const controller = new AbortController(); + emitOperator(emitter, { + id: "queued", + source: PRIMARY_ASK_OPERATOR_SOURCE, + signal: controller.signal, + }); + controller.abort(); + expect(settled).toEqual(["queued"]); + disposeGates(); + expect(settled).toEqual(["queued", "open"]); + }, + { + hooks: { + onPrimaryOperatorAdmitted: () => undefined, + onPrimaryOperatorSettled: (event) => settled.push(event.id), + }, + }, + ); + }); + test("sequential operator asks paint B's labels and id-scoped values, not A's", async () => { await withGates(async ({ shell, emitter }) => { const settledA = settleCapture(); diff --git a/src/tui/gate-wire.ts b/src/tui/gate-wire.ts index 51f864b24..6f858df25 100644 --- a/src/tui/gate-wire.ts +++ b/src/tui/gate-wire.ts @@ -25,6 +25,7 @@ import { import { EXPAND_KEY } from "./stream.js"; import { APPROVAL_UNAVAILABLE_MESSAGE, + isPrimaryAskOperatorEvent, type OperatorGateEvent, type PermissionGateEvent, } from "./gate-events.js"; @@ -209,6 +210,10 @@ export interface GateLifecycleHooks { readonly onGateOpened: () => void; /** A previously raised gate resolved. */ readonly onGateClosed: () => void; + /** A marked primary ask_operator gate became outstanding. */ + readonly onPrimaryOperatorAdmitted?: (event: OperatorGateEvent) => void; + /** A marked primary ask_operator gate settled. */ + readonly onPrimaryOperatorSettled?: (event: OperatorGateEvent) => void; } const NOOP_GATE_HOOKS: GateLifecycleHooks = { @@ -438,12 +443,16 @@ export function wireGates( } function onOperator(ev: OperatorGateEvent): void { - hooks.onGateOpened(); - const resolve = onceClosed(hooks.onGateClosed, ev.resolve); if (typeof ev.id !== "string" || ev.id.length === 0) { + hooks.onGateOpened(); + const resolve = onceClosed(hooks.onGateClosed, ev.resolve); resolve(operatorCancelResult()); return; } + hooks.onGateOpened(); + const resolve = onceClosed(hooks.onGateClosed, ev.resolve); + const primaryOperator = isPrimaryAskOperatorEvent(ev); + if (primaryOperator) hooks.onPrimaryOperatorAdmitted?.(ev); const choices = operatorChoicesFromOptions(ev.options, ev.id); // Guarded like the permission gate: a settle path that forgets its guard // would double-resolve this promise. @@ -501,6 +510,7 @@ export function wireGates( settled = true; clearTimers(); operatorTeardowns.delete(teardown); + if (primaryOperator) hooks.onPrimaryOperatorSettled?.(ev); if (openedGeneration === undefined) { unqueue(open); } else if (openedGeneration === overlayGeneration) { diff --git a/src/tui/geometry.test.ts b/src/tui/geometry.test.ts index 5d8ed48a5..a1b354544 100644 --- a/src/tui/geometry.test.ts +++ b/src/tui/geometry.test.ts @@ -16,6 +16,7 @@ import { resolveGeometry, type GeometryInput, } from "./geometry/index.js"; +import type { Rect } from "./geometry/resolve.js"; function idle80x24(overrides: Partial = {}) { return resolveGeometry({ @@ -48,6 +49,9 @@ describe("worker wait zone", () => { test("is the last optional row cut before prompt growth is reclaimed", () => { expect(COLLAPSE_ORDER.indexOf("worker_wait")).toBe( + COLLAPSE_ORDER.indexOf("input_required") - 1, + ); + expect(COLLAPSE_ORDER.indexOf("input_required")).toBe( COLLAPSE_ORDER.indexOf("prompt") - 1, ); const layout = resolveGeometry({ @@ -60,6 +64,82 @@ describe("worker wait zone", () => { }); }); +describe("input required zone", () => { + test("requests one row directly on the prompt box without shrinking it", () => { + const idle = idle80x24(); + const layout = idle80x24({ visibility: { inputRequired: true } }); + const strip = defined(layout.regions.input_required, "input_required"); + 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); + expect( + layout.chromeHeight + layout.overlayHeight + layout.transcriptHeight, + ).toBe(24); + }); + + test("collapses before prompt growth and restores to zero after settlement", () => { + const layout = resolveGeometry({ + terminal: { columns: 80, rows: 18 }, + visibility: { + workerWait: true, + inputRequired: true, + notice: true, + pending: 2, + }, + }); + // worker_wait is cut first: input_required is the newest optional strip + // and survives whenever one optional row can be retained. + expect(layout.heights.notice).toBe(0); + expect(layout.heights.pending).toBe(0); + expect(layout.heights.worker_wait).toBe(0); + expect(layout.heights.input_required).toBe(1); + expect( + layout.chromeHeight + layout.overlayHeight + layout.transcriptHeight, + ).toBe(18); + // A settled strip asks for nothing and adds no region. + const restored = idle80x24(); + expect(restored.heights.input_required).toBe(0); + expect(restored.regions.input_required).toBeUndefined(); + }); + + test("a 24-row narrow terminal keeps both strips in bounds with no overlap", () => { + const layout = resolveGeometry({ + terminal: { columns: 40, rows: 24 }, + visibility: { workerWait: true, inputRequired: true }, + }); + const regions = Object.values(layout.regions).filter( + (rect): rect is Rect => rect !== undefined, + ); + expect(regions.length).toBeGreaterThan(0); + for (const rect of regions) { + expect(rect.y).toBeGreaterThanOrEqual(0); + expect(rect.y + rect.height).toBeLessThanOrEqual(24); + expect(rect.x).toBeGreaterThanOrEqual(0); + expect(rect.x + rect.width).toBeLessThanOrEqual(40); + } + for (let i = 0; i < regions.length; i++) { + const a = regions[i]; + if (a === undefined) continue; + for (let j = i + 1; j < regions.length; j++) { + const b = regions[j]; + if (b === undefined) continue; + const overlapY = a.y < b.y + b.height && b.y < a.y + a.height; + expect(overlapY).toBe(false); + } + } + // Vertical order: worker_wait above input_required above prompt. + const worker = defined(layout.regions.worker_wait, "worker_wait"); + const strip = defined(layout.regions.input_required, "input_required"); + const prompt = defined(layout.regions.prompt, "prompt"); + expect(worker.y).toBeLessThan(strip.y); + expect(strip.y + strip.height).toBe(prompt.y); + expect(strip.x).toBe(layout.sideMargin); + expect(strip.width).toBe(layout.contentWidth); + }); +}); + 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 42199a4c7..b2827a4e5 100644 --- a/src/tui/geometry/resolve.ts +++ b/src/tui/geometry/resolve.ts @@ -41,6 +41,8 @@ export interface ZoneVisibility { readonly pending?: boolean | number; /** WORKER WAITING strip on (default off). */ readonly workerWait?: boolean; + /** INPUT REQUIRED strip for marked primary ask_operator gates (default off). */ + readonly inputRequired?: 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. */ @@ -150,6 +152,7 @@ export function desiredHeights(input: GeometryInput): MutableHeights { 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, + input_required: vis.inputRequired === 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 171fe2daf..efde19f0d 100644 --- a/src/tui/geometry/zones.ts +++ b/src/tui/geometry/zones.ts @@ -9,6 +9,7 @@ const ZONE_IDS = [ "notice", "pending", "worker_wait", + "input_required", "prompt", "task", "agents", @@ -92,6 +93,13 @@ export const ZONE_REGISTRY: Readonly> = { idleDefault: 0, alwaysOn: false, }, + input_required: { + id: "input_required", + 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: { @@ -205,9 +213,12 @@ export const COLLAPSE_ORDER = [ // 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. + // once the wake turn scrolls away, so its single row is cut late. "worker_wait", + // An unanswered ask_operator blocks the primary turn on the operator + // alone, so its row is the last optional cut, just ahead of prompt growth + // reclaim. + "input_required", // prompt growth reclaimed next (handled specially; never below PROMPT_BASE_ROWS) "prompt", ] as const satisfies readonly ZoneId[]; @@ -227,6 +238,7 @@ export const PAINT_ORDER = [ "notice", "pending", "worker_wait", + "input_required", "prompt", ] as const satisfies readonly ZoneId[]; diff --git a/src/tui/operator-input-required-chrome.test.ts b/src/tui/operator-input-required-chrome.test.ts new file mode 100644 index 000000000..89f88d5ea --- /dev/null +++ b/src/tui/operator-input-required-chrome.test.ts @@ -0,0 +1,342 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { EventEmitter } from "node:events"; +import { rgbToHex, type CapturedSpan, type RGBA } from "@opentui/core"; + +import type { PendingAskWake } from "../subagent/fleet-report.js"; +import { + PRIMARY_ASK_OPERATOR_SOURCE, + type OperatorGateEvent, +} from "./gate-events.js"; +import { wireGates } from "./gate-wire.js"; +import { withTestRenderer, type Harness } from "./harness.js"; +import { BORDER } from "./prompt-border.js"; +import { repaintShellForTheme } from "./runner/settings.js"; +import { + appendStreamRow, + clearOperatorInputRequired, + paintChrome, + relayout, + setOperatorInputRequiredGate, + 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")); + +function gate(id: string, question: string): OperatorGateEvent { + return { + id, + source: PRIMARY_ASK_OPERATOR_SOURCE, + question, + options: ["Continue"], + resolve: () => undefined, + }; +} + +const WAITING_WORKER: PendingAskWake = { + sessionId: "sess-builder", + agentId: "builder", + description: "copy assets", + question: "Which destination path should I use?", + questionId: "q1", +}; + +/** + * Wire an emitter to the shell the way mountProductHost does, so gate events + * reach the strip through the real admission/settlement hooks. + */ +function wirePrimaryHost(shell: AppShell, emitter: EventEmitter): () => void { + return wireGates(emitter, shell, { + onGateOpened: () => undefined, + onGateClosed: () => undefined, + onPrimaryOperatorAdmitted: (event) => + setOperatorInputRequiredGate(shell, event), + onPrimaryOperatorSettled: (event) => + clearOperatorInputRequired(shell, event.id), + }); +} + +async function withShell( + fn: (shell: AppShell, h: Harness) => Promise, + size = { 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 inputRow(rows: readonly string[]): number { + return rows.findIndex((row) => row.includes("INPUT REQUIRED")); +} + +function hex(color: RGBA): string { + return rgbToHex(color).toLowerCase().slice(0, 7); +} + +function spanWith(h: Harness, needle: string): CapturedSpan | undefined { + return h + .captureSpans() + .lines.flatMap((line) => line.spans) + .find((span) => span.text.includes(needle)); +} + +describe("operator input required strip", () => { + test("seats one deduplicated row above the prompt without changing prompt height", async () => { + await withShell(async (shell, h) => { + const promptHeight = shell.layout.heights.prompt; + setOperatorInputRequiredGate( + shell, + gate("one", "Which branch should I use?"), + ); + setOperatorInputRequiredGate( + shell, + gate("one", "Which branch should I use?"), + ); + setOperatorInputRequiredGate( + shell, + gate("two", "Should I include tests?"), + ); + const rows = await frameRows(h); + const index = inputRow(rows); + const strip = shell.layout.regions.input_required; + const prompt = shell.layout.regions.prompt; + expect(index).toBeGreaterThanOrEqual(0); + expect(rows.filter((row) => row.includes("INPUT REQUIRED"))).toHaveLength( + 1, + ); + expect(rows[index]).toContain("(+1 more)"); + expect(strip?.height).toBe(1); + expect( + strip !== undefined && + prompt !== undefined && + strip.y + strip.height === prompt.y, + ).toBe(true); + expect(rows[index + 1]?.trimStart().startsWith(BORDER.topLeft)).toBe( + true, + ); + expect(shell.layout.heights.prompt).toBe(promptHeight); + }); + }); + + test("resize preserves the row and final settlement restores normal geometry", async () => { + await withShell(async (shell, h) => { + setOperatorInputRequiredGate( + shell, + gate("one", "Which branch should I use?"), + ); + h.resize(32, 30); + relayout(shell, { columns: 32, rows: 30 }); + expect((await frameRows(h))[inputRow(await frameRows(h))]).toContain( + "INPUT REQUIRED", + ); + clearOperatorInputRequired(shell, "one"); + const rows = await frameRows(h); + expect(shell.layout.heights.input_required).toBe(0); + expect(shell.inputRequiredRow.visible).toBe(false); + expect(rows.some((row) => row.includes("INPUT REQUIRED"))).toBe(false); + }); + }); + + for (const theme of ["corbits-dark", "corbits-light"] as const) { + test(`${theme} maps semantic label and routing roles through the active palette`, async () => { + setTheme(theme); + await withShell(async (shell, h) => { + setOperatorInputRequiredGate( + shell, + gate("one", "Which branch should I use?"), + ); + await frameRows(h); + const label = spanWith(h, "INPUT REQUIRED"); + const routing = spanWith(h, "operator answer needed"); + expect(label === undefined ? null : hex(label.fg)).toBe( + UI.action.toLowerCase(), + ); + expect(routing === undefined ? null : 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) => { + setOperatorInputRequiredGate( + shell, + gate("one", "Which branch should I use?"), + ); + await frameRows(h); + setTheme("corbits-light"); + repaintShellForTheme(shell); + await frameRows(h); + const label = spanWith(h, "INPUT REQUIRED"); + 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) => { + setOperatorInputRequiredGate( + shell, + gate("one", "Which branch should I use?"), + ); + const before = await frameRows(h); + const painted = before[inputRow(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); + expect(after[inputRow(after)]).toBe(painted); + expect(shell.layout.heights.input_required).toBe(1); + }); + }); + + test("the strip stays visible while an operator modal is open", async () => { + await withShell(async (shell, h) => { + const emitter = new EventEmitter(); + const disposeGates = wirePrimaryHost(shell, emitter); + try { + emitter.emit("operator.gate", { + id: "one", + source: PRIMARY_ASK_OPERATOR_SOURCE, + question: "Which branch should I use?", + options: ["main", "dev"], + resolve: () => undefined, + }); + expect(shell.overlayKind).toBe("operator"); + const rows = await frameRows(h); + expect(shell.layout.heights.input_required).toBe(1); + expect(rows.some((row) => row.includes("INPUT REQUIRED"))).toBe(true); + } finally { + disposeGates(); + } + }); + }); + + test("settling one of two leaves the other strip in place", async () => { + await withShell(async (shell, h) => { + setOperatorInputRequiredGate( + shell, + gate("one", "Which branch should I use?"), + ); + setOperatorInputRequiredGate( + shell, + gate("two", "Should I include tests?"), + ); + await frameRows(h); + clearOperatorInputRequired(shell, "one"); + const rows = await frameRows(h); + expect(rows.some((row) => row.includes("INPUT REQUIRED"))).toBe(true); + expect(rows.some((row) => row.includes("Should I include tests?"))).toBe( + true, + ); + expect(shell.layout.heights.input_required).toBe(1); + clearOperatorInputRequired(shell, "two"); + const restored = await frameRows(h); + expect(shell.layout.heights.input_required).toBe(0); + expect(restored.some((row) => row.includes("INPUT REQUIRED"))).toBe( + false, + ); + }); + }); + + test("WORKER WAITING and INPUT REQUIRED sit together with correct order", async () => { + await withShell(async (shell, h) => { + setWorkerWaitAsks(shell, [WAITING_WORKER]); + setOperatorInputRequiredGate( + shell, + gate("one", "Which branch should I use?"), + ); + const rows = await frameRows(h); + const worker = rows.findIndex((row) => row.includes("WORKER WAITING")); + const input = rows.findIndex((row) => row.includes("INPUT REQUIRED")); + expect(worker).toBeGreaterThanOrEqual(0); + expect(input).toBeGreaterThan(worker); + expect(rows.filter((row) => row.includes("WORKER WAITING"))).toHaveLength( + 1, + ); + expect(rows.filter((row) => row.includes("INPUT REQUIRED"))).toHaveLength( + 1, + ); + const w = shell.layout.regions.worker_wait; + const i = shell.layout.regions.input_required; + const p = shell.layout.regions.prompt; + expect(w !== undefined && i !== undefined && p !== undefined).toBe(true); + if (w === undefined || i === undefined || p === undefined) return; + expect(w.y).toBeLessThan(i.y); + expect(i.y + i.height).toBe(p.y); + }); + }); + + test("MCP-trust and permission gates never raise an INPUT REQUIRED row", async () => { + await withShell(async (shell, h) => { + const emitter = new EventEmitter(); + const disposeGates = wirePrimaryHost(shell, emitter); + try { + emitter.emit("permission.gate", { + id: "perm-1", + request: { + tool: "run_shell", + action: "Run shell command", + subject: "bun test", + scopes: [], + }, + resolve: () => undefined, + }); + emitter.emit("operator.gate", { + id: "mcp-trust", + question: "Trust this local MCP server?", + options: ["Trust and connect", "Cancel"], + resolve: () => undefined, + }); + const rows = await frameRows(h); + expect(shell.overlayKind).toBe("permissions"); + expect(shell.layout.heights.input_required).toBe(0); + expect(rows.some((row) => row.includes("INPUT REQUIRED"))).toBe(false); + } finally { + disposeGates(); + } + }); + }); + + test("worker ask_director waiting and OAuth-style notices never raise an INPUT REQUIRED row", async () => { + await withShell(async (shell, h) => { + setWorkerWaitAsks(shell, [WAITING_WORKER]); + setStatusFlash(shell, "OAuth token refresh needed"); + const rows = await frameRows(h); + expect(rows.some((row) => row.includes("WORKER WAITING"))).toBe(true); + expect(shell.layout.heights.input_required).toBe(0); + expect(rows.some((row) => row.includes("INPUT REQUIRED"))).toBe(false); + }); + }); +}); diff --git a/src/tui/operator-input-required.test.ts b/src/tui/operator-input-required.test.ts new file mode 100644 index 000000000..317a6f56a --- /dev/null +++ b/src/tui/operator-input-required.test.ts @@ -0,0 +1,158 @@ +import { describe, expect, test } from "bun:test"; + +import type { OperatorGateEvent } from "./gate-events.js"; +import { PRIMARY_ASK_OPERATOR_SOURCE } from "./gate-events.js"; +import { + addOperatorInputRequired, + composeOperatorInputRequiredLine, + NO_OPERATOR_INPUT_REQUIRED, + operatorInputRequiredMoreCount, + removeOperatorInputRequired, +} from "./operator-input-required.js"; +import { stringWidth } from "./view/height.js"; + +function gate( + id: string, + question = "Which option should I choose?", +): OperatorGateEvent { + return { + id, + source: PRIMARY_ASK_OPERATOR_SOURCE, + question, + options: ["one"], + resolve: () => undefined, + }; +} + +function text( + parts: ReturnType, +): string { + return parts.map((part) => part.text).join(""); +} + +describe("operator input required view model", () => { + test("is inactive when empty", () => { + expect( + composeOperatorInputRequiredLine(NO_OPERATOR_INPUT_REQUIRED, 80), + ).toEqual([]); + }); + + test("admits marked primary gates once by id and keeps a duplicate as a no-op", () => { + const first = addOperatorInputRequired( + NO_OPERATOR_INPUT_REQUIRED, + gate("one"), + ); + const again = addOperatorInputRequired(first, gate("one")); + expect(first.items).toHaveLength(1); + expect(again).toBe(first); + expect(text(composeOperatorInputRequiredLine(first, 100))).toContain( + "INPUT REQUIRED", + ); + }); + + test("fails closed for unmarked, foreign-shaped, and empty ids", () => { + const unmarked = { ...gate("one") }; + delete unmarked.source; + expect(addOperatorInputRequired(NO_OPERATOR_INPUT_REQUIRED, unmarked)).toBe( + NO_OPERATOR_INPUT_REQUIRED, + ); + expect(addOperatorInputRequired(NO_OPERATOR_INPUT_REQUIRED, gate(""))).toBe( + NO_OPERATOR_INPUT_REQUIRED, + ); + const permissionLike = { + id: "permission", + request: { tool: "run_shell" }, + resolve: () => undefined, + } as unknown as OperatorGateEvent; + expect( + addOperatorInputRequired(NO_OPERATOR_INPUT_REQUIRED, permissionLike), + ).toBe(NO_OPERATOR_INPUT_REQUIRED); + }); + + test("fails closed for an unrecognized marker string and an MCP-trust-shaped event", () => { + const wrongMarker = { + ...gate("one"), + source: "mcp-trust", + } as unknown as OperatorGateEvent; + expect( + addOperatorInputRequired(NO_OPERATOR_INPUT_REQUIRED, wrongMarker), + ).toBe(NO_OPERATOR_INPUT_REQUIRED); + const mcpTrust = { + id: "mcp-trust", + question: "Trust this local MCP server?", + options: ["Trust and connect", "Cancel"], + resolve: () => undefined, + } as unknown as OperatorGateEvent; + expect(addOperatorInputRequired(NO_OPERATOR_INPUT_REQUIRED, mcpTrust)).toBe( + NO_OPERATOR_INPUT_REQUIRED, + ); + }); + + test("keeps insertion selection and removes exactly the settled id", () => { + const first = addOperatorInputRequired( + NO_OPERATOR_INPUT_REQUIRED, + gate("one"), + ); + const both = addOperatorInputRequired( + first, + gate("two", "Second question"), + ); + expect(operatorInputRequiredMoreCount(both)).toBe(1); + expect(text(composeOperatorInputRequiredLine(both, 100))).toContain( + "(+1 more)", + ); + const afterSecond = removeOperatorInputRequired(both, "two"); + expect(afterSecond.items.map((item) => item.id)).toEqual(["one"]); + const afterFirst = removeOperatorInputRequired(afterSecond, "one"); + expect(afterFirst).toBe(NO_OPERATOR_INPUT_REQUIRED); + expect(removeOperatorInputRequired(afterFirst, "one")).toBe(afterFirst); + }); + + test("removing an id drops every item sharing it, so a duplicate cannot outlive the first settle", () => { + // Unreachable through add (it dedups by id, and gate ids are random UUIDs); + // built directly to pin the intent: one settle clears the whole id. + const duplicated = { + items: [ + { id: "dup", question: "first" }, + { id: "dup", question: "second" }, + ], + selectedId: "dup", + }; + expect(removeOperatorInputRequired(duplicated, "dup")).toBe( + NO_OPERATOR_INPUT_REQUIRED, + ); + }); + + test("updates a repeated id without changing insertion order and falls back after selection settles", () => { + const first = addOperatorInputRequired( + NO_OPERATOR_INPUT_REQUIRED, + gate("one"), + ); + const both = addOperatorInputRequired( + first, + gate("two", "Second question"), + ); + const updated = addOperatorInputRequired( + both, + gate("one", "Updated question"), + ); + expect(updated.items.map((item) => item.id)).toEqual(["one", "two"]); + expect(updated.selectedId).toBe("one"); + const fallback = removeOperatorInputRequired(updated, "one"); + expect(fallback.selectedId).toBe("two"); + expect(removeOperatorInputRequired(fallback, "missing")).toBe(fallback); + }); + + test("sanitizes question previews and stays within every row width", () => { + const state = addOperatorInputRequired( + NO_OPERATOR_INPUT_REQUIRED, + gate("one", "line one\n\u001b[31mline two"), + ); + for (let cells = 1; cells <= 120; cells++) { + const line = text(composeOperatorInputRequiredLine(state, cells)); + expect(stringWidth(line)).toBeLessThanOrEqual(cells); + expect(line).not.toContain("\n"); + expect(line).not.toContain("\u001b"); + } + }); +}); diff --git a/src/tui/operator-input-required.ts b/src/tui/operator-input-required.ts new file mode 100644 index 000000000..cd7bcc1e9 --- /dev/null +++ b/src/tui/operator-input-required.ts @@ -0,0 +1,159 @@ +import { + isPrimaryAskOperatorEvent, + type OperatorGateEvent, +} from "./gate-events.js"; +import { stripTerminalControlSequences } from "../util/control-char-strip.js"; +import { sliceToWidth, stringWidth } from "./view/height.js"; + +export interface OperatorInputRequiredItem { + readonly id: string; + readonly question: string; +} + +export interface OperatorInputRequiredState { + readonly items: readonly OperatorInputRequiredItem[]; + readonly selectedId: string | null; +} + +export const NO_OPERATOR_INPUT_REQUIRED: OperatorInputRequiredState = { + items: [], + selectedId: null, +}; + +export type OperatorInputRequiredRole = + | "mark" + | "label" + | "routing" + | "question" + | "more" + | "separator"; + +export interface OperatorInputRequiredPart { + readonly text: string; + readonly role: OperatorInputRequiredRole; +} + +function sameItem( + a: OperatorInputRequiredItem, + b: OperatorInputRequiredItem, +): boolean { + return a.id === b.id && a.question === b.question; +} + +function selectedItem( + state: OperatorInputRequiredState, +): OperatorInputRequiredItem | null { + return state.items.find((item) => item.id === state.selectedId) ?? null; +} + +/** Admit only marked primary asks, preserving insertion order by gate id. */ +export function addOperatorInputRequired( + state: OperatorInputRequiredState, + event: OperatorGateEvent, +): OperatorInputRequiredState { + if ( + !isPrimaryAskOperatorEvent(event) || + typeof event.id !== "string" || + event.id.length === 0 + ) { + return state; + } + const item = { id: event.id, question: event.question }; + const index = state.items.findIndex((live) => live.id === event.id); + if (index < 0) { + return { + items: [...state.items, item], + selectedId: state.selectedId ?? event.id, + }; + } + const current = state.items[index]; + if (current !== undefined && sameItem(current, item)) return state; + const items = [...state.items]; + items[index] = item; + return { items, selectedId: state.selectedId }; +} + +/** Remove exactly one settled gate; unknown and repeated removals are no-ops. */ +export function removeOperatorInputRequired( + state: OperatorInputRequiredState, + id: string, +): OperatorInputRequiredState { + const index = state.items.findIndex((item) => item.id === id); + if (index < 0) return state; + const items = state.items.filter((item) => item.id !== id); + const selectedId = + state.selectedId !== id ? state.selectedId : (items[0]?.id ?? null); + return items.length === 0 + ? NO_OPERATOR_INPUT_REQUIRED + : { items, selectedId }; +} + +export function operatorInputRequiredMoreCount( + state: OperatorInputRequiredState, +): number { + return Math.max(0, state.items.length - 1); +} + +/** Prevent question content from claiming rows or terminal styling. */ +export function operatorInputRequiredPreview(text: string): string { + return stripTerminalControlSequences(text).replace(/\s+/g, " ").trim(); +} + +const MARK = "◆ "; +const LABEL = "INPUT REQUIRED"; +const ROUTING = "operator answer needed"; +const SEPARATOR = " · "; +const ELLIPSIS = "…"; + +function width(parts: readonly OperatorInputRequiredPart[]): number { + return parts.reduce((total, part) => total + stringWidth(part.text), 0); +} + +function fit(text: string, cells: number): string { + if (stringWidth(text) <= cells) return text; + if (cells <= stringWidth(ELLIPSIS)) return ""; + return `${sliceToWidth(text, cells - stringWidth(ELLIPSIS))}${ELLIPSIS}`; +} + +/** One bounded semantic line, with content roles left for shell paint to theme. */ +export function composeOperatorInputRequiredLine( + state: OperatorInputRequiredState, + cells: number, +): readonly OperatorInputRequiredPart[] { + const item = selectedItem(state); + if (item === null || cells <= 0) return []; + const more = operatorInputRequiredMoreCount(state); + const moreParts: OperatorInputRequiredPart[] = + more > 0 ? [{ text: ` (+${more} more)`, role: "more" }] : []; + const full: OperatorInputRequiredPart[] = [ + { text: MARK, role: "mark" }, + { text: LABEL, role: "label" }, + { text: SEPARATOR, role: "separator" }, + { text: ROUTING, role: "routing" }, + ]; + const question = operatorInputRequiredPreview(item.question); + const room = cells - width(full) - stringWidth(SEPARATOR) - width(moreParts); + if (question.length > 0 && room > stringWidth(ELLIPSIS)) { + return [ + ...full, + { text: SEPARATOR, role: "separator" }, + { text: fit(question, room), role: "question" }, + ...moreParts, + ]; + } + if (width(full) + width(moreParts) <= cells) return [...full, ...moreParts]; + const compact: OperatorInputRequiredPart[] = [ + { text: MARK, role: "mark" }, + { text: LABEL, role: "label" }, + ...moreParts, + ]; + const result: OperatorInputRequiredPart[] = []; + let remaining = cells; + for (const part of compact) { + const text = sliceToWidth(part.text, remaining); + if (text.length === 0) break; + result.push({ text, role: part.role }); + remaining -= stringWidth(text); + } + return result; +} diff --git a/src/tui/product-host.test.ts b/src/tui/product-host.test.ts index 42c7fa6d3..980333d5e 100644 --- a/src/tui/product-host.test.ts +++ b/src/tui/product-host.test.ts @@ -14,6 +14,8 @@ import { runOverlayAction, } from "./shell/overlay-list.js"; import { handleListFilterKey } from "./shell/palette.js"; +import { PRIMARY_ASK_OPERATOR_SOURCE } from "./gate-events.js"; +import { NO_OPERATOR_INPUT_REQUIRED } from "./operator-input-required.js"; import { decideRemoveKey, mountProductHost, @@ -531,6 +533,28 @@ describe("mountProductHost", () => { } }); + test("session.clear removes the INPUT REQUIRED strip for an outstanding gate", async () => { + const { host, emitter } = await mountHeadless(); + try { + // No abort signal, as on the real ask path, so nothing else can clear it. + emitter.emit("operator.gate", { + id: "ask-rotated", + source: PRIMARY_ASK_OPERATOR_SOURCE, + question: "Continue?", + options: ["Yes", "No"], + resolve: () => undefined, + }); + expect(host.shell.layout.heights.input_required).toBe(1); + + emitter.emit("session.clear"); + + expect(host.shell.operatorInputRequired).toBe(NO_OPERATOR_INPUT_REQUIRED); + expect(host.shell.layout.heights.input_required).toBe(0); + } finally { + host.dispose(); + } + }); + test("dispose() detaches emitter listeners and resolves waitUntilExit", async () => { const { host, emitter } = await mountHeadless(); @@ -607,6 +631,39 @@ describe("mountProductHost", () => { destroyHarness(); } }); + + test("host teardown drains a marked outstanding operator gate and clears its strip", async () => { + const { host, emitter, destroyHarness } = await mountHeadless(); + try { + let resolved: unknown = "unset"; + emitter.emit("operator.gate", { + id: "ask-teardown", + source: PRIMARY_ASK_OPERATOR_SOURCE, + question: "Continue?", + options: ["Yes", "No"], + resolve: (result: unknown) => { + resolved = result; + }, + }); + expect( + host.shell.operatorInputRequired.items.map((item) => item.id), + ).toEqual(["ask-teardown"]); + expect(host.shell.layout.heights.input_required).toBe(1); + expect(emitter.listenerCount("operator.gate")).toBe(1); + expect(emitter.listenerCount("permission.gate")).toBe(1); + + host.dispose(); + + // The gate is drained by the teardown sweep, never left hanging. + expect(resolved).toEqual({ kind: "cancel" }); + expect(host.shell.operatorInputRequired).toBe(NO_OPERATOR_INPUT_REQUIRED); + expect(host.shell.layout.heights.input_required).toBe(0); + expect(emitter.listenerCount("operator.gate")).toBe(0); + expect(emitter.listenerCount("permission.gate")).toBe(0); + } finally { + destroyHarness(); + } + }); }); describe("flat type-to-filter model picker", () => { diff --git a/src/tui/product-host.ts b/src/tui/product-host.ts index 3755de74c..45f7fb801 100644 --- a/src/tui/product-host.ts +++ b/src/tui/product-host.ts @@ -43,8 +43,11 @@ import { appendObserveStreamRow, appendStreamRow, clearTranscript, + clearAllOperatorInputRequired, noteUnloadedHistory, paintChrome, + clearOperatorInputRequired, + setOperatorInputRequiredGate, setChromeZones, setHeader, setMcpNeedsAuth, @@ -448,6 +451,7 @@ export async function mountProductHost( clearInterval(stickyPoll); config.eventEmitter.off("event", onEvent); disposeGates(); + clearAllOperatorInputRequired(shell); config.eventEmitter.off("history.hydrate", onHistory); config.eventEmitter.off("session.title", onTitle); config.eventEmitter.off("session.clear", onSessionClear); @@ -551,6 +555,10 @@ export async function mountProductHost( disposeGates = wireGates(config.eventEmitter, shell, { onGateOpened: () => bridge.gateOpened(), onGateClosed: () => bridge.gateClosed(), + onPrimaryOperatorAdmitted: (event) => + setOperatorInputRequiredGate(shell, event), + onPrimaryOperatorSettled: (event) => + clearOperatorInputRequired(shell, event.id), }); } catch (err: unknown) { try { @@ -578,10 +586,12 @@ export async function mountProductHost( // /clear and /new rotate the backend session; wipe the painted transcript // so the screen matches a brand-new session (the Ink App used to own this; - // OpenTUI regressed it). + // OpenTUI regressed it). The INPUT REQUIRED strip is cleared here too: the + // ask path merges no identity signal, so a gate abort never reaches it. function onSessionClear(): void { if (disposed) return; clearTranscript(shell); + clearAllOperatorInputRequired(shell); bridge.clearQueuedDelivery(); } diff --git a/src/tui/runner/session.operator-input-required.test.ts b/src/tui/runner/session.operator-input-required.test.ts new file mode 100644 index 000000000..74fd47295 --- /dev/null +++ b/src/tui/runner/session.operator-input-required.test.ts @@ -0,0 +1,163 @@ +/** + * Phase-4 primary-session integration regression for the input-required strip. + * + * The marker stamping lives inside assembleTUISession's primary onOperatorGate + * closure (runner/session.ts), which no existing test seam can invoke without + * assembling the whole runner. These tests therefore mirror that closure's + * emission construction exactly and drive the resulting event through a real + * shell + wireGates host compose: admission, modal behavior, and final strip + * removal must hold together without changing event routing. + */ +import { EventEmitter } from "node:events"; +import { randomUUID } from "node:crypto"; +import { describe, expect, test } from "bun:test"; +import type { OperatorResult } from "../../agent/tools.js"; +import { defined } from "../../../testkit/defined.js"; +import { + PRIMARY_ASK_OPERATOR_SOURCE, + type OperatorGateEvent, +} from "../gate-events.js"; +import { wireGates } from "../gate-wire.js"; +import { NO_OPERATOR_INPUT_REQUIRED } from "../operator-input-required.js"; +import { attachApprovalBudget } from "../request-approval.js"; +import { + clearOperatorInputRequired, + setOperatorInputRequiredGate, +} from "../shell/chrome.js"; +import { acceptOverlaySelection } from "../shell/overlay-host.js"; +import { withAppShell } from "../test-helpers.js"; + +/** Mirror session.ts's primary onOperatorGate closure (marker + budget id). */ +function emitPrimaryAsk( + emitter: EventEmitter, + question: string, + options: string[], +): { id: string; result: Promise } { + let id = ""; + const result = new Promise((resolve) => { + const { finish, signal } = attachApprovalBudget(resolve, { + tool: "ask_operator", + kind: "operator", + }); + const event: OperatorGateEvent = { + id: randomUUID(), + source: PRIMARY_ASK_OPERATOR_SOURCE, + question, + options, + resolve: finish, + ...(signal !== undefined ? { signal } : {}), + }; + id = event.id; + emitter.emit("operator.gate", event); + }); + return { id, result }; +} + +/** Mirror session.ts's requestMcpTrust emission: a normal, unmarked modal. */ +function emitMcpTrust(emitter: EventEmitter): Promise { + return new Promise((resolve) => { + const { finish, signal } = attachApprovalBudget(resolve, { + tool: "mcp:local-server", + kind: "operator", + }); + const event: OperatorGateEvent = { + id: randomUUID(), + question: "Trust this local MCP server?", + options: ["Trust and connect", "Cancel"], + resolve: finish, + ...(signal !== undefined ? { signal } : {}), + }; + emitter.emit("operator.gate", event); + }); +} + +function primaryHostHooks(shell: Parameters[1]) { + return { + onGateOpened: () => undefined, + onGateClosed: () => undefined, + onPrimaryOperatorAdmitted: (event: OperatorGateEvent) => + setOperatorInputRequiredGate(shell, event), + onPrimaryOperatorSettled: (event: OperatorGateEvent) => + clearOperatorInputRequired(shell, event.id), + }; +} + +describe("primary ask_operator emission", () => { + test("stamps the marker, a unique id, and preserves question, options and resolver", async () => { + const emitter = new EventEmitter(); + const captured: OperatorGateEvent[] = []; + emitter.on("operator.gate", (event: OperatorGateEvent) => + captured.push(event), + ); + const first = emitPrimaryAsk(emitter, "Which branch?", ["main", "dev"]); + const second = emitPrimaryAsk(emitter, "Include tests?", ["yes", "no"]); + + expect(captured).toHaveLength(2); + for (const event of captured) { + expect(event.source).toBe(PRIMARY_ASK_OPERATOR_SOURCE); + expect(typeof event.id).toBe("string"); + expect(event.id.length).toBeGreaterThan(0); + expect(typeof event.resolve).toBe("function"); + } + expect(captured[0]?.question).toBe("Which branch?"); + expect(captured[0]?.options).toEqual(["main", "dev"]); + expect(captured[1]?.question).toBe("Include tests?"); + const firstEvent = defined(captured[0], "captured[0]"); + const secondEvent = defined(captured[1], "captured[1]"); + expect(secondEvent.id).not.toBe(firstEvent.id); + expect(first.id).toBe(firstEvent.id); + expect(second.id).toBe(secondEvent.id); + expect(first.result).toBeInstanceOf(Promise); + }); + + test("drives admission, modal behavior, and final strip removal through the host compose", async () => { + await withAppShell( + async (shell) => { + const emitter = new EventEmitter(); + const disposeGates = wireGates(emitter, shell, primaryHostHooks(shell)); + try { + const { id, result } = emitPrimaryAsk(emitter, "Which branch?", [ + "main", + "dev", + ]); + expect( + shell.operatorInputRequired.items.map((item) => item.id), + ).toEqual([id]); + expect(shell.layout.heights.input_required).toBe(1); + expect(shell.overlayKind).toBe("operator"); + + acceptOverlaySelection(shell); + expect(await result).toEqual({ kind: "option", index: 0 }); + expect(shell.operatorInputRequired).toBe(NO_OPERATOR_INPUT_REQUIRED); + expect(shell.layout.heights.input_required).toBe(0); + expect(shell.overlayList).toBeNull(); + } finally { + disposeGates(); + } + }, + { shell: { run: "idle" } }, + ); + }); + + test("an MCP trust emission stays unmarked and never admits an input-required item", async () => { + await withAppShell( + async (shell) => { + const emitter = new EventEmitter(); + const disposeGates = wireGates(emitter, shell, primaryHostHooks(shell)); + try { + const result = emitMcpTrust(emitter); + expect(shell.overlayKind).toBe("operator"); + expect(shell.operatorInputRequired).toBe(NO_OPERATOR_INPUT_REQUIRED); + expect(shell.layout.heights.input_required).toBe(0); + + acceptOverlaySelection(shell); + expect(await result).toEqual({ kind: "option", index: 0 }); + expect(shell.operatorInputRequired).toBe(NO_OPERATOR_INPUT_REQUIRED); + } finally { + disposeGates(); + } + }, + { shell: { run: "idle" } }, + ); + }); +}); diff --git a/src/tui/runner/session.ts b/src/tui/runner/session.ts index 4effe1dec..bfc012768 100644 --- a/src/tui/runner/session.ts +++ b/src/tui/runner/session.ts @@ -114,7 +114,10 @@ import { import { attachApprovalBudget } from "../request-approval.js"; import { createGateRequestApproval } from "../request-approval.js"; import { getActivePricingCache } from "../../cost/cost-visibility.js"; -import type { OperatorGateEvent } from "../gate-events.js"; +import { + PRIMARY_ASK_OPERATOR_SOURCE, + type OperatorGateEvent, +} from "../gate-events.js"; import { sessionDir } from "../../session/index.js"; import { ID_PREFIX } from "../../branding.js"; import { @@ -391,6 +394,7 @@ export async function assembleTUISession( const timeout = approvalTimeout(); const event: OperatorGateEvent = { id: randomUUID(), + source: PRIMARY_ASK_OPERATOR_SOURCE, question, options, resolve: finish, diff --git a/src/tui/shell/chrome.ts b/src/tui/shell/chrome.ts index 3964b26d3..666d695da 100644 --- a/src/tui/shell/chrome.ts +++ b/src/tui/shell/chrome.ts @@ -97,6 +97,15 @@ import { type WorkerWaitPart, type WorkerWaitRole, } from "../worker-wait.js"; +import { + addOperatorInputRequired, + composeOperatorInputRequiredLine, + NO_OPERATOR_INPUT_REQUIRED, + removeOperatorInputRequired, + type OperatorInputRequiredPart, + type OperatorInputRequiredRole, +} from "../operator-input-required.js"; +import type { OperatorGateEvent } from "../gate-events.js"; import { type AppShell, @@ -197,6 +206,32 @@ export function clearWorkerWait(shell: AppShell): void { paintChrome(shell); } +/** Add one marked primary ask to the display-only input-required chrome. */ +export function setOperatorInputRequiredGate( + shell: AppShell, + event: OperatorGateEvent, +): void { + const next = addOperatorInputRequired(shell.operatorInputRequired, event); + if (next === shell.operatorInputRequired) return; + shell.operatorInputRequired = next; + paintChrome(shell); +} + +/** Remove exactly one settled primary ask from the input-required chrome. */ +export function clearOperatorInputRequired(shell: AppShell, id: string): void { + const next = removeOperatorInputRequired(shell.operatorInputRequired, id); + if (next === shell.operatorInputRequired) return; + shell.operatorInputRequired = next; + paintChrome(shell); +} + +/** Teardown helper for hosts that need to discard every outstanding indicator. */ +export function clearAllOperatorInputRequired(shell: AppShell): void { + if (shell.operatorInputRequired === NO_OPERATOR_INPUT_REQUIRED) return; + shell.operatorInputRequired = NO_OPERATOR_INPUT_REQUIRED; + 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". @@ -221,6 +256,29 @@ function workerWaitChunks(parts: readonly WorkerWaitPart[]): TextChunk[] { return parts.map((part) => fgChunk(workerWaitFg(part.role))(part.text)); } +function operatorInputRequiredFg(role: OperatorInputRequiredRole): string { + switch (role) { + case "mark": + case "label": + return UI.action; + case "routing": + case "question": + return UI.text; + case "more": + return UI.textDim; + case "separator": + return UI.textFaint; + } +} + +function operatorInputRequiredChunks( + parts: readonly OperatorInputRequiredPart[], +): TextChunk[] { + return parts.map((part) => + fgChunk(operatorInputRequiredFg(part.role))(part.text), + ); +} + /** * Key over every input a chrome paint reads; a miss leaves stale chrome. */ @@ -228,11 +286,13 @@ function chromeComposeKey( shell: AppShell, notice: string, workerWait: string, + inputRequired: string, ): string { const meter = shell.costContext; return [ notice, workerWait, + inputRequired, pendingColumnRows(shell.session.items) .map((row) => `${row.tag ?? ""}:${row.text}`) .join("\u0001"), @@ -281,10 +341,15 @@ export function paintChrome( shell.workerWait, shell.layout.contentWidth, ); + const inputRequired = composeOperatorInputRequiredLine( + shell.operatorInputRequired, + shell.layout.contentWidth, + ); const key = chromeComposeKey( shell, notice, workerWait.map((part) => part.text).join(""), + inputRequired.map((part) => part.text).join(""), ); if (!opts?.force && paintedChromeKey.get(shell) === key) return; paintedChromeKey.set(shell, key); @@ -299,6 +364,10 @@ export function paintChrome( syncPendingColumn(shell); shell.workerWaitRow.content = new StyledText(workerWaitChunks(workerWait)); syncWorkerWaitRow(shell, workerWait.length > 0); + shell.inputRequiredRow.content = new StyledText( + operatorInputRequiredChunks(inputRequired), + ); + syncOperatorInputRequiredRow(shell, inputRequired.length > 0); } /** @@ -313,6 +382,13 @@ function syncWorkerWaitRow(shell: AppShell, wanted: boolean): void { relayout(shell, { visibility: { ...bag.visibility, workerWait: wanted } }); } +function syncOperatorInputRequiredRow(shell: AppShell, wanted: boolean): void { + const bag = shellInternals(shell); + if (bag === undefined) return; + if ((bag.visibility.inputRequired ?? false) === wanted) return; + relayout(shell, { visibility: { ...bag.visibility, inputRequired: wanted } }); +} + /** Rebuild pendingBox rows only when the painted content moved; unchanged * queues cost a signature compare, not a rebuild. */ function syncPendingRows(shell: AppShell): void { @@ -864,6 +940,10 @@ export function applyLayout(shell: AppShell, layout: GeometryLayout): void { shell.workerWaitRow.height = workerWaitH > 0 ? workerWaitH : 1; shell.workerWaitRow.visible = workerWaitH > 0; + const inputRequiredH = Math.max(0, h.input_required); + shell.inputRequiredRow.height = inputRequiredH > 0 ? inputRequiredH : 1; + shell.inputRequiredRow.visible = inputRequiredH > 0; + const promptH = Math.max(0, h.prompt); shell.promptBox.height = promptH > 0 ? promptH : 1; shell.promptBox.visible = promptH > 0; @@ -883,8 +963,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, pending and the worker-wait strip - // are transient). + // agents, task, prompt (notice, pending, the worker-wait strip and + // the input-required 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 d734adffa..341f431dd 100644 --- a/src/tui/shell/index.ts +++ b/src/tui/shell/index.ts @@ -74,6 +74,7 @@ import { } from "./prompt.js"; import { armMarkdownLinks } from "../url-links.js"; import { NO_WORKER_WAIT } from "../worker-wait.js"; +import { NO_OPERATOR_INPUT_REQUIRED } from "../operator-input-required.js"; import { createShellKeyHandlers, routePromptWheelToTranscript, @@ -269,6 +270,14 @@ export function createAppShell( visible: layout.heights.worker_wait > 0, }); + const inputRequiredRow = new TextRenderable(ctx, { + id: "shell-input-required", + height: Math.max(1, layout.heights.input_required), + content: "", + fg: UI.textDim, + visible: layout.heights.input_required > 0, + }); + const promptBox = new BoxRenderable(ctx, { id: "shell-prompt-region", width: "100%", @@ -329,6 +338,7 @@ export function createAppShell( root.add(notice); root.add(pendingBox); root.add(workerWaitRow); + root.add(inputRequiredRow); root.add(promptBox); root.add(landingBelow); root.add(bottomPad); @@ -434,6 +444,7 @@ export function createAppShell( notice, pendingBox, workerWaitRow, + inputRequiredRow, layout, focus: createFocusState(), session, @@ -459,6 +470,7 @@ export function createAppShell( mcpNeedsAuth: [], pluginNeedsAttention: false, workerWait: NO_WORKER_WAIT, + operatorInputRequired: NO_OPERATOR_INPUT_REQUIRED, lockupNowMs: 0, inFlightTool: null, lockupAnimating: false, diff --git a/src/tui/shell/internals.ts b/src/tui/shell/internals.ts index bd021ea3a..7399a4f47 100644 --- a/src/tui/shell/internals.ts +++ b/src/tui/shell/internals.ts @@ -35,6 +35,7 @@ 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"; +import { type OperatorInputRequiredState } from "../operator-input-required.js"; export const shellExitHandlers = new WeakMap void>(); @@ -370,6 +371,8 @@ export interface AppShell { * parked on ask_director; geometry owns its row (zone `worker_wait`). */ readonly workerWaitRow: TextRenderable; + /** INPUT REQUIRED strip for live marked primary ask_operator gates. */ + readonly inputRequiredRow: TextRenderable; /** Latest geometry resolution (updated on resize / relayout). */ layout: GeometryLayout; /** Focus tree + scroll lease (updated by shell helpers). */ @@ -426,6 +429,8 @@ export interface AppShell { * Display state: nothing reads it to route input or settle an ask. */ workerWait: WorkerWaitState; + /** Display-only state for outstanding primary ask_operator gate ids. */ + operatorInputRequired: OperatorInputRequiredState; /** Clock and motion state for the bottom-left status slot; the bridge pushes * it off its monitor tick. */ lockupNowMs: number;