diff --git a/packages/coding-agent/docs/tui.md b/packages/coding-agent/docs/tui.md index 704a78633..c3fb30939 100644 --- a/packages/coding-agent/docs/tui.md +++ b/packages/coding-agent/docs/tui.md @@ -135,6 +135,8 @@ 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. + 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. Focused overlays retain input ownership across ordinary renders. If another component should receive input while an overlay remains visible, explicitly release or redirect focus through the handle. diff --git a/packages/coding-agent/src/core/extensions/types.ts b/packages/coding-agent/src/core/extensions/types.ts index b29e2d4b0..8bb7fbb5e 100644 --- a/packages/coding-agent/src/core/extensions/types.ts +++ b/packages/coding-agent/src/core/extensions/types.ts @@ -52,6 +52,7 @@ import type { KeyId, OverlayHandle, OverlayOptions, + ProgramStatus, TUI, } from "@earendil-works/pi-tui"; import type { Static, TSchema } from "typebox"; @@ -224,6 +225,11 @@ export interface ExtensionUIContext { overlayOptions?: OverlayOptions | (() => OverlayOptions); /** 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. + */ + blocked?: { kind: NonNullable; message: string }; }, ): Promise; diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index 7b3b45ba9..f6b0f7a58 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -2939,6 +2939,7 @@ export class InteractiveMode { overlay?: boolean; overlayOptions?: OverlayOptions | (() => OverlayOptions); onHandle?: (handle: OverlayHandle) => void; + blocked?: BlockedStatus; }, ): Promise { const savedText = this.editor.getText(); @@ -2992,7 +2993,7 @@ export class InteractiveMode { // Expose handle to caller for visibility control options?.onHandle?.(overlayHandle); } else { - this.pushExtensionDialog(c); + this.pushExtensionDialog(c, options?.blocked); } }) .catch((err) => { 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 2a59f9189..91a9d1966 100644 --- a/packages/coding-agent/test/interactive-mode-extension-dialogs.test.ts +++ b/packages/coding-agent/test/interactive-mode-extension-dialogs.test.ts @@ -18,6 +18,7 @@ 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 } }, ): Promise; setCustomEditorComponent( factory: ((tui: unknown, theme: unknown, keybindings: unknown) => Component) | undefined, @@ -153,6 +154,43 @@ describe("InteractiveMode extension dialogs", () => { expect(context.focused).toBe(context.editor); }); + // Regression for #11428: inline custom dialogs could not report blocked program status. + it("reports blocked status for an inline custom component that opts in", async () => { + const context = createContext(); + const dones: Array<(result: string) => void> = []; + const first = context.showExtensionCustom( + (_tui, _theme, _keybindings, done) => { + dones.push(done); + return new Text("approval"); + }, + { blocked: { kind: "permission", message: "Approve?" } }, + ); + await vi.waitFor(() => expect(visible(context)).toContain("approval")); + expect(context.programStatus.setBlocked).toHaveBeenLastCalledWith("extension-dialog", { + kind: "permission", + message: "Approve?", + }); + + const loader = context.showExtensionCustom((_tui, _theme, _keybindings, done) => { + dones.push(done); + return new Text("loading"); + }); + await vi.waitFor(() => expect(visible(context)).toContain("loading")); + expect(context.programStatus.setBlocked).toHaveBeenLastCalledWith("extension-dialog", undefined); + + dones[1]("loaded"); + await expect(loader).resolves.toBe("loaded"); + expect(context.programStatus.setBlocked).toHaveBeenLastCalledWith("extension-dialog", { + kind: "permission", + message: "Approve?", + }); + + dones[0]("yes"); + await expect(first).resolves.toBe("yes"); + expect(context.focused).toBe(context.editor); + expect(context.programStatus.setBlocked).toHaveBeenLastCalledWith("extension-dialog", undefined); + }); + // 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();