mirror of
https://github.com/badlogic/pi-mono.git
synced 2026-11-05 23:37:25 +00:00
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.
This commit is contained in:
parent
36142c1c7e
commit
7f47a2ed09
5 changed files with 129 additions and 5 deletions
|
|
@ -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 <provider>` 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 <provider>` 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
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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<ProgramStatus["kind"]>; message: string };
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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<string>();
|
||||
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 {
|
||||
|
|
|
|||
|
|
@ -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<string>;
|
||||
programStatus: { setBlocked: ReturnType<typeof vi.fn> };
|
||||
disposeActiveSelector: () => void;
|
||||
toggleToolOutputExpansion: () => void;
|
||||
|
|
@ -18,11 +26,16 @@ 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 } },
|
||||
options?: {
|
||||
overlay?: boolean;
|
||||
onHandle?: (handle: { setHidden: (hidden: boolean) => void }) => void;
|
||||
blocked?: { kind: "permission" | "question" | "auth"; message: string };
|
||||
},
|
||||
): Promise<T>;
|
||||
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<string>(
|
||||
(_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<string>(() => 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();
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue