diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index c194ee9dc..223e7f851 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -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 ` 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 ` 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 diff --git a/packages/coding-agent/docs/tui.md b/packages/coding-agent/docs/tui.md index c3fb30939..4b32646c6 100644 --- a/packages/coding-agent/docs/tui.md +++ b/packages/coding-agent/docs/tui.md @@ -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. diff --git a/packages/coding-agent/src/core/extensions/types.ts b/packages/coding-agent/src/core/extensions/types.ts index 142451e4d..7fcc870a9 100644 --- a/packages/coding-agent/src/core/extensions/types.ts +++ b/packages/coding-agent/src/core/extensions/types.ts @@ -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; message: string }; }, diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index 4eaa3a489..88ef9f643 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -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(); + /** 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 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 { diff --git a/packages/coding-agent/test/interactive-mode-extension-dialogs.test.ts b/packages/coding-agent/test/interactive-mode-extension-dialogs.test.ts index 91a9d1966..7ed53b38d 100644 --- a/packages/coding-agent/test/interactive-mode-extension-dialogs.test.ts +++ b/packages/coding-agent/test/interactive-mode-extension-dialogs.test.ts @@ -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 void>; programStatus: { setBlocked: ReturnType }; disposeActiveSelector: () => void; toggleToolOutputExpansion: () => void; @@ -18,11 +25,16 @@ type DialogMethods = { showExtensionConfirm(title: string, message: string, opts?: { signal?: AbortSignal }): Promise; showExtensionCustom( 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; 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( + (_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(() => 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();