mirror of
https://github.com/badlogic/pi-mono.git
synced 2026-11-05 23:37:25 +00:00
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:
parent
36142c1c7e
commit
7f47a2ed09
5 changed files with 129 additions and 5 deletions
|
|
@ -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();
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue