Skip to content
Open
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
624 changes: 624 additions & 0 deletions src/tui/allow-once-reprompt.test.ts

Large diffs are not rendered by default.

66 changes: 49 additions & 17 deletions src/tui/gate-wire.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,9 @@ import {
closeInsetOverlay,
isOverlayHostIdle,
onOverlayClosed,
resumeSuspendedCommandSurface,
setOverlayBody,
suspendReplaceableOverlay,
} from "./shell/overlay-host.js";
import { EXPAND_KEY } from "./stream.js";
import {
Expand Down Expand Up @@ -258,6 +260,7 @@ export function wireGates(
// nothing on screen to answer — so a gate that arrives while another overlay
// is up waits here and opens as soon as the host frees up.
const pending: (() => void)[] = [];
let disposed = false;
// Owns queued-approval reconciliation (see src/permission/queue.ts): this
// host only enqueues requests and renders whatever settle calls the queue
// hands back — it never decides which grant covers which request.
Expand Down Expand Up @@ -295,11 +298,45 @@ export function wireGates(
}

function openOrQueue(open: () => void): void {
if (!isOverlayHostIdle(shell)) {
if (isOverlayHostIdle(shell)) {
openHost(open);
return;
}
if (shell.overlayList !== null) {
// A replaceable command surface yields to the decision gate and is
// restored after the gate settles. The suspend is a no-op for live
// gates and stacked popups (palette, mentions — they keep their
// stacking contracts), so those arrivals simply stay queued.
pending.push(open);
suspendReplaceableOverlay(shell);
// The suspend-close's idle-notify may already have opened an older
// queued gate (FIFO): drain here only if the host is still free, so a
// close-notify drain is never doubled.
if (shell.overlayList === null) {
const next = pending.shift();
if (next !== undefined) openHost(next);
}
return;
}
pending.push(open);
}

/**
* Open the next queued gate, else return a suspended command surface to
* the host. Every gate settle path runs this after resolving. Skipped past
* teardown so a late settle cannot paint onto a dead shell.
*/
function drainPendingOrResume(): void {
if (disposed || shell.disposed) return;
// A close-notify drain may already have taken the host (Esc / timeout
// while displayed): never double-open, and never tear down a live gate.
if (shell.overlayList !== null) return;
const next = pending.shift();
if (next !== undefined) {
openHost(next);
return;
}
openHost(open);
resumeSuspendedCommandSurface(shell);
}

function unqueue(open: () => void): void {
Expand Down Expand Up @@ -351,6 +388,9 @@ export function wireGates(
closeInsetOverlay(shell);
}
resolve(outcome);
// The next queued gate takes the host before any deferred surface;
// a suspended command surface returns only when no gate is waiting.
drainPendingOrResume();
});

const onToggleExpand = (): void => {
Expand Down Expand Up @@ -496,11 +536,7 @@ export function wireGates(
// ask — or the overlay's generic accept echo — into the transcript.
echoChoice: false,
onAccept: (sel: OverlaySelection) => {
if (settled) return;
settled = true;
clearTimers();
operatorTeardowns.delete(teardown);
resolve(
settleOnce(
operatorResultFromSelection(choices, {
index: sel.index,
...(sel.id !== undefined ? { id: sel.id } : {}),
Expand All @@ -510,11 +546,7 @@ export function wireGates(
// The ask_operator contract offers a free-form answer, so the overlay
// must be able to send one back rather than only an option index.
onTextAnswer: (text: string) => {
if (settled) return;
settled = true;
clearTimers();
operatorTeardowns.delete(teardown);
resolve(operatorCustomResult(text));
settleOnce(operatorCustomResult(text));
},
// Esc must settle the awaited promise (as a cancel), not abandon it —
// an unresolved gate hangs the run until the process is killed.
Expand All @@ -523,11 +555,7 @@ export function wireGates(
// closeInsetOverlay itself; doing so would reenter this same
// onCancel (see the permission gate's identical note on `settle`).
onCancel: () => {
if (settled) return;
settled = true;
clearTimers();
operatorTeardowns.delete(teardown);
resolve(operatorCancelResult());
settleOnce(operatorCancelResult());
},
isGate: true,
});
Expand All @@ -544,6 +572,9 @@ export function wireGates(
closeInsetOverlay(shell);
}
resolve(result);
// The next queued gate takes the host before any deferred surface;
// a suspended command surface returns only when no gate is waiting.
drainPendingOrResume();
};
const autoCancel = (): void => {
settleOnce(operatorCancelResult());
Expand All @@ -568,6 +599,7 @@ export function wireGates(
emitter.on("operator.gate", onOperator);

return () => {
disposed = true;
emitter.off("permission.gate", onPermission);
emitter.off("operator.gate", onOperator);
disposeReconciliation();
Expand Down
2 changes: 1 addition & 1 deletion src/tui/overlay-view.ts
Original file line number Diff line number Diff line change
Expand Up @@ -435,5 +435,5 @@ export function createOverlayView(ctx: RenderContext) {
);
}

return { host, title, body, paintTitle, paintList, clearBody };
return { host, title, body, paintTitle, paintList, clearBody, detachList };
}
12 changes: 11 additions & 1 deletion src/tui/runtime-bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import {
import {
clearShellBridgeHooks,
setShellBridgeHooks,
shellInternals,
type AppShell,
} from "./shell/internals.js";
import { applyShellInterrupt, surfaceSystemNotice } from "./shell/prompt.js";
Expand Down Expand Up @@ -1185,10 +1186,18 @@ function syncShellOutputs(
/**
* Refresh every plain in-flight tool call's row with how long it has been
* running, frame-coalesced. `spawn_agent` dispatches already get this (and
* more) from `syncAgentProgress`, so they are skipped here.
* more) from `syncAgentProgress`, so they are skipped here. While a decision
* gate is outstanding but not on screen — queued behind another overlay — the
* tool waits on an operator who cannot see it yet, so its elapsed stays frozen
* and the row reads as paused instead of running. Once the gate is shown the
* clock runs again: the operator can see what blocks the tool.
*/
function syncToolElapsed(shell: AppShell, bag: BridgeBag, nowMs: number): void {
if (bag.toolCallStartedAt.size === 0) return;
const gateOnScreen =
shell.overlayList !== null &&
shellInternals(shell)?.primaryBindings.isGate === true;
const gateHidden = bag.turn.blockedGateCount > 0 && !gateOnScreen;
for (const [callId, startedAt] of bag.toolCallStartedAt) {
if (bag.taskCallIds.has(callId)) continue;
const index = bag.toolRows.get(callId);
Expand All @@ -1201,6 +1210,7 @@ function syncToolElapsed(shell: AppShell, bag: BridgeBag, nowMs: number): void {
bag.toolCallStartedAt.delete(callId);
continue;
}
if (gateHidden) continue;
const current = bag.pendingRowUpdates.get(index) ?? row;
const stat = clockLabel(nowMs - startedAt);
if (current.stat === stat) continue;
Expand Down
1 change: 1 addition & 0 deletions src/tui/shell/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -523,6 +523,7 @@ export function createAppShell(
overlayClosedListeners: new Set(),
deferredCommandOverlay: null,
deferredFlushScheduled: false,
suspendedCommandSurface: null,
overlayHostReservations: 0,
overlayReservationEpoch: 0,
paletteCatalog: paletteCatalogOpt,
Expand Down
9 changes: 8 additions & 1 deletion src/tui/shell/internals.ts
Original file line number Diff line number Diff line change
Expand Up @@ -684,7 +684,7 @@ export const EMPTY_PRIMARY_BINDINGS: Readonly<PrimaryOverlayBindings> = {
mcpAddHint: false,
};

interface PriorOverlaySnapshot {
export interface PriorOverlaySnapshot {
readonly kind: PrimaryOverlayKind | null;
readonly items: readonly string[];
readonly bodyLines: readonly string[];
Expand All @@ -711,6 +711,13 @@ interface ShellInternals {
overlayRawBodyText: string;
/** Snapshot when palette stacks over another primary overlay. */
priorOverlay: PriorOverlaySnapshot | null;
/**
* Replaceable command surface suspended while a decision gate holds the
* host. One slot; restored after the gate settles when no queued gate
* takes the host first. Never a gate or palette — those keep their own
* stacking contracts.
*/
suspendedCommandSurface: PriorOverlaySnapshot | null;
/** Advances on a new overlay taking the host, and when the host empties. */
overlayGeneration: number;
primaryBindings: PrimaryOverlayBindings;
Expand Down
Loading
Loading