feat(coding-agent): let ctx.ui.custom() overlays report blocked program status (#11851)

Closes #11816.

## Problem

`ctx.ui.custom()` accepted `blocked` only for inline components.
Overlays ignored it, so an extension question shown as an overlay left
pi reporting `working` over OSC 7501. Example: pi-ask-user in its
default overlay mode inside Herdr App never shows the session as blocked
and never notifies.

## Change

- Each overlay opened with `blocked` registers its own program status
source (`extension-overlay:N`) after `showOverlay()` and clears it when
the overlay calls `done()`.
- The status stays while the overlay is hidden with `setHidden()` or
covered by another overlay: the question is still unanswered.
- Separate sources per overlay, so closing one keeps other open
questions, dialogs and login prompts blocked. With several blocking
prompts, the most recently shown one supplies `kind` and `message`.
- Overlays without `blocked` (loaders etc.) register nothing and cannot
clear another overlay's status.
- `/reload` and session switches drop overlays without calling `done()`;
`resetExtensionUI()` now clears their blocked status so pi does not stay
blocked.
- Inline components are unchanged: they report the dialog shown in the
editor slot, so a non-blocking component shown in its place clears the
status until it closes.
- Docs (`docs/tui.md`, `custom()` JSDoc) and changelog updated.

## Testing

- `npm run check`
- `test/interactive-mode-extension-dialogs.test.ts`: new regression test
(two blocking overlays, one non-blocking overlay, hide, close in order)
and one for clearing the status on reset.

pi-ask-user still needs to pass `blocked` for its overlay prompts.
This commit is contained in:
Petr Houška 2026-11-05 23:23:58 +01:00 • committed by GitHub
parent 36142c1c7e
commit 7f47a2ed09
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 129 additions and 5 deletions

View file

@ -8,7 +8,15 @@ type DialogContext = {
editorContainer: Container;
editor: Component & { getText(): string; setText(text: string): void };
focused: Component | undefined;
ui: { setFocus: (component: Component) => void; requestRender: () => void };
ui: {
setFocus: (component: Component) => void;
requestRender: () => void;
showOverlay: (component: Component) => { hide: () => void; setHidden: (hidden: boolean) => void };
hideOverlay: () => void;
};
overlays: Component[];
nextBlockingOverlayId: number;
blockingOverlaySources: Set<string>;
programStatus: { setBlocked: ReturnType<typeof vi.fn> };
disposeActiveSelector: () => void;
toggleToolOutputExpansion: () => void;
@ -18,11 +26,16 @@ type DialogMethods = {
showExtensionConfirm(title: string, message: string, opts?: { signal?: AbortSignal }): Promise<boolean>;
showExtensionCustom<T>(
factory: (tui: unknown, theme: unknown, keybindings: unknown, done: (result: T) => void) => Component,
options?: { blocked?: { kind: "permission" | "question" | "auth"; message: string } },
options?: {
overlay?: boolean;
onHandle?: (handle: { setHidden: (hidden: boolean) => void }) => void;
blocked?: { kind: "permission" | "question" | "auth"; message: string };
},
): Promise<T>;
setCustomEditorComponent(
factory: ((tui: unknown, theme: unknown, keybindings: unknown) => Component) | undefined,
): void;
resetExtensionUI(): void;
};
function createContext(): DialogContext & DialogMethods {
@ -44,7 +57,22 @@ function createContext(): DialogContext & DialogMethods {
context.focused = component;
},
requestRender: vi.fn(),
showOverlay: (component) => {
context.overlays.push(component);
return {
hide: () => {
context.overlays = context.overlays.filter((overlay) => overlay !== component);
},
setHidden: vi.fn(),
};
},
hideOverlay: () => {
context.overlays.pop();
},
},
overlays: [],
nextBlockingOverlayId: 0,
blockingOverlaySources: new Set(),
programStatus: { setBlocked: vi.fn() },
disposeActiveSelector: vi.fn(),
toggleToolOutputExpansion: vi.fn(),
@ -191,6 +219,82 @@ describe("InteractiveMode extension dialogs", () => {
expect(context.programStatus.setBlocked).toHaveBeenLastCalledWith("extension-dialog", undefined);
});
// Regression for #11816: overlays ignored the blocked option.
it("reports blocked status for each opted-in overlay until it is done, also while hidden", async () => {
const context = createContext();
const dones: Array<(result: string) => void> = [];
const handles: Array<{ setHidden: (hidden: boolean) => void }> = [];
const open = (text: string, blocked?: { kind: "question"; message: string }) =>
context.showExtensionCustom<string>(
(_tui, _theme, _keybindings, done) => {
dones.push(done);
return new Text(text);
},
{ overlay: true, onHandle: (handle) => handles.push(handle), blocked },
);
const first = open("first", { kind: "question", message: "First?" });
await vi.waitFor(() => expect(context.overlays).toHaveLength(1));
expect(context.programStatus.setBlocked).toHaveBeenLastCalledWith("extension-overlay:0", {
kind: "question",
message: "First?",
});
const second = open("second", { kind: "question", message: "Second?" });
await vi.waitFor(() => expect(context.overlays).toHaveLength(2));
expect(context.programStatus.setBlocked).toHaveBeenLastCalledWith("extension-overlay:1", {
kind: "question",
message: "Second?",
});
const loader = open("loading");
await vi.waitFor(() => expect(context.overlays).toHaveLength(3));
dones[2]("loaded");
await expect(loader).resolves.toBe("loaded");
handles[0].setHidden(true);
dones[1]("two");
await expect(second).resolves.toBe("two");
expect(context.programStatus.setBlocked).toHaveBeenLastCalledWith("extension-overlay:1", undefined);
dones[0]("one");
await expect(first).resolves.toBe("one");
expect(context.programStatus.setBlocked).toHaveBeenLastCalledWith("extension-overlay:0", undefined);
expect(context.programStatus.setBlocked).toHaveBeenCalledTimes(4);
});
// Regression for #11816: /reload and session switches drop overlays without calling done().
it("clears overlay blocked status when the extension UI is reset", async () => {
const context = createContext();
Object.assign(context, {
clearExtensionTerminalInputListeners: vi.fn(),
setExtensionFooter: vi.fn(),
setExtensionHeader: vi.fn(),
clearExtensionWidgets: vi.fn(),
footerDataProvider: { clearExtensionStatuses: vi.fn() },
footer: { invalidate: vi.fn() },
setCustomEditorComponent: vi.fn(),
setupAutocompleteProvider: vi.fn(),
defaultEditor: {},
updateTerminalTitle: vi.fn(),
setWorkingIndicator: vi.fn(),
setHiddenThinkingLabel: vi.fn(),
});
void context.showExtensionCustom<string>(() => new Text("question"), {
overlay: true,
blocked: { kind: "question", message: "Question?" },
});
await vi.waitFor(() => expect(context.overlays).toHaveLength(1));
expect(context.programStatus.setBlocked).toHaveBeenLastCalledWith("extension-overlay:0", {
kind: "question",
message: "Question?",
});
context.resetExtensionUI();
expect(context.programStatus.setBlocked).toHaveBeenLastCalledWith("extension-overlay:0", undefined);
expect(context.blockingOverlaySources.size).toBe(0);
});
// Regression for #11431: replacing the editor while a dialog was open hid the dialog and left it unanswerable.
it("keeps an open dialog visible when the editor component is replaced", async () => {
const context = createContext();