diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index f74150106..e0983f28e 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 @@ -36,6 +37,7 @@ - Fixed OAuth logins such as `openai` and `anthropic` failing with `invalid_grant` after pi was stopped while it refreshed an expired token, for example an RPC client that stops pi right after startup while the background catalog refresh runs. The token endpoint had already replaced the refresh token, but pi exited before saving the new one. On exit, pi now waits for a token refresh in progress to be saved ([#11692](https://github.com/earendil-works/pi/issues/11692)) - Fixed Ctrl+P not switching to the only model in scope (or the only available model) when the active model is a different one; it showed "Only one model in scope" instead. Cycling forward from a model outside the scope now also selects the first scoped model instead of skipping it ([#11806](https://github.com/earendil-works/pi/issues/11806)) - Fixed the bash and PowerShell tools on Windows leaving processes running after an abort or timeout. `taskkill /T` finds a command's processes through their parent, so it missed background processes whose shell had already exited (for example a loop that kept writing until the timeout) and processes started by intermediate processes that had exited. Commands now run in a Windows job object through a small bundled helper (`pi-job.exe`, x64 and arm64), which every process the command starts joins and cannot leave, and abort and timeout terminate the whole job. Background processes still keep running after a command finishes, as on Unix ([#11613](https://github.com/earendil-works/pi/issues/11613)) +- Fixed `/reload` and session switches leaving extension overlays on screen when an extension had more than one `ctx.ui.custom({ overlay: true })` open: only the topmost overlay was removed, and the others kept focus and input although the extension that opened them was gone. All extension overlays, including hidden ones, are now removed and disposed; pi's own overlays stay open ## [1.5.0] - 2026-11-02 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 5f1b468b4..469b63129 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -576,6 +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 }> = []; + /** 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; @@ -2583,7 +2590,9 @@ export class InteractiveMode { this.extensionDialogs = []; this.showTopExtensionDialog(); } - this.ui.hideOverlay(); + // 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); @@ -3072,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 = () => { @@ -3080,14 +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) overlayHandle?.hide(); - else restoreEditor(); - // Note: both branches above already call requestRender - resolve(result); + const disposeComponent = () => { try { component?.dispose?.(); } catch { @@ -3095,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; @@ -3114,6 +3141,8 @@ export class InteractiveMode { return w ? { width: w } : undefined; }; overlayHandle = this.ui.showOverlay(c, resolveOptions()); + 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(); diff --git a/packages/coding-agent/test/interactive-mode-status.test.ts b/packages/coding-agent/test/interactive-mode-status.test.ts index 3e26d845c..931b329aa 100644 --- a/packages/coding-agent/test/interactive-mode-status.test.ts +++ b/packages/coding-agent/test/interactive-mode-status.test.ts @@ -2,7 +2,7 @@ import { homedir } from "node:os"; import * as path from "node:path"; import { type AutocompleteProvider, CombinedAutocompleteProvider } from "@earendil-works/pi-tui"; import { beforeAll, describe, expect, test, vi } from "vitest"; -import { type Component, Container, type Focusable, type TUI } from "../../tui/src/tui.ts"; +import { type Component, Container, type Focusable, type OverlayHandle, type TUI } from "../../tui/src/tui.ts"; import { TuiMainScreen } from "../../tui/src/tui-main-screen.ts"; import { VirtualTerminal } from "../../tui/test/virtual-terminal.ts"; import type { AutocompleteProviderFactory } from "../src/core/extensions/types.ts"; @@ -237,6 +237,7 @@ describe("InteractiveMode.showExtensionCustom", () => { // Inline custom components go through the extension dialog stack. const extensionDialogState = () => ({ extensionDialogs: [], + extensionOverlays: new Map(), programStatus: { setBlocked: vi.fn() }, pushExtensionDialog: (InteractiveMode as any).prototype.pushExtensionDialog, removeExtensionDialog: (InteractiveMode as any).prototype.removeExtensionDialog, @@ -389,6 +390,79 @@ describe("InteractiveMode.showExtensionCustom", () => { } }, ); + + // Regression: reset hid only the topmost overlay, so lower extension overlays stayed and kept input. + test("resetExtensionUI removes every extension overlay but keeps pi's own overlays", async () => { + const terminal = new VirtualTerminal(80, 24); + const ui: TUI = new TuiMainScreen(terminal); + const editorContainer = new Container(); + const editor = new TestFocusableComponent("EDITOR"); + const internal = new TestFocusableComponent("INTERNAL"); + const lower = Object.assign(new TestFocusableComponent("LOWER"), { dispose: vi.fn() }); + const hidden = Object.assign(new TestFocusableComponent("HIDDEN"), { dispose: vi.fn() }); + const upper = Object.assign(new TestFocusableComponent("UPPER"), { dispose: vi.fn() }); + const fakeThis = { + editor, + editorContainer, + keybindings: {}, + ui, + disposeActiveSelector: vi.fn(), + ...extensionDialogState(), + // The rest of resetExtensionUI is not under test here. + 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(), + }; + const showOverlay = (component: Component, onHandle?: (handle: OverlayHandle) => void): Promise => + (InteractiveMode as any).prototype.showExtensionCustom.call(fakeThis, () => component, { + overlay: true, + onHandle, + }) as Promise; + + editorContainer.addChild(editor); + ui.addChild(editorContainer); + ui.setFocus(editor); + ui.start(); + try { + // A pi-internal overlay (for example the header easter egg) opened below the extension overlays. + ui.showOverlay(internal); + void showOverlay(lower); + void showOverlay(hidden, (handle) => handle.setHidden(true)); + void showOverlay(upper); + await flushTui(ui, terminal); + expect(upper.focused).toBe(true); + + (InteractiveMode as any).prototype.resetExtensionUI.call(fakeThis); + await flushTui(ui, terminal); + + const viewport = terminal.getViewport().join("\n"); + expect(viewport).toContain("INTERNAL"); + expect(viewport).not.toContain("LOWER"); + expect(viewport).not.toContain("UPPER"); + expect(lower.dispose).toHaveBeenCalledOnce(); + expect(hidden.dispose).toHaveBeenCalledOnce(); + expect(upper.dispose).toHaveBeenCalledOnce(); + terminal.sendInput("x"); + await flushTui(ui, terminal); + expect(internal.inputs).toEqual(["x"]); + expect(lower.inputs).toEqual([]); + + ui.hideOverlay(); + expect(ui.hasOverlay()).toBe(false); + expect(editor.focused).toBe(true); + } finally { + ui.stop(); + } + }); }); describe("InteractiveMode.createExtensionUIContext addAutocompleteProvider", () => {