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

@ -12,6 +12,7 @@
- Anthropic sessions now use message threads: each request sends only the new messages and the server continues from the previous reply, falling back to the full request when the conversation changed (for example after compaction or a tool list change). On by default for the `anthropic` provider on `api.anthropic.com`; set `compat.supportsMessageThreads` in `models.json` to enable it for gateways that forward the `thread` field, or to turn it off. Cache warming does not touch the session's thread ([#11545](https://github.com/earendil-works/pi/pull/11545))
- `pi.registerMarkdownTransformer()` takes an optional `{ messageTypes }` that selects the Markdown surfaces a transformer runs on. Besides user, assistant, and thinking Markdown, transformers can now opt into `custom` (custom messages and skill blocks), `summary` (branch and compaction summaries), and `changelog` (What's New, `/changelog`, the Extension Updates notice, and the Update Available note). Without options a transformer runs on user, assistant, and thinking Markdown as before. The built-in Mermaid renderer now also draws diagrams in expanded branch and compaction summaries
- `pi auth login <provider>` logs in to a provider from the shell without starting a session, for example `pi auth login radius`. It signs in with an account in the browser and exits; `--api-key` saves an API key instead, and `--no-browser` uses device code or copy-code sign-in instead. Logging in to Radius also configures the Radius MCP server. `pi auth logout <provider>` removes the stored credentials, like `/logout`
- `ctx.ui.custom()` overlays now honor the `blocked` option: pi reports blocked program status (OSC 7501) from when the overlay is shown until it calls `done()`, also while it is hidden with `setHidden()`. Closing one overlay keeps other open questions blocked ([#11816](https://github.com/earendil-works/pi/issues/11816))
### Changed

View file

@ -135,7 +135,7 @@ export default function (pi: ExtensionAPI) {
This component does not display a text cursor, so it does not need `Focusable`. A component containing `Input` or `Editor` must implement `Focusable` and propagate its `focused` value to that child.
Custom components do not report pi as waiting for the user, because `ctx.ui.custom()` also hosts loaders and other non-blocking UI. When an inline component waits for an answer, pass `blocked: { kind: "question", message: "Pick a color" }` so terminals that support [program status](terminal-setup.md#program-status) show pi as blocked while the component is visible. `kind` is `"question"`, `"permission"`, or `"auth"`. Overlays ignore this option.
Custom components do not report pi as waiting for the user, because `ctx.ui.custom()` also hosts loaders and other non-blocking UI. When a component waits for an answer, pass `blocked: { kind: "question", message: "Pick a color" }` so terminals that support [program status](terminal-setup.md#program-status) show pi as blocked. `kind` is `"question"`, `"permission"`, or `"auth"`. An inline component reports blocked while it is visible: a newer dialog or component shown in its place reports its own status instead. An overlay reports blocked from when it is shown until it calls `done()`, also while it is hidden with `setHidden()` or covered by another overlay. When several blocking prompts are open, the most recently shown one supplies `kind` and `message`.
Pass `overlay: true` to draw above existing content. Overlay options control size, anchors, offsets, margins, and responsive visibility. An overlay handle can change focus or temporarily hide and show the overlay with `setHidden()` while the interaction remains active.

View file

@ -240,8 +240,9 @@ export interface ExtensionUIContext {
/** Called with the overlay handle after the overlay is shown. Use to control visibility. */
onHandle?: (handle: OverlayHandle) => void;
/**
* Report pi as blocked on user input (OSC 7501 program status) while this component is visible.
* Only applies to inline components, not overlays.
* Report pi as blocked on user input (OSC 7501 program status) while this component waits for input.
* Inline components report it while visible. Overlays report it until `done()` is called, also while
* hidden with `setHidden()`.
*/
blocked?: { kind: NonNullable<ProgramStatus["kind"]>; message: string };
},

View file

@ -576,6 +576,10 @@ export class InteractiveMode {
// Extension dialogs shown in the editor slot (select, confirm, input, editor, inline custom).
// The last entry is visible; see pushExtensionDialog().
private extensionDialogs: Array<{ component: ExtensionDialogComponent; blocked: BlockedStatus | undefined }> = [];
/** Numbers the program status sources of blocking overlays, so closing one keeps the others. */
private nextBlockingOverlayId = 0;
/** Program status sources of blocking overlays that have not called `done()` yet. */
private readonly blockingOverlaySources = new Set<string>();
private extensionTerminalInputSubscriptions = new Set<{
handler: (data: string) => { consume?: boolean; data?: string } | undefined;
unsubscribe: () => void;
@ -2584,6 +2588,9 @@ export class InteractiveMode {
this.showTopExtensionDialog();
}
this.ui.hideOverlay();
// Reload and session switches drop overlays without calling done(), so clear their blocked status here.
for (const source of this.blockingOverlaySources) this.programStatus.setBlocked(source, undefined);
this.blockingOverlaySources.clear();
this.clearExtensionTerminalInputListeners();
this.setExtensionFooter(undefined);
this.setExtensionHeader(undefined);
@ -3072,6 +3079,9 @@ export class InteractiveMode {
return new Promise((resolve, reject) => {
let component: ExtensionDialogComponent | undefined;
let overlayHandle: OverlayHandle | undefined;
// An overlay stays blocked until its interaction ends, also while setHidden() hides it.
const overlayStatusSource =
isOverlay && options?.blocked ? `extension-overlay:${this.nextBlockingOverlayId++}` : undefined;
let closed = false;
const restoreEditor = () => {
@ -3086,6 +3096,10 @@ export class InteractiveMode {
// Remove this overlay, not the topmost one: another overlay may have opened above it.
if (isOverlay) overlayHandle?.hide();
else restoreEditor();
if (overlayStatusSource) {
this.blockingOverlaySources.delete(overlayStatusSource);
this.programStatus.setBlocked(overlayStatusSource, undefined);
}
// Note: both branches above already call requestRender
resolve(result);
try {
@ -3114,6 +3128,10 @@ export class InteractiveMode {
return w ? { width: w } : undefined;
};
overlayHandle = this.ui.showOverlay(c, resolveOptions());
if (overlayStatusSource) {
this.blockingOverlaySources.add(overlayStatusSource);
this.programStatus.setBlocked(overlayStatusSource, options?.blocked);
}
// Expose handle to caller for visibility control
options?.onHandle?.(overlayHandle);
} else {

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();