mirror of
https://github.com/getcompanion-ai/co-mono.git
synced 2026-04-20 15:01:24 +00:00
parent
282273e156
commit
9771fa1e44
7 changed files with 184 additions and 13 deletions
|
|
@ -2,6 +2,10 @@
|
||||||
|
|
||||||
## [Unreleased]
|
## [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
|
## [0.37.5] - 2026-01-06
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
|
||||||
|
|
@ -1094,6 +1094,38 @@ ctx.ui.notify("Done!", "info"); // "info" | "warning" | "error"
|
||||||
- `ctx.ui.editor()`: [handoff.ts](../examples/extensions/handoff.ts)
|
- `ctx.ui.editor()`: [handoff.ts](../examples/extensions/handoff.ts)
|
||||||
- `ctx.ui.setEditorText()`: [handoff.ts](../examples/extensions/handoff.ts), [qna.ts](../examples/extensions/qna.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
|
### Widgets, Status, and Footer
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
|
|
|
||||||
|
|
@ -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 |
|
| `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 |
|
| `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 |
|
| `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
|
### Git Integration
|
||||||
|
|
||||||
|
|
|
||||||
63
packages/coding-agent/examples/extensions/timed-confirm.ts
Normal file
63
packages/coding-agent/examples/extensions/timed-confirm.ts
Normal file
|
|
@ -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");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -52,13 +52,13 @@ export type { AgentToolResult, AgentToolUpdateCallback };
|
||||||
*/
|
*/
|
||||||
export interface ExtensionUIContext {
|
export interface ExtensionUIContext {
|
||||||
/** Show a selector and return the user's choice. */
|
/** Show a selector and return the user's choice. */
|
||||||
select(title: string, options: string[]): Promise<string | undefined>;
|
select(title: string, options: string[], opts?: { signal?: AbortSignal }): Promise<string | undefined>;
|
||||||
|
|
||||||
/** Show a confirmation dialog. */
|
/** Show a confirmation dialog. */
|
||||||
confirm(title: string, message: string): Promise<boolean>;
|
confirm(title: string, message: string, opts?: { signal?: AbortSignal }): Promise<boolean>;
|
||||||
|
|
||||||
/** Show a text input dialog. */
|
/** Show a text input dialog. */
|
||||||
input(title: string, placeholder?: string): Promise<string | undefined>;
|
input(title: string, placeholder?: string, opts?: { signal?: AbortSignal }): Promise<string | undefined>;
|
||||||
|
|
||||||
/** Show a notification to the user. */
|
/** Show a notification to the user. */
|
||||||
notify(message: string, type?: "info" | "warning" | "error"): void;
|
notify(message: string, type?: "info" | "warning" | "error"): void;
|
||||||
|
|
|
||||||
|
|
@ -739,9 +739,9 @@ export class InteractiveMode {
|
||||||
*/
|
*/
|
||||||
private createExtensionUIContext(): ExtensionUIContext {
|
private createExtensionUIContext(): ExtensionUIContext {
|
||||||
return {
|
return {
|
||||||
select: (title, options) => this.showExtensionSelector(title, options),
|
select: (title, options, opts) => this.showExtensionSelector(title, options, opts),
|
||||||
confirm: (title, message) => this.showExtensionConfirm(title, message),
|
confirm: (title, message, opts) => this.showExtensionConfirm(title, message, opts),
|
||||||
input: (title, placeholder) => this.showExtensionInput(title, placeholder),
|
input: (title, placeholder, opts) => this.showExtensionInput(title, placeholder, opts),
|
||||||
notify: (message, type) => this.showExtensionNotify(message, type),
|
notify: (message, type) => this.showExtensionNotify(message, type),
|
||||||
setStatus: (key, text) => this.setExtensionStatus(key, text),
|
setStatus: (key, text) => this.setExtensionStatus(key, text),
|
||||||
setWidget: (key, content) => this.setExtensionWidget(key, content),
|
setWidget: (key, content) => this.setExtensionWidget(key, content),
|
||||||
|
|
@ -761,16 +761,33 @@ export class InteractiveMode {
|
||||||
/**
|
/**
|
||||||
* Show a selector for extensions.
|
* Show a selector for extensions.
|
||||||
*/
|
*/
|
||||||
private showExtensionSelector(title: string, options: string[]): Promise<string | undefined> {
|
private showExtensionSelector(
|
||||||
|
title: string,
|
||||||
|
options: string[],
|
||||||
|
opts?: { signal?: AbortSignal },
|
||||||
|
): Promise<string | undefined> {
|
||||||
return new Promise((resolve) => {
|
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(
|
this.extensionSelector = new ExtensionSelectorComponent(
|
||||||
title,
|
title,
|
||||||
options,
|
options,
|
||||||
(option) => {
|
(option) => {
|
||||||
|
opts?.signal?.removeEventListener("abort", onAbort);
|
||||||
this.hideExtensionSelector();
|
this.hideExtensionSelector();
|
||||||
resolve(option);
|
resolve(option);
|
||||||
},
|
},
|
||||||
() => {
|
() => {
|
||||||
|
opts?.signal?.removeEventListener("abort", onAbort);
|
||||||
this.hideExtensionSelector();
|
this.hideExtensionSelector();
|
||||||
resolve(undefined);
|
resolve(undefined);
|
||||||
},
|
},
|
||||||
|
|
@ -797,24 +814,45 @@ export class InteractiveMode {
|
||||||
/**
|
/**
|
||||||
* Show a confirmation dialog for extensions.
|
* Show a confirmation dialog for extensions.
|
||||||
*/
|
*/
|
||||||
private async showExtensionConfirm(title: string, message: string): Promise<boolean> {
|
private async showExtensionConfirm(
|
||||||
const result = await this.showExtensionSelector(`${title}\n${message}`, ["Yes", "No"]);
|
title: string,
|
||||||
|
message: string,
|
||||||
|
opts?: { signal?: AbortSignal },
|
||||||
|
): Promise<boolean> {
|
||||||
|
const result = await this.showExtensionSelector(`${title}\n${message}`, ["Yes", "No"], opts);
|
||||||
return result === "Yes";
|
return result === "Yes";
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Show a text input for extensions.
|
* Show a text input for extensions.
|
||||||
*/
|
*/
|
||||||
private showExtensionInput(title: string, placeholder?: string): Promise<string | undefined> {
|
private showExtensionInput(
|
||||||
|
title: string,
|
||||||
|
placeholder?: string,
|
||||||
|
opts?: { signal?: AbortSignal },
|
||||||
|
): Promise<string | undefined> {
|
||||||
return new Promise((resolve) => {
|
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(
|
this.extensionInput = new ExtensionInputComponent(
|
||||||
title,
|
title,
|
||||||
placeholder,
|
placeholder,
|
||||||
(value) => {
|
(value) => {
|
||||||
|
opts?.signal?.removeEventListener("abort", onAbort);
|
||||||
this.hideExtensionInput();
|
this.hideExtensionInput();
|
||||||
resolve(value);
|
resolve(value);
|
||||||
},
|
},
|
||||||
() => {
|
() => {
|
||||||
|
opts?.signal?.removeEventListener("abort", onAbort);
|
||||||
this.hideExtensionInput();
|
this.hideExtensionInput();
|
||||||
resolve(undefined);
|
resolve(undefined);
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -67,11 +67,22 @@ export async function runRpcMode(session: AgentSession): Promise<never> {
|
||||||
* Create an extension UI context that uses the RPC protocol.
|
* Create an extension UI context that uses the RPC protocol.
|
||||||
*/
|
*/
|
||||||
const createExtensionUIContext = (): ExtensionUIContext => ({
|
const createExtensionUIContext = (): ExtensionUIContext => ({
|
||||||
async select(title: string, options: string[]): Promise<string | undefined> {
|
async select(title: string, options: string[], opts?: { signal?: AbortSignal }): Promise<string | undefined> {
|
||||||
|
if (opts?.signal?.aborted) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
const id = crypto.randomUUID();
|
const id = crypto.randomUUID();
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
|
const onAbort = () => {
|
||||||
|
pendingExtensionRequests.delete(id);
|
||||||
|
resolve(undefined);
|
||||||
|
};
|
||||||
|
opts?.signal?.addEventListener("abort", onAbort, { once: true });
|
||||||
|
|
||||||
pendingExtensionRequests.set(id, {
|
pendingExtensionRequests.set(id, {
|
||||||
resolve: (response: RpcExtensionUIResponse) => {
|
resolve: (response: RpcExtensionUIResponse) => {
|
||||||
|
opts?.signal?.removeEventListener("abort", onAbort);
|
||||||
if ("cancelled" in response && response.cancelled) {
|
if ("cancelled" in response && response.cancelled) {
|
||||||
resolve(undefined);
|
resolve(undefined);
|
||||||
} else if ("value" in response) {
|
} else if ("value" in response) {
|
||||||
|
|
@ -86,11 +97,22 @@ export async function runRpcMode(session: AgentSession): Promise<never> {
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
async confirm(title: string, message: string): Promise<boolean> {
|
async confirm(title: string, message: string, opts?: { signal?: AbortSignal }): Promise<boolean> {
|
||||||
|
if (opts?.signal?.aborted) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
const id = crypto.randomUUID();
|
const id = crypto.randomUUID();
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
|
const onAbort = () => {
|
||||||
|
pendingExtensionRequests.delete(id);
|
||||||
|
resolve(false);
|
||||||
|
};
|
||||||
|
opts?.signal?.addEventListener("abort", onAbort, { once: true });
|
||||||
|
|
||||||
pendingExtensionRequests.set(id, {
|
pendingExtensionRequests.set(id, {
|
||||||
resolve: (response: RpcExtensionUIResponse) => {
|
resolve: (response: RpcExtensionUIResponse) => {
|
||||||
|
opts?.signal?.removeEventListener("abort", onAbort);
|
||||||
if ("cancelled" in response && response.cancelled) {
|
if ("cancelled" in response && response.cancelled) {
|
||||||
resolve(false);
|
resolve(false);
|
||||||
} else if ("confirmed" in response) {
|
} else if ("confirmed" in response) {
|
||||||
|
|
@ -105,11 +127,22 @@ export async function runRpcMode(session: AgentSession): Promise<never> {
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
async input(title: string, placeholder?: string): Promise<string | undefined> {
|
async input(title: string, placeholder?: string, opts?: { signal?: AbortSignal }): Promise<string | undefined> {
|
||||||
|
if (opts?.signal?.aborted) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
const id = crypto.randomUUID();
|
const id = crypto.randomUUID();
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
|
const onAbort = () => {
|
||||||
|
pendingExtensionRequests.delete(id);
|
||||||
|
resolve(undefined);
|
||||||
|
};
|
||||||
|
opts?.signal?.addEventListener("abort", onAbort, { once: true });
|
||||||
|
|
||||||
pendingExtensionRequests.set(id, {
|
pendingExtensionRequests.set(id, {
|
||||||
resolve: (response: RpcExtensionUIResponse) => {
|
resolve: (response: RpcExtensionUIResponse) => {
|
||||||
|
opts?.signal?.removeEventListener("abort", onAbort);
|
||||||
if ("cancelled" in response && response.cancelled) {
|
if ("cancelled" in response && response.cancelled) {
|
||||||
resolve(undefined);
|
resolve(undefined);
|
||||||
} else if ("value" in response) {
|
} else if ("value" in response) {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue