Skip to content

Commit 797ae9c

Browse files
committed
feat(tui): show required operator input at composer
1 parent a585a25 commit 797ae9c

14 files changed

Lines changed: 993 additions & 4 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
* of the queue — so 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";
@@ -37,6 +38,7 @@ import {
3738
PERMISSION_ONCE_ID,
3839
permissionBodyFromRequest,
3940
permissionChoicesFromRequest,
41+
type GateLifecycleHooks,
4042
wireGates,
4143
} from "./gate-wire.js";
4244

@@ -61,6 +63,7 @@ type GateCtx = {
6163

6264
type GateOpts = {
6365
readonly terminal?: { readonly columns: number; readonly rows: number };
66+
readonly hooks?: Partial<GateLifecycleHooks>;
6467
};
6568

6669
async function withGates(
@@ -71,7 +74,11 @@ async function withGates(
7174
await withAppShell(
7275
async (shell, h) => {
7376
const emitter = new EventEmitter();
74-
const disposeGates = wireGates(emitter, shell);
77+
const disposeGates = wireGates(emitter, shell, {
78+
onGateOpened: () => undefined,
79+
onGateClosed: () => undefined,
80+
...opts.hooks,
81+
});
7582
try {
7683
await fn({ h, shell, emitter, disposeGates });
7784
} finally {
@@ -484,6 +491,107 @@ describe("wireGates", () => {
484491
});
485492
});
486493

494+
test("marked primary asks notify admission and settlement exactly once", async () => {
495+
const admitted: string[] = [];
496+
const settled: string[] = [];
497+
await withGates(
498+
async ({ shell, emitter }) => {
499+
emitOperator(emitter, { source: PRIMARY_ASK_OPERATOR_SOURCE });
500+
expect(admitted).toEqual(["ask-1"]);
501+
acceptOverlaySelection(shell);
502+
expect(settled).toEqual(["ask-1"]);
503+
closeInsetOverlay(shell);
504+
expect(settled).toEqual(["ask-1"]);
505+
},
506+
{
507+
hooks: {
508+
onPrimaryOperatorAdmitted: (event) => admitted.push(event.id),
509+
onPrimaryOperatorSettled: (event) => settled.push(event.id),
510+
},
511+
},
512+
);
513+
});
514+
515+
test("re-emitted marked primary ask keeps one live gate and resolves once", async () => {
516+
const admitted: string[] = [];
517+
const settled: string[] = [];
518+
const resolved: unknown[] = [];
519+
await withGates(
520+
async ({ shell, emitter }) => {
521+
const event = {
522+
id: "same-ask",
523+
source: PRIMARY_ASK_OPERATOR_SOURCE,
524+
resolve: (result: unknown) => resolved.push(result),
525+
};
526+
emitOperator(emitter, event);
527+
emitOperator(emitter, event);
528+
529+
expect(admitted).toEqual(["same-ask"]);
530+
expect(shell.overlayKind).toBe("operator");
531+
532+
acceptOverlaySelection(shell);
533+
534+
expect(settled).toEqual(["same-ask"]);
535+
expect(resolved).toEqual([{ kind: "option", index: 0 }]);
536+
expect(shell.overlayList).toBeNull();
537+
},
538+
{
539+
hooks: {
540+
onPrimaryOperatorAdmitted: (event) => admitted.push(event.id),
541+
onPrimaryOperatorSettled: (event) => settled.push(event.id),
542+
},
543+
},
544+
);
545+
});
546+
547+
test("unmarked operator gates retain modal behavior without input-required hooks", async () => {
548+
let admitted = 0;
549+
let settled = 0;
550+
await withGates(
551+
async ({ shell, emitter }) => {
552+
emitOperator(emitter);
553+
expect(shell.overlayKind).toBe("operator");
554+
acceptOverlaySelection(shell);
555+
expect(admitted).toBe(0);
556+
expect(settled).toBe(0);
557+
},
558+
{
559+
hooks: {
560+
onPrimaryOperatorAdmitted: () => admitted++,
561+
onPrimaryOperatorSettled: () => settled++,
562+
},
563+
},
564+
);
565+
});
566+
567+
test("a marked queued ask settles its own indicator on abort and disposal", async () => {
568+
const settled: string[] = [];
569+
await withGates(
570+
async ({ emitter, disposeGates }) => {
571+
emitOperator(emitter, {
572+
id: "open",
573+
source: PRIMARY_ASK_OPERATOR_SOURCE,
574+
});
575+
const controller = new AbortController();
576+
emitOperator(emitter, {
577+
id: "queued",
578+
source: PRIMARY_ASK_OPERATOR_SOURCE,
579+
signal: controller.signal,
580+
});
581+
controller.abort();
582+
expect(settled).toEqual(["queued"]);
583+
disposeGates();
584+
expect(settled).toEqual(["queued", "open"]);
585+
},
586+
{
587+
hooks: {
588+
onPrimaryOperatorAdmitted: () => undefined,
589+
onPrimaryOperatorSettled: (event) => settled.push(event.id),
590+
},
591+
},
592+
);
593+
});
594+
487595
test("sequential operator asks paint B's labels and id-scoped values, not A's", async () => {
488596
await withGates(async ({ shell, emitter }) => {
489597
const settledA = settleCapture();

‎src/tui/gate-wire.ts‎

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import {
2727
import { EXPAND_KEY } from "./stream.js";
2828
import {
2929
APPROVAL_UNAVAILABLE_MESSAGE,
30+
isPrimaryAskOperatorEvent,
3031
type OperatorGateEvent,
3132
type PermissionGateEvent,
3233
} from "./gate-events.js";
@@ -227,6 +228,10 @@ export interface GateLifecycleHooks {
227228
readonly onGateOpened: () => void;
228229
/** A previously raised gate resolved. */
229230
readonly onGateClosed: () => void;
231+
/** A marked primary ask_operator gate became outstanding. */
232+
readonly onPrimaryOperatorAdmitted?: (event: OperatorGateEvent) => void;
233+
/** A marked primary ask_operator gate settled. */
234+
readonly onPrimaryOperatorSettled?: (event: OperatorGateEvent) => void;
230235
}
231236

232237
const NOOP_GATE_HOOKS: GateLifecycleHooks = {
@@ -286,6 +291,10 @@ export function wireGates(
286291
// gate into dispose's teardown sweep after it has already resolved
287292
// (harmless, since `settled` guards the double-resolve, but wasted work).
288293
const operatorTeardowns = new Set<() => void>();
294+
// A live ask_operator gate can be re-emitted while its original event is
295+
// still awaiting an answer. Its id owns the single overlay and resolver
296+
// until that gate settles.
297+
const liveOperatorGateIds = new Set<string>();
289298
// Bumped every time any gate (permission or operator) opens on the shared
290299
// host. A settle path that only knows "my overlay was opened" cannot tell
291300
// whether the host has since moved on to a newer one — the shell closes an
@@ -507,12 +516,18 @@ export function wireGates(
507516
}
508517

509518
function onOperator(ev: OperatorGateEvent): void {
510-
hooks.onGateOpened();
511-
const resolve = onceClosed(hooks.onGateClosed, ev.resolve);
512519
if (typeof ev.id !== "string" || ev.id.length === 0) {
520+
hooks.onGateOpened();
521+
const resolve = onceClosed(hooks.onGateClosed, ev.resolve);
513522
resolve(operatorCancelResult());
514523
return;
515524
}
525+
if (liveOperatorGateIds.has(ev.id)) return;
526+
liveOperatorGateIds.add(ev.id);
527+
hooks.onGateOpened();
528+
const resolve = onceClosed(hooks.onGateClosed, ev.resolve);
529+
const primaryOperator = isPrimaryAskOperatorEvent(ev);
530+
if (primaryOperator) hooks.onPrimaryOperatorAdmitted?.(ev);
516531
const choices = operatorChoicesFromOptions(ev.options, ev.id);
517532
// Guarded the same way as the permission gate: correctness must not rest
518533
// on callers of closeInsetOverlay remembering to null the cancel hook
@@ -582,6 +597,8 @@ export function wireGates(
582597
settled = true;
583598
clearTimers();
584599
operatorTeardowns.delete(teardown);
600+
liveOperatorGateIds.delete(ev.id);
601+
if (primaryOperator) hooks.onPrimaryOperatorSettled?.(ev);
585602
if (openedGeneration === undefined) {
586603
unqueue(open);
587604
} else if (openedGeneration === overlayGeneration) {

‎src/tui/geometry/resolve.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,8 @@ export interface ZoneVisibility {
4747
readonly pending?: boolean | number;
4848
/** WORKER WAITING strip on (default off). */
4949
readonly workerWait?: boolean;
50+
/** INPUT REQUIRED strip for marked primary ask_operator gates (default off). */
51+
readonly inputRequired?: boolean;
5052
/** Progress: false/omit = 0; true = 2; or explicit 1|2. */
5153
readonly progress?: boolean | 1 | 2;
5254
/** Progress divider (0–1). Default on when progress is shown. */
@@ -160,6 +162,7 @@ export function desiredHeights(input: GeometryInput): MutableHeights {
160162
notice: vis.notice === true ? 1 : ZONE_REGISTRY.notice.idleDefault,
161163
pending: clamp(boolOrRows(vis.pending, 1), 0, ZONE_REGISTRY.pending.max),
162164
worker_wait: vis.workerWait === true ? 1 : 0,
165+
input_required: vis.inputRequired === true ? 1 : 0,
163166
prompt: promptRows,
164167
task: clamp(boolOrRows(vis.task, 1), 0, ZONE_REGISTRY.task.max),
165168
// The board asks for exactly the rows it will paint; the fraction is what

‎src/tui/geometry/zones.ts‎

Lines changed: 10 additions & 0 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",
@@ -114,6 +115,13 @@ export const ZONE_REGISTRY: Readonly<Record<ZoneId, ZoneDeclaration>> = {
114115
idleDefault: 0,
115116
alwaysOn: false,
116117
},
118+
input_required: {
119+
id: "input_required",
120+
min: 0,
121+
max: 1,
122+
idleDefault: 0,
123+
alwaysOn: false,
124+
},
117125
// Grows with what is being composed; the resolver caps it at PROMPT_CAP_FRACTION
118126
// and collapses it back toward min when the transcript would breach its floor.
119127
prompt: {
@@ -237,6 +245,7 @@ export const COLLAPSE_ORDER = [
237245
// once the wake turn scrolls away, so its single row is the last optional
238246
// cut, just ahead of prompt growth reclaim.
239247
"worker_wait",
248+
"input_required",
240249
// prompt growth reclaimed next (handled specially; never below PROMPT_BASE_ROWS)
241250
"prompt",
242251
] as const satisfies readonly ZoneId[];
@@ -259,6 +268,7 @@ export const PAINT_ORDER = [
259268
"notice",
260269
"pending",
261270
"worker_wait",
271+
"input_required",
262272
"prompt",
263273
] as const satisfies readonly ZoneId[];
264274

0 commit comments

Comments
 (0)