mirror of
https://github.com/getcompanion-ai/co-mono.git
synced 2026-04-15 19:05:11 +00:00
feat(coding-agent): add timeout option to extension dialogs with live countdown
Extension UI dialogs (select, confirm, input) now support a timeout option that auto-dismisses with a live countdown display. Simpler alternative to manually managing AbortSignal for timed dialogs. Also adds ExtensionUIDialogOptions type export and updates RPC mode to forward timeout to clients.
This commit is contained in:
parent
fcb3b4aa72
commit
77477f6166
12 changed files with 262 additions and 191 deletions
|
|
@ -2,6 +2,10 @@
|
|||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
|
||||
- Extension UI dialogs (`ctx.ui.select()`, `ctx.ui.confirm()`, `ctx.ui.input()`) now support a `timeout` option that auto-dismisses the dialog with a live countdown display. Simpler alternative to `AbortSignal` for timed dialogs.
|
||||
|
||||
## [0.37.8] - 2026-01-07
|
||||
|
||||
## [0.37.7] - 2026-01-07
|
||||
|
|
|
|||
|
|
@ -1094,9 +1094,33 @@ 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
|
||||
#### Timed Dialogs with Countdown
|
||||
|
||||
Dialogs can be programmatically dismissed using `AbortSignal`. This is useful for implementing timeouts:
|
||||
Dialogs support a `timeout` option that auto-dismisses with a live countdown display:
|
||||
|
||||
```typescript
|
||||
// Dialog shows "Title (5s)" → "Title (4s)" → ... → auto-dismisses at 0
|
||||
const confirmed = await ctx.ui.confirm(
|
||||
"Timed Confirmation",
|
||||
"This dialog will auto-cancel in 5 seconds. Confirm?",
|
||||
{ timeout: 5000 }
|
||||
);
|
||||
|
||||
if (confirmed) {
|
||||
// User confirmed
|
||||
} else {
|
||||
// User cancelled or timed out
|
||||
}
|
||||
```
|
||||
|
||||
**Return values on timeout:**
|
||||
- `select()` returns `undefined`
|
||||
- `confirm()` returns `false`
|
||||
- `input()` returns `undefined`
|
||||
|
||||
#### Manual Dismissal with AbortSignal
|
||||
|
||||
For more control (e.g., to distinguish timeout from user cancel), use `AbortSignal`:
|
||||
|
||||
```typescript
|
||||
const controller = new AbortController();
|
||||
|
|
@ -1119,12 +1143,7 @@ if (confirmed) {
|
|||
}
|
||||
```
|
||||
|
||||
**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.
|
||||
See [examples/extensions/timed-confirm.ts](../examples/extensions/timed-confirm.ts) for complete examples.
|
||||
|
||||
### Widgets, Status, and Footer
|
||||
|
||||
|
|
|
|||
|
|
@ -1,16 +1,49 @@
|
|||
/**
|
||||
* Example extension demonstrating AbortSignal for auto-dismissing dialogs.
|
||||
* Example extension demonstrating timed dialogs with live countdown.
|
||||
*
|
||||
* Commands:
|
||||
* - /timed - Shows confirm dialog that auto-cancels after 5 seconds
|
||||
* - /timed-select - Shows select dialog that auto-cancels after 10 seconds
|
||||
* - /timed - Shows confirm dialog that auto-cancels after 5 seconds with countdown
|
||||
* - /timed-select - Shows select dialog that auto-cancels after 10 seconds with countdown
|
||||
* - /timed-signal - Shows confirm using AbortSignal (manual approach)
|
||||
*/
|
||||
|
||||
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
||||
|
||||
export default function (pi: ExtensionAPI) {
|
||||
// Simple approach: use timeout option (recommended)
|
||||
pi.registerCommand("timed", {
|
||||
description: "Show a timed confirmation dialog (auto-cancels in 5s)",
|
||||
description: "Show a timed confirmation dialog (auto-cancels in 5s with countdown)",
|
||||
handler: async (_args, ctx) => {
|
||||
const confirmed = await ctx.ui.confirm(
|
||||
"Timed Confirmation",
|
||||
"This dialog will auto-cancel in 5 seconds. Confirm?",
|
||||
{ timeout: 5000 },
|
||||
);
|
||||
|
||||
if (confirmed) {
|
||||
ctx.ui.notify("Confirmed by user!", "info");
|
||||
} else {
|
||||
ctx.ui.notify("Cancelled or timed out", "info");
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
pi.registerCommand("timed-select", {
|
||||
description: "Show a timed select dialog (auto-cancels in 10s with countdown)",
|
||||
handler: async (_args, ctx) => {
|
||||
const choice = await ctx.ui.select("Pick an option", ["Option A", "Option B", "Option C"], { timeout: 10000 });
|
||||
|
||||
if (choice) {
|
||||
ctx.ui.notify(`Selected: ${choice}`, "info");
|
||||
} else {
|
||||
ctx.ui.notify("Selection cancelled or timed out", "info");
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
// Manual approach: use AbortSignal for more control
|
||||
pi.registerCommand("timed-signal", {
|
||||
description: "Show a timed confirm using AbortSignal (manual approach)",
|
||||
handler: async (_args, ctx) => {
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), 5000);
|
||||
|
|
@ -34,30 +67,4 @@ export default function (pi: ExtensionAPI) {
|
|||
}
|
||||
},
|
||||
});
|
||||
|
||||
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");
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -36,6 +36,7 @@ export type {
|
|||
ExtensionHandler,
|
||||
ExtensionShortcut,
|
||||
ExtensionUIContext,
|
||||
ExtensionUIDialogOptions,
|
||||
FindToolResultEvent,
|
||||
GetActiveToolsHandler,
|
||||
GetAllToolsHandler,
|
||||
|
|
|
|||
|
|
@ -46,19 +46,27 @@ export type { AgentToolResult, AgentToolUpdateCallback };
|
|||
// UI Context
|
||||
// ============================================================================
|
||||
|
||||
/** Options for extension UI dialogs. */
|
||||
export interface ExtensionUIDialogOptions {
|
||||
/** AbortSignal to programmatically dismiss the dialog. */
|
||||
signal?: AbortSignal;
|
||||
/** Timeout in milliseconds. Dialog auto-dismisses with live countdown display. */
|
||||
timeout?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* UI context for extensions to request interactive UI.
|
||||
* Each mode (interactive, RPC, print) provides its own implementation.
|
||||
*/
|
||||
export interface ExtensionUIContext {
|
||||
/** Show a selector and return the user's choice. */
|
||||
select(title: string, options: string[], opts?: { signal?: AbortSignal }): Promise<string | undefined>;
|
||||
select(title: string, options: string[], opts?: ExtensionUIDialogOptions): Promise<string | undefined>;
|
||||
|
||||
/** Show a confirmation dialog. */
|
||||
confirm(title: string, message: string, opts?: { signal?: AbortSignal }): Promise<boolean>;
|
||||
confirm(title: string, message: string, opts?: ExtensionUIDialogOptions): Promise<boolean>;
|
||||
|
||||
/** Show a text input dialog. */
|
||||
input(title: string, placeholder?: string, opts?: { signal?: AbortSignal }): Promise<string | undefined>;
|
||||
input(title: string, placeholder?: string, opts?: ExtensionUIDialogOptions): Promise<string | undefined>;
|
||||
|
||||
/** Show a notification to the user. */
|
||||
notify(message: string, type?: "info" | "warning" | "error"): void;
|
||||
|
|
|
|||
|
|
@ -54,6 +54,7 @@ export type {
|
|||
ExtensionHandler,
|
||||
ExtensionShortcut,
|
||||
ExtensionUIContext,
|
||||
ExtensionUIDialogOptions,
|
||||
LoadExtensionsResult,
|
||||
LoadedExtension,
|
||||
MessageRenderer,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,38 @@
|
|||
/**
|
||||
* Reusable countdown timer for dialog components.
|
||||
*/
|
||||
|
||||
import type { TUI } from "@mariozechner/pi-tui";
|
||||
|
||||
export class CountdownTimer {
|
||||
private intervalId: ReturnType<typeof setInterval> | undefined;
|
||||
private remainingSeconds: number;
|
||||
|
||||
constructor(
|
||||
timeoutMs: number,
|
||||
private tui: TUI | undefined,
|
||||
private onTick: (seconds: number) => void,
|
||||
private onExpire: () => void,
|
||||
) {
|
||||
this.remainingSeconds = Math.ceil(timeoutMs / 1000);
|
||||
this.onTick(this.remainingSeconds);
|
||||
|
||||
this.intervalId = setInterval(() => {
|
||||
this.remainingSeconds--;
|
||||
this.onTick(this.remainingSeconds);
|
||||
this.tui?.requestRender();
|
||||
|
||||
if (this.remainingSeconds <= 0) {
|
||||
this.dispose();
|
||||
this.onExpire();
|
||||
}
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
if (this.intervalId) {
|
||||
clearInterval(this.intervalId);
|
||||
this.intervalId = undefined;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -2,64 +2,73 @@
|
|||
* Simple text input component for extensions.
|
||||
*/
|
||||
|
||||
import { Container, getEditorKeybindings, Input, Spacer, Text } from "@mariozechner/pi-tui";
|
||||
import { Container, getEditorKeybindings, Input, Spacer, Text, type TUI } from "@mariozechner/pi-tui";
|
||||
import { theme } from "../theme/theme.js";
|
||||
import { CountdownTimer } from "./countdown-timer.js";
|
||||
import { DynamicBorder } from "./dynamic-border.js";
|
||||
|
||||
export interface ExtensionInputOptions {
|
||||
tui?: TUI;
|
||||
timeout?: number;
|
||||
}
|
||||
|
||||
export class ExtensionInputComponent extends Container {
|
||||
private input: Input;
|
||||
private onSubmitCallback: (value: string) => void;
|
||||
private onCancelCallback: () => void;
|
||||
private titleText: Text;
|
||||
private baseTitle: string;
|
||||
private countdown: CountdownTimer | undefined;
|
||||
|
||||
constructor(
|
||||
title: string,
|
||||
_placeholder: string | undefined,
|
||||
onSubmit: (value: string) => void,
|
||||
onCancel: () => void,
|
||||
opts?: ExtensionInputOptions,
|
||||
) {
|
||||
super();
|
||||
|
||||
this.onSubmitCallback = onSubmit;
|
||||
this.onCancelCallback = onCancel;
|
||||
this.baseTitle = title;
|
||||
|
||||
// Add top border
|
||||
this.addChild(new DynamicBorder());
|
||||
this.addChild(new Spacer(1));
|
||||
|
||||
// Add title
|
||||
this.addChild(new Text(theme.fg("accent", title), 1, 0));
|
||||
this.titleText = new Text(theme.fg("accent", title), 1, 0);
|
||||
this.addChild(this.titleText);
|
||||
this.addChild(new Spacer(1));
|
||||
|
||||
// Create input
|
||||
if (opts?.timeout && opts.timeout > 0 && opts.tui) {
|
||||
this.countdown = new CountdownTimer(
|
||||
opts.timeout,
|
||||
opts.tui,
|
||||
(s) => this.titleText.setText(theme.fg("accent", `${this.baseTitle} (${s}s)`)),
|
||||
() => this.onCancelCallback(),
|
||||
);
|
||||
}
|
||||
|
||||
this.input = new Input();
|
||||
this.addChild(this.input);
|
||||
|
||||
this.addChild(new Spacer(1));
|
||||
|
||||
// Add hint
|
||||
this.addChild(new Text(theme.fg("dim", "enter submit esc cancel"), 1, 0));
|
||||
|
||||
this.addChild(new Spacer(1));
|
||||
|
||||
// Add bottom border
|
||||
this.addChild(new DynamicBorder());
|
||||
}
|
||||
|
||||
handleInput(keyData: string): void {
|
||||
const kb = getEditorKeybindings();
|
||||
// Enter
|
||||
if (kb.matches(keyData, "selectConfirm") || keyData === "\n") {
|
||||
this.onSubmitCallback(this.input.getValue());
|
||||
return;
|
||||
}
|
||||
|
||||
// Escape or Ctrl+C to cancel
|
||||
if (kb.matches(keyData, "selectCancel")) {
|
||||
} else if (kb.matches(keyData, "selectCancel")) {
|
||||
this.onCancelCallback();
|
||||
return;
|
||||
} else {
|
||||
this.input.handleInput(keyData);
|
||||
}
|
||||
}
|
||||
|
||||
// Forward to input
|
||||
this.input.handleInput(keyData);
|
||||
dispose(): void {
|
||||
this.countdown?.dispose();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,90 +3,94 @@
|
|||
* Displays a list of string options with keyboard navigation.
|
||||
*/
|
||||
|
||||
import { Container, getEditorKeybindings, Spacer, Text } from "@mariozechner/pi-tui";
|
||||
import { Container, getEditorKeybindings, Spacer, Text, type TUI } from "@mariozechner/pi-tui";
|
||||
import { theme } from "../theme/theme.js";
|
||||
import { CountdownTimer } from "./countdown-timer.js";
|
||||
import { DynamicBorder } from "./dynamic-border.js";
|
||||
|
||||
export interface ExtensionSelectorOptions {
|
||||
tui?: TUI;
|
||||
timeout?: number;
|
||||
}
|
||||
|
||||
export class ExtensionSelectorComponent extends Container {
|
||||
private options: string[];
|
||||
private selectedIndex = 0;
|
||||
private listContainer: Container;
|
||||
private onSelectCallback: (option: string) => void;
|
||||
private onCancelCallback: () => void;
|
||||
private titleText: Text;
|
||||
private baseTitle: string;
|
||||
private countdown: CountdownTimer | undefined;
|
||||
|
||||
constructor(title: string, options: string[], onSelect: (option: string) => void, onCancel: () => void) {
|
||||
constructor(
|
||||
title: string,
|
||||
options: string[],
|
||||
onSelect: (option: string) => void,
|
||||
onCancel: () => void,
|
||||
opts?: ExtensionSelectorOptions,
|
||||
) {
|
||||
super();
|
||||
|
||||
this.options = options;
|
||||
this.onSelectCallback = onSelect;
|
||||
this.onCancelCallback = onCancel;
|
||||
this.baseTitle = title;
|
||||
|
||||
// Add top border
|
||||
this.addChild(new DynamicBorder());
|
||||
this.addChild(new Spacer(1));
|
||||
|
||||
// Add title
|
||||
this.addChild(new Text(theme.fg("accent", title), 1, 0));
|
||||
this.titleText = new Text(theme.fg("accent", title), 1, 0);
|
||||
this.addChild(this.titleText);
|
||||
this.addChild(new Spacer(1));
|
||||
|
||||
// Create list container
|
||||
if (opts?.timeout && opts.timeout > 0 && opts.tui) {
|
||||
this.countdown = new CountdownTimer(
|
||||
opts.timeout,
|
||||
opts.tui,
|
||||
(s) => this.titleText.setText(theme.fg("accent", `${this.baseTitle} (${s}s)`)),
|
||||
() => this.onCancelCallback(),
|
||||
);
|
||||
}
|
||||
|
||||
this.listContainer = new Container();
|
||||
this.addChild(this.listContainer);
|
||||
|
||||
this.addChild(new Spacer(1));
|
||||
|
||||
// Add hint
|
||||
this.addChild(new Text(theme.fg("dim", "↑↓ navigate enter select esc cancel"), 1, 0));
|
||||
|
||||
this.addChild(new Spacer(1));
|
||||
|
||||
// Add bottom border
|
||||
this.addChild(new DynamicBorder());
|
||||
|
||||
// Initial render
|
||||
this.updateList();
|
||||
}
|
||||
|
||||
private updateList(): void {
|
||||
this.listContainer.clear();
|
||||
|
||||
for (let i = 0; i < this.options.length; i++) {
|
||||
const option = this.options[i];
|
||||
const isSelected = i === this.selectedIndex;
|
||||
|
||||
let text = "";
|
||||
if (isSelected) {
|
||||
text = theme.fg("accent", "→ ") + theme.fg("accent", option);
|
||||
} else {
|
||||
text = ` ${theme.fg("text", option)}`;
|
||||
}
|
||||
|
||||
const text = isSelected
|
||||
? theme.fg("accent", "→ ") + theme.fg("accent", this.options[i])
|
||||
: ` ${theme.fg("text", this.options[i])}`;
|
||||
this.listContainer.addChild(new Text(text, 1, 0));
|
||||
}
|
||||
}
|
||||
|
||||
handleInput(keyData: string): void {
|
||||
const kb = getEditorKeybindings();
|
||||
// Up arrow or k
|
||||
if (kb.matches(keyData, "selectUp") || keyData === "k") {
|
||||
this.selectedIndex = Math.max(0, this.selectedIndex - 1);
|
||||
this.updateList();
|
||||
}
|
||||
// Down arrow or j
|
||||
else if (kb.matches(keyData, "selectDown") || keyData === "j") {
|
||||
} else if (kb.matches(keyData, "selectDown") || keyData === "j") {
|
||||
this.selectedIndex = Math.min(this.options.length - 1, this.selectedIndex + 1);
|
||||
this.updateList();
|
||||
}
|
||||
// Enter
|
||||
else if (kb.matches(keyData, "selectConfirm") || keyData === "\n") {
|
||||
} else if (kb.matches(keyData, "selectConfirm") || keyData === "\n") {
|
||||
const selected = this.options[this.selectedIndex];
|
||||
if (selected) {
|
||||
this.onSelectCallback(selected);
|
||||
}
|
||||
}
|
||||
// Escape or Ctrl+C
|
||||
else if (kb.matches(keyData, "selectCancel")) {
|
||||
if (selected) this.onSelectCallback(selected);
|
||||
} else if (kb.matches(keyData, "selectCancel")) {
|
||||
this.onCancelCallback();
|
||||
}
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this.countdown?.dispose();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -32,6 +32,7 @@ import type {
|
|||
ExtensionContext,
|
||||
ExtensionRunner,
|
||||
ExtensionUIContext,
|
||||
ExtensionUIDialogOptions,
|
||||
LoadedExtension,
|
||||
} from "../../core/extensions/index.js";
|
||||
import { KeybindingsManager } from "../../core/keybindings.js";
|
||||
|
|
@ -764,7 +765,7 @@ export class InteractiveMode {
|
|||
private showExtensionSelector(
|
||||
title: string,
|
||||
options: string[],
|
||||
opts?: { signal?: AbortSignal },
|
||||
opts?: ExtensionUIDialogOptions,
|
||||
): Promise<string | undefined> {
|
||||
return new Promise((resolve) => {
|
||||
if (opts?.signal?.aborted) {
|
||||
|
|
@ -791,6 +792,7 @@ export class InteractiveMode {
|
|||
this.hideExtensionSelector();
|
||||
resolve(undefined);
|
||||
},
|
||||
{ tui: this.ui, timeout: opts?.timeout },
|
||||
);
|
||||
|
||||
this.editorContainer.clear();
|
||||
|
|
@ -804,6 +806,7 @@ export class InteractiveMode {
|
|||
* Hide the extension selector.
|
||||
*/
|
||||
private hideExtensionSelector(): void {
|
||||
this.extensionSelector?.dispose();
|
||||
this.editorContainer.clear();
|
||||
this.editorContainer.addChild(this.editor);
|
||||
this.extensionSelector = undefined;
|
||||
|
|
@ -817,7 +820,7 @@ export class InteractiveMode {
|
|||
private async showExtensionConfirm(
|
||||
title: string,
|
||||
message: string,
|
||||
opts?: { signal?: AbortSignal },
|
||||
opts?: ExtensionUIDialogOptions,
|
||||
): Promise<boolean> {
|
||||
const result = await this.showExtensionSelector(`${title}\n${message}`, ["Yes", "No"], opts);
|
||||
return result === "Yes";
|
||||
|
|
@ -829,7 +832,7 @@ export class InteractiveMode {
|
|||
private showExtensionInput(
|
||||
title: string,
|
||||
placeholder?: string,
|
||||
opts?: { signal?: AbortSignal },
|
||||
opts?: ExtensionUIDialogOptions,
|
||||
): Promise<string | undefined> {
|
||||
return new Promise((resolve) => {
|
||||
if (opts?.signal?.aborted) {
|
||||
|
|
@ -856,6 +859,7 @@ export class InteractiveMode {
|
|||
this.hideExtensionInput();
|
||||
resolve(undefined);
|
||||
},
|
||||
{ tui: this.ui, timeout: opts?.timeout },
|
||||
);
|
||||
|
||||
this.editorContainer.clear();
|
||||
|
|
@ -869,6 +873,7 @@ export class InteractiveMode {
|
|||
* Hide the extension input.
|
||||
*/
|
||||
private hideExtensionInput(): void {
|
||||
this.extensionInput?.dispose();
|
||||
this.editorContainer.clear();
|
||||
this.editorContainer.addChild(this.editor);
|
||||
this.extensionInput = undefined;
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@
|
|||
import * as crypto from "node:crypto";
|
||||
import * as readline from "readline";
|
||||
import type { AgentSession } from "../../core/agent-session.js";
|
||||
import type { ExtensionUIContext } from "../../core/extensions/index.js";
|
||||
import type { ExtensionUIContext, ExtensionUIDialogOptions } from "../../core/extensions/index.js";
|
||||
import { theme } from "../interactive/theme/theme.js";
|
||||
import type {
|
||||
RpcCommand,
|
||||
|
|
@ -63,99 +63,67 @@ export async function runRpcMode(session: AgentSession): Promise<never> {
|
|||
{ resolve: (value: any) => void; reject: (error: Error) => void }
|
||||
>();
|
||||
|
||||
/** Helper for dialog methods with signal/timeout support */
|
||||
function createDialogPromise<T>(
|
||||
opts: ExtensionUIDialogOptions | undefined,
|
||||
defaultValue: T,
|
||||
request: Record<string, unknown>,
|
||||
parseResponse: (response: RpcExtensionUIResponse) => T,
|
||||
): Promise<T> {
|
||||
if (opts?.signal?.aborted) return Promise.resolve(defaultValue);
|
||||
|
||||
const id = crypto.randomUUID();
|
||||
return new Promise((resolve, reject) => {
|
||||
let timeoutId: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
const cleanup = () => {
|
||||
if (timeoutId) clearTimeout(timeoutId);
|
||||
opts?.signal?.removeEventListener("abort", onAbort);
|
||||
pendingExtensionRequests.delete(id);
|
||||
};
|
||||
|
||||
const onAbort = () => {
|
||||
cleanup();
|
||||
resolve(defaultValue);
|
||||
};
|
||||
opts?.signal?.addEventListener("abort", onAbort, { once: true });
|
||||
|
||||
if (opts?.timeout) {
|
||||
timeoutId = setTimeout(() => {
|
||||
cleanup();
|
||||
resolve(defaultValue);
|
||||
}, opts.timeout);
|
||||
}
|
||||
|
||||
pendingExtensionRequests.set(id, {
|
||||
resolve: (response: RpcExtensionUIResponse) => {
|
||||
cleanup();
|
||||
resolve(parseResponse(response));
|
||||
},
|
||||
reject,
|
||||
});
|
||||
output({ type: "extension_ui_request", id, ...request } as RpcExtensionUIRequest);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an extension UI context that uses the RPC protocol.
|
||||
*/
|
||||
const createExtensionUIContext = (): ExtensionUIContext => ({
|
||||
async select(title: string, options: string[], opts?: { signal?: AbortSignal }): Promise<string | undefined> {
|
||||
if (opts?.signal?.aborted) {
|
||||
return undefined;
|
||||
}
|
||||
select: (title, options, opts) =>
|
||||
createDialogPromise(opts, undefined, { method: "select", title, options, timeout: opts?.timeout }, (r) =>
|
||||
"cancelled" in r && r.cancelled ? undefined : "value" in r ? r.value : undefined,
|
||||
),
|
||||
|
||||
const id = crypto.randomUUID();
|
||||
return new Promise((resolve, reject) => {
|
||||
const onAbort = () => {
|
||||
pendingExtensionRequests.delete(id);
|
||||
resolve(undefined);
|
||||
};
|
||||
opts?.signal?.addEventListener("abort", onAbort, { once: true });
|
||||
confirm: (title, message, opts) =>
|
||||
createDialogPromise(opts, false, { method: "confirm", title, message, timeout: opts?.timeout }, (r) =>
|
||||
"cancelled" in r && r.cancelled ? false : "confirmed" in r ? r.confirmed : false,
|
||||
),
|
||||
|
||||
pendingExtensionRequests.set(id, {
|
||||
resolve: (response: RpcExtensionUIResponse) => {
|
||||
opts?.signal?.removeEventListener("abort", onAbort);
|
||||
if ("cancelled" in response && response.cancelled) {
|
||||
resolve(undefined);
|
||||
} else if ("value" in response) {
|
||||
resolve(response.value);
|
||||
} else {
|
||||
resolve(undefined);
|
||||
}
|
||||
},
|
||||
reject,
|
||||
});
|
||||
output({ type: "extension_ui_request", id, method: "select", title, options } as RpcExtensionUIRequest);
|
||||
});
|
||||
},
|
||||
|
||||
async confirm(title: string, message: string, opts?: { signal?: AbortSignal }): Promise<boolean> {
|
||||
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) {
|
||||
resolve(response.confirmed);
|
||||
} else {
|
||||
resolve(false);
|
||||
}
|
||||
},
|
||||
reject,
|
||||
});
|
||||
output({ type: "extension_ui_request", id, method: "confirm", title, message } as RpcExtensionUIRequest);
|
||||
});
|
||||
},
|
||||
|
||||
async input(title: string, placeholder?: string, opts?: { signal?: AbortSignal }): Promise<string | undefined> {
|
||||
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) {
|
||||
resolve(response.value);
|
||||
} else {
|
||||
resolve(undefined);
|
||||
}
|
||||
},
|
||||
reject,
|
||||
});
|
||||
output({ type: "extension_ui_request", id, method: "input", title, placeholder } as RpcExtensionUIRequest);
|
||||
});
|
||||
},
|
||||
input: (title, placeholder, opts) =>
|
||||
createDialogPromise(opts, undefined, { method: "input", title, placeholder, timeout: opts?.timeout }, (r) =>
|
||||
"cancelled" in r && r.cancelled ? undefined : "value" in r ? r.value : undefined,
|
||||
),
|
||||
|
||||
notify(message: string, type?: "info" | "warning" | "error"): void {
|
||||
// Fire and forget - no response needed
|
||||
|
|
|
|||
|
|
@ -177,9 +177,16 @@ export type RpcResponse =
|
|||
|
||||
/** Emitted when an extension needs user input */
|
||||
export type RpcExtensionUIRequest =
|
||||
| { type: "extension_ui_request"; id: string; method: "select"; title: string; options: string[] }
|
||||
| { type: "extension_ui_request"; id: string; method: "confirm"; title: string; message: string }
|
||||
| { type: "extension_ui_request"; id: string; method: "input"; title: string; placeholder?: string }
|
||||
| { type: "extension_ui_request"; id: string; method: "select"; title: string; options: string[]; timeout?: number }
|
||||
| { type: "extension_ui_request"; id: string; method: "confirm"; title: string; message: string; timeout?: number }
|
||||
| {
|
||||
type: "extension_ui_request";
|
||||
id: string;
|
||||
method: "input";
|
||||
title: string;
|
||||
placeholder?: string;
|
||||
timeout?: number;
|
||||
}
|
||||
| { type: "extension_ui_request"; id: string; method: "editor"; title: string; prefill?: string }
|
||||
| {
|
||||
type: "extension_ui_request";
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue