From 7f47a2ed09628bda02d470de2b0d6d2f5cc61d43 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Petr=20Hou=C5=A1ka?= Date: Thu, 5 Nov 2026 23:23:58 +0100 Subject: [PATCH 1/2] 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. --- packages/coding-agent/CHANGELOG.md | 1 + packages/coding-agent/docs/tui.md | 2 +- .../coding-agent/src/core/extensions/types.ts | 5 +- .../src/modes/interactive/interactive-mode.ts | 18 +++ ...interactive-mode-extension-dialogs.test.ts | 108 +++++++++++++++++- 5 files changed, 129 insertions(+), 5 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index a7700b2a0..37efbc3ae 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 c65c40c2a..be314164c 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -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(); 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 { 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..6d5c87560 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,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; programStatus: { setBlocked: ReturnType }; disposeActiveSelector: () => void; toggleToolOutputExpansion: () => void; @@ -18,11 +26,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 +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( + (_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.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(); From 80d4d3c869016ed9c61d16a88e5eb5e0e56186a9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Petr=20Hou=C5=A1ka?= Date: Thu, 5 Nov 2026 23:30:54 +0100 Subject: [PATCH 2/2] fix(coding-agent): remove all extension overlays on /reload and session switch (#11853) ## Problem `InteractiveMode.resetExtensionUI()` runs on `/reload` and before a session is invalidated (session switch, new session, fork). For overlays it called `this.ui.hideOverlay()` once, which pops only the topmost entry of the TUI overlay stack. Trace: an extension opens two overlays with `ctx.ui.custom({ overlay: true })`, then the user runs `/reload`. Reset removes the upper overlay; the lower one stays on screen, gets focus back and keeps receiving input, although the extension instance that created it is gone. A hidden overlay (`handle.setHidden(true)`) also stays in the stack and can be shown again by stale code. Blindly clearing the whole stack is not correct either: pi opens its own overlays (the header logo easter egg in `components/easter-egg-3d.ts`). With the old code, a reset while the easter egg was topmost removed the easter egg and left the extension overlay. ## Fix `showExtensionCustom` records each overlay it opens in `extensionOverlays` (handle -> removal function). The same function serves `done()` and `resetExtensionUI()`: it hides the overlay, clears its blocked program status (#11851) and disposes its component. Deleting the map entry first makes a second call a no-op, so a late `done()` after reset does not dispose twice. Reset calls it for every recorded overlay, newest first, including hidden ones. Overlays not opened by extensions are left alone. This replaces the separate `blockingOverlaySources` set and reset loop added in #11851. Pending `ctx.ui.custom()` promises of removed overlays stay unsettled, matching how reset already treats inline dialogs. ## Testing - New regression test in `test/interactive-mode-status.test.ts`: a pi-internal overlay plus three extension overlays (one hidden), then reset. Checks that the extension overlays are gone and disposed, the internal overlay remains and receives input. Fails without the fix. - `test/interactive-mode-status.test.ts`, `test/interactive-mode-extension-dialogs.test.ts`, `test/interactive-mode-paste.test.ts`, `test/suite/regressions/5943-session-start-notify.test.ts` pass. - `npm run check` clean. --- packages/coding-agent/CHANGELOG.md | 1 + .../src/modes/interactive/interactive-mode.ts | 55 ++++++++------ ...interactive-mode-extension-dialogs.test.ts | 11 +-- .../test/interactive-mode-status.test.ts | 76 ++++++++++++++++++- 4 files changed, 113 insertions(+), 30 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 37efbc3ae..223e7f851 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -36,6 +36,7 @@ - Fixed the read tool loading whole files into memory, which ran out of memory on large files even with `offset`/`limit`; it now streams the file and keeps only the shown lines. Custom `ReadOperations` can stream too by providing the new optional `createReadStream` ([#9654](https://github.com/earendil-works/pi/issues/9654)) - 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 `/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/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index be314164c..88ef9f643 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -578,8 +578,11 @@ export class InteractiveMode { 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(); + /** + * 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; @@ -2587,10 +2590,9 @@ export class InteractiveMode { this.extensionDialogs = []; 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(); + // 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); @@ -3090,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) overlayHandle?.hide(); - else restoreEditor(); - if (overlayStatusSource) { - this.blockingOverlaySources.delete(overlayStatusSource); - this.programStatus.setBlocked(overlayStatusSource, undefined); - } - // Note: both branches above already call requestRender - resolve(result); + const disposeComponent = () => { try { component?.dispose?.(); } catch { @@ -3109,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; @@ -3128,10 +3141,8 @@ 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); - } + 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 6d5c87560..7ed53b38d 100644 --- a/packages/coding-agent/test/interactive-mode-extension-dialogs.test.ts +++ b/packages/coding-agent/test/interactive-mode-extension-dialogs.test.ts @@ -12,11 +12,10 @@ type DialogContext = { setFocus: (component: Component) => void; requestRender: () => void; showOverlay: (component: Component) => { hide: () => void; setHidden: (hidden: boolean) => void }; - hideOverlay: () => void; }; overlays: Component[]; nextBlockingOverlayId: number; - blockingOverlaySources: Set; + extensionOverlays: Map void>; programStatus: { setBlocked: ReturnType }; disposeActiveSelector: () => void; toggleToolOutputExpansion: () => void; @@ -66,13 +65,10 @@ function createContext(): DialogContext & DialogMethods { setHidden: vi.fn(), }; }, - hideOverlay: () => { - context.overlays.pop(); - }, }, overlays: [], nextBlockingOverlayId: 0, - blockingOverlaySources: new Set(), + extensionOverlays: new Map(), programStatus: { setBlocked: vi.fn() }, disposeActiveSelector: vi.fn(), toggleToolOutputExpansion: vi.fn(), @@ -292,7 +288,8 @@ describe("InteractiveMode extension dialogs", () => { context.resetExtensionUI(); expect(context.programStatus.setBlocked).toHaveBeenLastCalledWith("extension-overlay:0", undefined); - expect(context.blockingOverlaySources.size).toBe(0); + 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. 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", () => {