diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 7a51caad1..481dff5d6 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..2ac9f2520 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. An overlay reports blocked from when it is shown until it calls `done()`, also while it is temporarily hidden with `setHidden()`. 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 c65c40c2a..649cf3a13 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -576,6 +576,8 @@ 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; private extensionTerminalInputSubscriptions = new Set<{ handler: (data: string) => { consume?: boolean; data?: string } | undefined; unsubscribe: () => void; @@ -3072,6 +3074,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 +3091,7 @@ 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.programStatus.setBlocked(overlayStatusSource, undefined); // Note: both branches above already call requestRender resolve(result); try { @@ -3114,6 +3120,7 @@ export class InteractiveMode { return w ? { width: w } : undefined; }; overlayHandle = this.ui.showOverlay(c, resolveOptions()); + 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..d2a798c95 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,13 @@ 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; programStatus: { setBlocked: ReturnType }; disposeActiveSelector: () => void; toggleToolOutputExpansion: () => void; @@ -18,7 +24,11 @@ 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, @@ -44,7 +54,18 @@ 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, programStatus: { setBlocked: vi.fn() }, disposeActiveSelector: vi.fn(), toggleToolOutputExpansion: vi.fn(), @@ -191,6 +212,50 @@ 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 #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();