Skip to content

Commit f4ab199

Browse files
committed
feat(tui): notify the operator when a gate needs attention
1 parent 3148e3e commit f4ab199

3 files changed

Lines changed: 73 additions & 1 deletion

File tree

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

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -412,6 +412,47 @@ describe("operatorChoicesFromOptions / operatorResultFromSelection", () => {
412412
});
413413

414414
describe("wireGates", () => {
415+
test("notifies once when a permission gate reaches the screen", async () => {
416+
const seen: string[] = [];
417+
await withGates(
418+
async ({ emitter }) => {
419+
emitPermission(emitter, { id: "req-9" });
420+
emitPermission(emitter, { id: "req-9" });
421+
},
422+
{
423+
hooks: {
424+
notify: {
425+
notify: (message) => {
426+
seen.push(message);
427+
return true;
428+
},
429+
},
430+
},
431+
},
432+
);
433+
expect(seen).toEqual(["Approval needed"]);
434+
});
435+
436+
test("notifies when an operator gate reaches the screen", async () => {
437+
const seen: string[] = [];
438+
await withGates(
439+
async ({ emitter }) => {
440+
emitOperator(emitter);
441+
},
442+
{
443+
hooks: {
444+
notify: {
445+
notify: (message) => {
446+
seen.push(message);
447+
return true;
448+
},
449+
},
450+
},
451+
},
452+
);
453+
expect(seen).toEqual(["Input needed"]);
454+
});
455+
415456
test("subscribes exactly permission.gate and operator.gate; dispose removes both", async () => {
416457
await withGates(async ({ emitter, disposeGates }) => {
417458
expect(emitter.listenerCount("permission.gate")).toBe(1);

‎src/tui/gate-wire.ts‎

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -222,6 +222,13 @@ export interface GateLifecycleHooks {
222222
readonly onPrimaryOperatorAdmitted?: (event: OperatorGateEvent) => void;
223223
/** A marked primary ask_operator gate settled. */
224224
readonly onPrimaryOperatorSettled?: (event: OperatorGateEvent) => void;
225+
/** Desktop/terminal notification. Fired once per gate id when that gate
226+
* reaches the screen. Absent means no notification. */
227+
readonly notify?: NotifierPort;
228+
}
229+
230+
export interface NotifierPort {
231+
notify(message: string, title?: string): boolean;
225232
}
226233

227234
const NOOP_GATE_HOOKS: GateLifecycleHooks = {
@@ -275,6 +282,17 @@ export function wireGates(
275282
// tell whether the host has since opened a newer gate; comparing the
276283
// open-time capture against the current value closes only its own overlay.
277284
let overlayGeneration = 0;
285+
const notifiedGateIds = new Set<string>();
286+
287+
function notifyOnce(id: string, message: string, title: string): void {
288+
if (notifiedGateIds.has(id)) return;
289+
notifiedGateIds.add(id);
290+
try {
291+
hooks.notify?.notify(message, title);
292+
} catch {
293+
// A notifier that throws must not block the gate.
294+
}
295+
}
278296

279297
function openHost(open: () => void): void {
280298
overlayGeneration++;
@@ -388,6 +406,7 @@ export function wireGates(
388406
// screen, distinct from when it was raised (see
389407
// PermissionRequest.markDisplayed).
390408
ev.request.markDisplayed?.();
409+
notifyOnce(ev.id, "Approval needed", "corbits");
391410
if (ev.timeoutMs !== undefined) {
392411
timer = setTimeout(() => {
393412
autoDeny(ev.timeoutMessage ?? "approval timed out; request denied");
@@ -489,6 +508,7 @@ export function wireGates(
489508

490509
const open = (): void => {
491510
openedGeneration = overlayGeneration;
511+
notifyOnce(ev.id, "Input needed", "corbits");
492512
if (ev.timeoutMs !== undefined) {
493513
timer = setTimeout(() => {
494514
autoCancel();

‎src/tui/product-host.ts‎

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ import {
2020
} from "./runtime-bridge.js";
2121
import type { ShellOutputFeed } from "../session/shell-output-feed.js";
2222
import { openAddProviderOverlay, openModelPickerOverlay } from "./overlays.js";
23-
import { wireGates } from "./gate-wire.js";
23+
import { wireGates, type NotifierPort } from "./gate-wire.js";
2424
import { createSystemClipboard } from "./system-clipboard.js";
2525
import { programStatusForChrome } from "./program-status.js";
2626
import {
@@ -268,6 +268,8 @@ export interface ProductHostConfig {
268268
/** OSC 7501 sink. Default calls `renderer.setProgramStatus` and ignores a
269269
* false return (terminal inactive, output full, or destroyed). */
270270
readonly programStatus?: ProgramStatusPort;
271+
/** Gate attention ping. Default calls `renderer.triggerNotification`. */
272+
readonly notifier?: NotifierPort;
271273
}
272274

273275
export interface ProgramStatusPort {
@@ -616,6 +618,15 @@ export async function mountProductHost(
616618
setOperatorInputRequiredGate(shell, event),
617619
onPrimaryOperatorSettled: (event) =>
618620
clearOperatorInputRequired(shell, event.id),
621+
notify: config.notifier ?? {
622+
notify: (message, title) => {
623+
try {
624+
return renderer.triggerNotification(message, title);
625+
} catch {
626+
return false;
627+
}
628+
},
629+
},
619630
});
620631
} catch (err: unknown) {
621632
try {

0 commit comments

Comments
 (0)