From c1810bf70f5e75558f12bdbff51bf94921ae686b Mon Sep 17 00:00:00 2001 From: "robopipi[bot]" <336283572+robopipi[bot]@users.noreply.github.com> Date: Tue, 27 Oct 2026 09:27:28 +0100 Subject: [PATCH] feat(coding-agent): let inline ctx.ui.custom() components report blocked program status (#11471) Inline `ctx.ui.custom()` components never report pi as blocked through OSC 7501 program status. This is on purpose, because the same API hosts loaders. As a result, a custom dialog that waits for the user (an approval prompt, the `question.ts` example) shows up as `working`, and the terminal cannot tell that pi needs input. The extension dialog stack already stores a blocked status for each dialog and reports the visible one. This change adds an optional `blocked` option to `ctx.ui.custom()`: ```ts ctx.ui.custom(factory, { blocked: { kind: "question", message: "Pick a color" } }); ``` The inline branch of `showExtensionCustom()` passes it to `pushExtensionDialog()`. Components without the option behave as before. Overlays ignore the option because they are not part of the dialog stack. `docs/tui.md` documents the option. Testing: - Added a test to `test/interactive-mode-extension-dialogs.test.ts`: - A custom component that opts in reports `blocked`. - A plain custom component stacked on top of it clears the blocked status. - Closing the plain component restores the blocked status. - Closing the opted-in component clears it. - The test fails without the change to `interactive-mode.ts` and passes with it. - `npm run check`: biome and lint pass, and there are no type errors in coding-agent. The only errors are existing type errors in `packages/ai/test` from locally generated model data. They are not related to this change. Fixes #11428 Requested by @badlogic Co-authored-by: triagent --- packages/coding-agent/docs/tui.md | 2 + .../coding-agent/src/core/extensions/types.ts | 6 +++ .../src/modes/interactive/interactive-mode.ts | 3 +- ...interactive-mode-extension-dialogs.test.ts | 38 +++++++++++++++++++ 4 files changed, 48 insertions(+), 1 deletion(-) 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();