mirror of
https://github.com/badlogic/pi-mono.git
synced 2026-11-05 23:37:25 +00:00
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:
parent
a6a7bac868
commit
c1810bf70f
4 changed files with 48 additions and 1 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue