Skip to content

Commit f60e42e

Browse files
committed
feat(tui): show required operator input at composer
1 parent b72ebc9 commit f60e42e

14 files changed

Lines changed: 1017 additions & 13 deletions

‎BUILD_PLAN.md‎

Lines changed: 291 additions & 0 deletions
Large diffs are not rendered by default.

‎src/tui/gate-events.ts‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,11 @@ import type {
44
} from "../permission/types.js";
55
import type { OperatorResult } from "../agent/tools.js";
66

7+
/** Provenance minted only by the primary session's ask_operator callback. */
8+
export const PRIMARY_ASK_OPERATOR_SOURCE = "primary-ask-operator" as const;
9+
10+
export type OperatorGateSource = typeof PRIMARY_ASK_OPERATOR_SOURCE;
11+
712
/** Fail-closed settle when no approval UI can bind the operator's accept. */
813
export const APPROVAL_UNAVAILABLE_MESSAGE =
914
"no approval UI available; request denied" as const;
@@ -27,6 +32,18 @@ export interface OperatorGateEvent {
2732
* queue — the modal cannot outlive a tool that already finished.
2833
*/
2934
signal?: AbortSignal;
35+
/**
36+
* Optional display provenance minted by the session emitter, never inferred
37+
* from an operator question by the TUI.
38+
*/
39+
source?: OperatorGateSource;
40+
}
41+
42+
/** Only an explicitly marked primary ask may enter input-required chrome. */
43+
export function isPrimaryAskOperatorEvent(
44+
event: OperatorGateEvent,
45+
): event is OperatorGateEvent & { readonly source: OperatorGateSource } {
46+
return event.source === PRIMARY_ASK_OPERATOR_SOURCE;
3047
}
3148

