mirror of
https://github.com/getcompanion-ai/co-mono.git
synced 2026-04-15 17:00:59 +00:00
Merge origin/main into feat/no-extensions-flag
# Conflicts: # packages/coding-agent/CHANGELOG.md
This commit is contained in:
commit
2f16c7f7fc
12 changed files with 259 additions and 191 deletions
|
|
@ -5,6 +5,7 @@
|
|||
### Added
|
||||
|
||||
- `--no-extensions` flag to disable extension discovery and loading
|
||||
- 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
|
||||
|
||||
|
|
|
|||
|
|
@ -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