Merge origin/main: share overlay removal between done() and reset

#11851 added blocked status tracking for overlays. done() and
resetExtensionUI() now both remove an overlay through one function,
which hides it, clears its blocked status and disposes it.
This commit is contained in:
Petr Houška 2026-11-05 23:27:38 +01:00
commit 5b520b0efc
5 changed files with 146 additions and 26 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,9 +576,13 @@ 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 }> = [];
// Open extension overlays (custom with overlay: true), in opening order. Reset removes only these,
// not pi's own overlays.
private extensionOverlays = new Map<OverlayHandle, ExtensionDialogComponent>();
/** Numbers the program status sources of blocking overlays, so closing one keeps the others. */
private nextBlockingOverlayId = 0;
/**
* Open extension overlays (custom with overlay: true) and the function that removes each, in opening
* order. Reset removes only these, not pi's own overlays.
*/
private readonly extensionOverlays = new Map<OverlayHandle, () => void>();
private extensionTerminalInputSubscriptions = new Set<{
handler: (data: string) => { consume?: boolean; data?: string } | undefined;
unsubscribe: () => void;
@ -2586,11 +2590,9 @@ export class InteractiveMode {
this.extensionDialogs = [];
this.showTopExtensionDialog();
}
for (const [handle, component] of [...this.extensionOverlays].reverse()) {
handle.hide();
component.dispose?.();
}
this.extensionOverlays.clear();
// Reload and session switches replace every extension without calling done(): remove their overlays,
// newest first, the same way done() does, but leave the promises pending as for inline dialogs.
for (const removeOverlay of [...this.extensionOverlays.values()].reverse()) removeOverlay();
this.clearExtensionTerminalInputListeners();
this.setExtensionFooter(undefined);
this.setExtensionHeader(undefined);
@ -3079,6 +3081,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 = () => {
@ -3087,18 +3092,7 @@ export class InteractiveMode {
this.ui.requestRender();
};
const close = (result: T) => {
if (closed) return;
closed = true;
// Remove this overlay, not the topmost one: another overlay may have opened above it.
if (isOverlay) {
if (overlayHandle) {
overlayHandle.hide();
this.extensionOverlays.delete(overlayHandle);
}
} else restoreEditor();
// Note: both branches above already call requestRender
resolve(result);
const disposeComponent = () => {
try {
component?.dispose?.();
} catch {
@ -3106,6 +3100,28 @@ export class InteractiveMode {
}
};
// Shared by done() and resetExtensionUI(). Removing the map entry first makes a second call a no-op.
const removeOverlay = () => {
if (!overlayHandle || !this.extensionOverlays.delete(overlayHandle)) return;
// Remove this overlay, not the topmost one: another overlay may have opened above it.
overlayHandle.hide();
if (overlayStatusSource) this.programStatus.setBlocked(overlayStatusSource, undefined);
disposeComponent();
};
const close = (result: T) => {
if (closed) return;
closed = true;
if (isOverlay) {
removeOverlay();
resolve(result);
} else {
restoreEditor();
resolve(result);
disposeComponent();
}
};
Promise.resolve(factory(this.ui, theme, this.keybindings, close))
.then((c) => {
if (closed) return;
@ -3125,7 +3141,8 @@ export class InteractiveMode {
return w ? { width: w } : undefined;
};
overlayHandle = this.ui.showOverlay(c, resolveOptions());
this.extensionOverlays.set(overlayHandle, c);
this.extensionOverlays.set(overlayHandle, removeOverlay);
if (overlayStatusSource) this.programStatus.setBlocked(overlayStatusSource, options?.blocked);
// Expose handle to caller for visibility control
options?.onHandle?.(overlayHandle);
} else {

View file

@ -8,7 +8,14 @@ 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 };
};
overlays: Component[];
nextBlockingOverlayId: number;
extensionOverlays: Map<unknown, () => void>;
programStatus: { setBlocked: ReturnType<typeof vi.fn> };
disposeActiveSelector: () => void;
toggleToolOutputExpansion: () => void;
@ -18,11 +25,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 +56,19 @@ 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(),
};
},
},
overlays: [],
nextBlockingOverlayId: 0,
extensionOverlays: new Map(),
programStatus: { setBlocked: vi.fn() },
disposeActiveSelector: vi.fn(),
toggleToolOutputExpansion: vi.fn(),
@ -191,6 +215,83 @@ 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.extensionOverlays.size).toBe(0);
expect(context.overlays).toHaveLength(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();