3249
export interface PermissionGateEvent {

‎src/tui/gate-wire.test.ts‎

Lines changed: 109 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import {
2323
import { streamRowGutter } from "./stream.js";
2424
import {
2525
APPROVAL_UNAVAILABLE_MESSAGE,
26+
PRIMARY_ASK_OPERATOR_SOURCE,
2627
type OperatorGateEvent,
2728
type PermissionGateEvent,
2829
} from "./gate-events.js";
@@ -36,6 +37,7 @@ import {
3637
PERMISSION_ONCE_ID,
3738
permissionBodyFromRequest,
3839
permissionChoicesFromRequest,
40+
type GateLifecycleHooks,
3941
wireGates,
4042
} from "./gate-wire.js";
4143

@@ -60,6 +62,7 @@ type GateCtx = {
6062

6163
type GateOpts = {
6264
readonly terminal?: { readonly columns: number; readonly rows: number };
65+
readonly hooks?: Partial<GateLifecycleHooks>;
6366
};
6467

6568
async function withGates(
@@ -70,7 +73,11 @@ async function withGates(
7073
await withAppShell(
7174
async (shell, h) => {
7275
const emitter = new EventEmitter();
73-
const disposeGates = wireGates(emitter, shell);
76+
const disposeGates = wireGates(emitter, shell, {
77+
onGateOpened: () => undefined,
78+
onGateClosed: () => undefined,
79+
...opts.hooks,
80+
});
7481
try {
7582
await fn({ h, shell, emitter, disposeGates });
7683
} finally {
@@ -463,6 +470,107 @@ describe("wireGates", () => {
463470
});
464471
});
465472

473+
test("marked primary asks notify admission and settlement exactly once", async () => {
474+
const admitted: string[] = [];
475+
const settled: string[] = [];
476+
await withGates(
477+
async ({ shell, emitter }) => {
478+
emitOperator(emitter, { source: PRIMARY_ASK_OPERATOR_SOURCE });
479+
expect(admitted).toEqual(["ask-1"]);
480+
acceptOverlaySelection(shell);
481+
expect(settled).toEqual(["ask-1"]);
482+
closeInsetOverlay(shell);
483+
expect(settled).toEqual(["ask-1"]);
484+
},
485+
{
486+
hooks: {
487+
onPrimaryOperatorAdmitted: (event) => admitted.push(event.id),
488+
onPrimaryOperatorSettled: (event) => settled.push(event.id),
489+
},
490+
},
491+
);
492+
});
493+
494+
test("re-emitted marked primary ask keeps one live gate and resolves once", async () => {
495+
const admitted: string[] = [];
496+
const settled: string[] = [];
497+
const resolved: unknown[] = [];
498+
await withGates(
499+
async ({ shell, emitter }) => {
500+
const event = {
501+
id: "same-ask",
502+
source: PRIMARY_ASK_OPERATOR_SOURCE,
503+
resolve: (result: unknown) => resolved.push(result),
504+
};
505+
emitOperator(emitter, event);
506+
emitOperator(emitter, event);
507+
508+
expect(admitted).toEqual(["same-ask"]);
509+
expect(shell.overlayKind).toBe("operator");
510+
511+
acceptOverlaySelection(shell);
512+
513+
expect(settled).toEqual(["same-ask"]);
514+
expect(resolved).toEqual([{ kind: "option", index: 0 }]);
515+
expect(shell.overlayList).toBeNull();
516+
},
517+
{
518+
hooks: {
519+
onPrimaryOperatorAdmitted: (event) => admitted.push(event.id),
520+
onPrimaryOperatorSettled: (event) => settled.push(event.id),
521+
},
522+
},
523+
);
524+
});
525+
526+
test("unmarked operator gates retain modal behavior without input-required hooks", async () => {
527+
let admitted = 0;
528+
let settled = 0;
529+
await withGates(
530+
async ({ shell, emitter }) => {
531+
emitOperator(emitter);
532+
expect(shell.overlayKind).toBe("operator");
533+
acceptOverlaySelection(shell);
534+
expect(admitted).toBe(0);
535+
expect(settled).toBe(0);
536+
},
537+
{
538+
hooks: {
539+
onPrimaryOperatorAdmitted: () => admitted++,
540+
onPrimaryOperatorSettled: () => settled++,
541+
},
542+
},
543+
);
544+
});
545+
546+
test("a marked queued ask settles its own indicator on abort and disposal", async () => {
547+
const settled: string[] = [];
548+
await withGates(
549+
async ({ emitter, disposeGates }) => {
550+
emitOperator(emitter, {
551+
id: "open",
552+
source: PRIMARY_ASK_OPERATOR_SOURCE,
553+
});
554+
const controller = new AbortController();
555+
emitOperator(emitter, {
556+
id: "queued",
557+
source: PRIMARY_ASK_OPERATOR_SOURCE,
558+
signal: controller.signal,
559+
});
560+
controller.abort();
561+
expect(settled).toEqual(["queued"]);
562+
disposeGates();
563+
expect(settled).toEqual(["queued", "open"]);
564+
},
565+
{
566+
hooks: {
567+
onPrimaryOperatorAdmitted: () => undefined,
568+
onPrimaryOperatorSettled: (event) => settled.push(event.id),
569+
},
570+
},
571+
);
572+
});
573+
466574
test("sequential operator asks paint B's labels and id-scoped values, not A's", async () => {
467575
await withGates(async ({ shell, emitter }) => {
468576
const settledA = settleCapture();

‎src/tui/gate-wire.ts‎

Lines changed: 29 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import {
2525
import { EXPAND_KEY } from "./stream.js";
2626
import {
2727
APPROVAL_UNAVAILABLE_MESSAGE,
28+
isPrimaryAskOperatorEvent,
2829
type OperatorGateEvent,
2930
type PermissionGateEvent,
3031
} from "./gate-events.js";
@@ -209,6 +210,10 @@ export interface GateLifecycleHooks {
209210
readonly onGateOpened: () => void;
210211
/** A previously raised gate resolved. */
211212
readonly onGateClosed: () => void;
213+
/** A marked primary ask_operator gate became outstanding. */
214+
readonly onPrimaryOperatorAdmitted?: (event: OperatorGateEvent) => void;
215+
/** A marked primary ask_operator gate settled. */
216+
readonly onPrimaryOperatorSettled?: (event: OperatorGateEvent) => void;
212217
}
213218

214219
const NOOP_GATE_HOOKS: GateLifecycleHooks = {
@@ -258,9 +263,20 @@ export function wireGates(
258263
// gate still queued at session teardown settles instead of hanging its
259264
// promise.
260265
const operatorTeardowns = new Set<() => void>();
261-
// Bumped on every open. A settle that knows only "my overlay opened" cannot
262-
// tell whether the host has since opened a newer gate; comparing the
263-
// open-time capture against the current value closes only its own overlay.
266+
// A live ask_operator gate can be re-emitted while its original event is
267+
// still awaiting an answer. Its id owns the single overlay and resolver
268+
// until that gate settles.
269+
const liveOperatorGateIds = new Set<string>();
270+
// Bumped every time any gate (permission or operator) opens on the shared
271+
// host. A settle path that only knows "my overlay was opened" cannot tell
272+
// whether the host has since moved on to a newer one - the shell closes an
273+
// accepted/cancelled overlay and may open the next queued gate before that
274+
// gate's own settle callback runs - and closing blind would tear down that
275+
// newer overlay instead of its own. Comparing the generation captured at
276+
// open-time against the current one answers that directly, so correctness
277+
// never rests on remembering shell.ts's close-before-callback ordering at
278+
// each call site. openHost is the only place an overlay opens, so it is
279+
// the only place this counter needs to change.
264280
let overlayGeneration = 0;
265281

266282
function openHost(open: () => void): void {
@@ -438,12 +454,18 @@ export function wireGates(
438454
}
439455

440456
function onOperator(ev: OperatorGateEvent): void {
441-
hooks.onGateOpened();
442-
const resolve = onceClosed(hooks.onGateClosed, ev.resolve);
443457
if (typeof ev.id !== "string" || ev.id.length === 0) {
458+
hooks.onGateOpened();
459+
const resolve = onceClosed(hooks.onGateClosed, ev.resolve);
444460
resolve(operatorCancelResult());
445461
return;
446462
}
463+
if (liveOperatorGateIds.has(ev.id)) return;
464+
liveOperatorGateIds.add(ev.id);
465+
hooks.onGateOpened();
466+
const resolve = onceClosed(hooks.onGateClosed, ev.resolve);
467+
const primaryOperator = isPrimaryAskOperatorEvent(ev);
468+
if (primaryOperator) hooks.onPrimaryOperatorAdmitted?.(ev);
447469
const choices = operatorChoicesFromOptions(ev.options, ev.id);
448470
// Guarded like the permission gate: a settle path that forgets its guard
449471
// would double-resolve this promise.
@@ -501,6 +523,8 @@ export function wireGates(
501523
settled = true;
502524
clearTimers();
503525
operatorTeardowns.delete(teardown);
526+
liveOperatorGateIds.delete(ev.id);
527+
if (primaryOperator) hooks.onPrimaryOperatorSettled?.(ev);
504528
if (openedGeneration === undefined) {
505529
unqueue(open);
506530
} else if (openedGeneration === overlayGeneration) {

‎src/tui/geometry/resolve.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,8 @@ export interface ZoneVisibility {
4141
readonly pending?: boolean | number;
4242
/** WORKER WAITING strip on (default off). */
4343
readonly workerWait?: boolean;
44+
/** INPUT REQUIRED strip for marked primary ask_operator gates (default off). */
45+
readonly inputRequired?: boolean;
4446
/** Progress: false/omit = 0; true = 2; or explicit 1|2. */
4547
readonly progress?: boolean | 1 | 2;
4648
/** Progress divider (0–1). Default on when progress is shown. */
@@ -150,6 +152,7 @@ export function desiredHeights(input: GeometryInput): MutableHeights {
150152
notice: vis.notice === true ? 1 : ZONE_REGISTRY.notice.idleDefault,
151153
pending: clamp(boolOrRows(vis.pending, 1), 0, ZONE_REGISTRY.pending.max),
152154
worker_wait: vis.workerWait === true ? 1 : 0,
155+
input_required: vis.inputRequired === true ? 1 : 0,
153156
prompt: promptRows,
154157
task: clamp(boolOrRows(vis.task, 1), 0, ZONE_REGISTRY.task.max),
155158
// The board asks for exactly the rows it paints; the fraction stops a large

‎src/tui/geometry/zones.ts‎

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ const ZONE_IDS = [
99
"notice",
1010
"pending",
1111
"worker_wait",
12+
"input_required",
1213
"prompt",
1314
"task",
1415
"agents",
@@ -92,8 +93,15 @@ export const ZONE_REGISTRY: Readonly<Record<ZoneId, ZoneDeclaration>> = {
9293
idleDefault: 0,
9394
alwaysOn: false,
9495
},
95-
// Grows with the draft; the resolver caps it at PROMPT_CAP_FRACTION and
96-
// collapses toward min when the transcript would breach its floor.
96+
input_required: {
97+
id: "input_required",
98+
min: 0,
99+
max: 1,
100+
idleDefault: 0,
101+
alwaysOn: false,
102+
},
103+
// Grows with what is being composed; the resolver caps it at PROMPT_CAP_FRACTION
104+
// and collapses it back toward min when the transcript would breach its floor.
97105
prompt: {
98106
id: "prompt",
99107
min: 3,
@@ -208,6 +216,7 @@ export const COLLAPSE_ORDER = [
208216
// once the wake turn scrolls away, so its single row is the last optional
209217
// cut, just ahead of prompt growth reclaim.
210218
"worker_wait",
219+
"input_required",
211220
// prompt growth reclaimed next (handled specially; never below PROMPT_BASE_ROWS)
212221
"prompt",
213222
] as const satisfies readonly ZoneId[];
@@ -227,6 +236,7 @@ export const PAINT_ORDER = [
227236
"notice",
228237
"pending",
229238
"worker_wait",
239+
"input_required",
230240
"prompt",
231241
] as const satisfies readonly ZoneId[];
232242

0 commit comments

Comments
 (0)