diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 4e085b8f..9e41a07d 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Added + +- Extension UI dialogs (`ctx.ui.select()`, `ctx.ui.confirm()`, `ctx.ui.input()`) now accept an optional `AbortSignal` to programmatically dismiss dialogs. Useful for implementing timeouts. See `examples/extensions/timed-confirm.ts`. ([#474](https://github.com/badlogic/pi-mono/issues/474)) + ## [0.37.5] - 2026-01-06 ### Added diff --git a/packages/coding-agent/docs/extensions.md b/packages/coding-agent/docs/extensions.md index 5c0a3362..b8bdc60d 100644 --- a/packages/coding-agent/docs/extensions.md +++ b/packages/coding-agent/docs/extensions.md @@ -1094,6 +1094,38 @@ ctx.ui.notify("Done!", "info"); // "info" | "warning" | "error" - `ctx.ui.editor()`: [handoff.ts](../examples/extensions/handoff.ts) - `ctx.ui.setEditorText()`: [handoff.ts](../examples/extensions/handoff.ts), [qna.ts](../examples/extensions/qna.ts) +#### Auto-Dismissing Dialogs + +Dialogs can be programmatically dismissed using `AbortSignal`. This is useful for implementing timeouts: + +```typescript +const controller = new AbortController(); +const timeoutId = setTimeout(() => controller.abort(), 5000); + +const confirmed = await ctx.ui.confirm( + "Timed Confirmation", + "This dialog will auto-cancel in 5 seconds. Confirm?", + { signal: controller.signal } +); + +clearTimeout(timeoutId); + +if (confirmed) { + // User confirmed +} else if (controller.signal.aborted) { + // Dialog timed out +} else { + // User cancelled (pressed Escape or selected "No") +} +``` + +**Return values on abort:** +- `select()` returns `undefined` +- `confirm()` returns `false` +- `input()` returns `undefined` + +See [examples/extensions/timed-confirm.ts](../examples/extensions/timed-confirm.ts) for a complete example. + ### Widgets, Status, and Footer ```typescript diff --git a/packages/coding-agent/examples/extensions/README.md b/packages/coding-agent/examples/extensions/README.md index 80909b41..91ce0918 100644 --- a/packages/coding-agent/examples/extensions/README.md +++ b/packages/coding-agent/examples/extensions/README.md @@ -44,6 +44,7 @@ cp permission-gate.ts ~/.pi/agent/extensions/ | `status-line.ts` | Shows turn progress in footer via `ctx.ui.setStatus()` with themed colors | | `snake.ts` | Snake game with custom UI, keyboard handling, and session persistence | | `send-user-message.ts` | Demonstrates `pi.sendUserMessage()` for sending user messages from extensions | +| `timed-confirm.ts` | Demonstrates AbortSignal for auto-dismissing `ctx.ui.confirm()` and `ctx.ui.select()` dialogs | ### Git Integration diff --git a/packages/coding-agent/examples/extensions/timed-confirm.ts b/packages/coding-agent/examples/extensions/timed-confirm.ts new file mode 100644 index 00000000..138996c2 --- /dev/null +++ b/packages/coding-agent/examples/extensions/timed-confirm.ts @@ -0,0 +1,63 @@ +/** + * Example extension demonstrating AbortSignal for auto-dismissing dialogs. + * + * Commands: + * - /timed - Shows confirm dialog that auto-cancels after 5 seconds + * - /timed-select - Shows select dialog that auto-cancels after 10 seconds + */ + +import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; + +export default function (pi: ExtensionAPI) { + pi.registerCommand("timed", { + description: "Show a timed confirmation dialog (auto-cancels in 5s)", + handler: async (_args, ctx) => { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 5000); + + ctx.ui.notify("Dialog will auto-cancel in 5 seconds...", "info"); + + const confirmed = await ctx.ui.confirm( + "Timed Confirmation", + "This dialog will auto-cancel in 5 seconds. Confirm?", + { signal: controller.signal }, + ); + + clearTimeout(timeoutId); + + if (confirmed) { + ctx.ui.notify("Confirmed by user!", "info"); + } else if (controller.signal.aborted) { + ctx.ui.notify("Dialog timed out (auto-cancelled)", "warning"); + } else { + ctx.ui.notify("Cancelled by user", "info"); + } + }, + }); + + pi.registerCommand("timed-select", { + description: "Show a timed select dialog (auto-cancels in 10s)", + handler: async (_args, ctx) => { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 10000); + + ctx.ui.notify("Select dialog will auto-cancel in 10 seconds...", "info"); + + const choice = await ctx.ui.select( + "Pick an option (auto-cancels in 10s)", + ["Option A", "Option B", "Option C"], + { signal: controller.signal }, + ); + + clearTimeout(timeoutId); + + if (choice) { + ctx.ui.notify(`Selected: ${choice}`, "info"); + } else if (controller.signal.aborted) { + ctx.ui.notify("Selection timed out", "warning"); + } else { + ctx.ui.notify("Selection cancelled", "info"); + } + }, + }); +} diff --git a/packages/coding-agent/src/core/extensions/types.ts b/packages/coding-agent/src/core/extensions/types.ts index ad7b8540..305e711c 100644 --- a/packages/coding-agent/src/core/extensions/types.ts +++ b/packages/coding-agent/src/core/extensions/types.ts @@ -52,13 +52,13 @@ export type { AgentToolResult, AgentToolUpdateCallback }; */ export interface ExtensionUIContext { /** Show a selector and return the user's choice. */ - select(title: string, options: string[]): Promise; + select(title: string, options: string[], opts?: { signal?: AbortSignal }): Promise; /** Show a confirmation dialog. */ - confirm(title: string, message: string): Promise; + confirm(title: string, message: string, opts?: { signal?: AbortSignal }): Promise; /** Show a text input dialog. */ - input(title: string, placeholder?: string): Promise; + input(title: string, placeholder?: string, opts?: { signal?: AbortSignal }): Promise; /** Show a notification to the user. */ notify(message: string, type?: "info" | "warning" | "error"): void; diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index 9f439cc0..fb8fedb4 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -739,9 +739,9 @@ export class InteractiveMode { */ private createExtensionUIContext(): ExtensionUIContext { return { - select: (title, options) => this.showExtensionSelector(title, options), - confirm: (title, message) => this.showExtensionConfirm(title, message), - input: (title, placeholder) => this.showExtensionInput(title, placeholder), + select: (title, options, opts) => this.showExtensionSelector(title, options, opts), + confirm: (title, message, opts) => this.showExtensionConfirm(title, message, opts), + input: (title, placeholder, opts) => this.showExtensionInput(title, placeholder, opts), notify: (message, type) => this.showExtensionNotify(message, type), setStatus: (key, text) => this.setExtensionStatus(key, text), setWidget: (key, content) => this.setExtensionWidget(key, content), @@ -761,16 +761,33 @@ export class InteractiveMode { /** * Show a selector for extensions. */ - private showExtensionSelector(title: string, options: string[]): Promise { + private showExtensionSelector( + title: string, + options: string[], + opts?: { signal?: AbortSignal }, + ): Promise { return new Promise((resolve) => { + if (opts?.signal?.aborted) { + resolve(undefined); + return; + } + + const onAbort = () => { + this.hideExtensionSelector(); + resolve(undefined); + }; + opts?.signal?.addEventListener("abort", onAbort, { once: true }); + this.extensionSelector = new ExtensionSelectorComponent( title, options, (option) => { + opts?.signal?.removeEventListener("abort", onAbort); this.hideExtensionSelector(); resolve(option); }, () => { + opts?.signal?.removeEventListener("abort", onAbort); this.hideExtensionSelector(); resolve(undefined); }, @@ -797,24 +814,45 @@ export class InteractiveMode { /** * Show a confirmation dialog for extensions. */ - private async showExtensionConfirm(title: string, message: string): Promise { - const result = await this.showExtensionSelector(`${title}\n${message}`, ["Yes", "No"]); + private async showExtensionConfirm( + title: string, + message: string, + opts?: { signal?: AbortSignal }, + ): Promise { + const result = await this.showExtensionSelector(`${title}\n${message}`, ["Yes", "No"], opts); return result === "Yes"; } /** * Show a text input for extensions. */ - private showExtensionInput(title: string, placeholder?: string): Promise { + private showExtensionInput( + title: string, + placeholder?: string, + opts?: { signal?: AbortSignal }, + ): Promise { return new Promise((resolve) => { + if (opts?.signal?.aborted) { + resolve(undefined); + return; + } + + const onAbort = () => { + this.hideExtensionInput(); + resolve(undefined); + }; + opts?.signal?.addEventListener("abort", onAbort, { once: true }); + this.extensionInput = new ExtensionInputComponent( title, placeholder, (value) => { + opts?.signal?.removeEventListener("abort", onAbort); this.hideExtensionInput(); resolve(value); }, () => { + opts?.signal?.removeEventListener("abort", onAbort); this.hideExtensionInput(); resolve(undefined); }, diff --git a/packages/coding-agent/src/modes/rpc/rpc-mode.ts b/packages/coding-agent/src/modes/rpc/rpc-mode.ts index 3fd7690b..7c2668b3 100644 --- a/packages/coding-agent/src/modes/rpc/rpc-mode.ts +++ b/packages/coding-agent/src/modes/rpc/rpc-mode.ts @@ -67,11 +67,22 @@ export async function runRpcMode(session: AgentSession): Promise { * Create an extension UI context that uses the RPC protocol. */ const createExtensionUIContext = (): ExtensionUIContext => ({ - async select(title: string, options: string[]): Promise { + async select(title: string, options: string[], opts?: { signal?: AbortSignal }): Promise { + if (opts?.signal?.aborted) { + return undefined; + } + const id = crypto.randomUUID(); return new Promise((resolve, reject) => { + const onAbort = () => { + pendingExtensionRequests.delete(id); + resolve(undefined); + }; + opts?.signal?.addEventListener("abort", onAbort, { once: true }); + pendingExtensionRequests.set(id, { resolve: (response: RpcExtensionUIResponse) => { + opts?.signal?.removeEventListener("abort", onAbort); if ("cancelled" in response && response.cancelled) { resolve(undefined); } else if ("value" in response) { @@ -86,11 +97,22 @@ export async function runRpcMode(session: AgentSession): Promise { }); }, - async confirm(title: string, message: string): Promise { + async confirm(title: string, message: string, opts?: { signal?: AbortSignal }): Promise { + if (opts?.signal?.aborted) { + return false; + } + const id = crypto.randomUUID(); return new Promise((resolve, reject) => { + const onAbort = () => { + pendingExtensionRequests.delete(id); + resolve(false); + }; + opts?.signal?.addEventListener("abort", onAbort, { once: true }); + pendingExtensionRequests.set(id, { resolve: (response: RpcExtensionUIResponse) => { + opts?.signal?.removeEventListener("abort", onAbort); if ("cancelled" in response && response.cancelled) { resolve(false); } else if ("confirmed" in response) { @@ -105,11 +127,22 @@ export async function runRpcMode(session: AgentSession): Promise { }); }, - async input(title: string, placeholder?: string): Promise { + async input(title: string, placeholder?: string, opts?: { signal?: AbortSignal }): Promise { + if (opts?.signal?.aborted) { + return undefined; + } + const id = crypto.randomUUID(); return new Promise((resolve, reject) => { + const onAbort = () => { + pendingExtensionRequests.delete(id); + resolve(undefined); + }; + opts?.signal?.addEventListener("abort", onAbort, { once: true }); + pendingExtensionRequests.set(id, { resolve: (response: RpcExtensionUIResponse) => { + opts?.signal?.removeEventListener("abort", onAbort); if ("cancelled" in response && response.cancelled) { resolve(undefined); } else if ("value" in response) {