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 <triagent@localhost>
This commit is contained in:
robopipi[bot] 2026-10-27 09:27:28 +01:00 • committed by GitHub
parent a6a7bac868
commit c1810bf70f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 48 additions and 1 deletions

View file

@ -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.

View file

@ -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<ProgramStatus["kind"]>; message: string };
},
): Promise<T>;

View file

@ -2939,6 +2939,7 @@ export class InteractiveMode {
overlay?: boolean;
overlayOptions?: OverlayOptions | (() => OverlayOptions);
onHandle?: (handle: OverlayHandle) => void;
blocked?: BlockedStatus;
},
): Promise<T> {
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) => {

View file

@ -18,6 +18,7 @@ type DialogMethods = {
showExtensionConfirm(title: string, message: string, opts?: { signal?: AbortSignal }): Promise<boolean>;
showExtensionCustom<T>(
factory: (tui: unknown, theme: unknown, keybindings: unknown, done: (result: T) => void) => Component,
options?: { blocked?: { kind: "permission" | "question" | "auth"; message: string } },
): Promise<T>;
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<string>(
(_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<string>((_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();