diff --git a/packages/agent/README.md b/packages/agent/README.md index 99e455c7..44206fb0 100644 --- a/packages/agent/README.md +++ b/packages/agent/README.md @@ -298,6 +298,22 @@ const readFileTool: AgentTool = { agent.setTools([readFileTool]); ``` +### Error Handling + +**Throw an error** when a tool fails. Do not return error messages as content. + +```typescript +execute: async (toolCallId, params, signal, onUpdate) => { + if (!fs.existsSync(params.path)) { + throw new Error(`File not found: ${params.path}`); + } + // Return content only on success + return { content: [{ type: "text", text: "..." }] }; +} +``` + +Thrown errors are caught by the agent and reported to the LLM as tool errors with `isError: true`. + ## Proxy Usage For browser apps that proxy through a backend: diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index bfcf1124..84caa259 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -198,7 +198,8 @@ Total color count increased from 46 to 50. See [docs/theme.md](docs/theme.md) fo ### Fixed -- **Resuming session resets thinking level to off**: Initial model and thinking level were not saved to session file, causing `--resume`/`--continue` to default to `off`. ([#342](https://github.com/badlogic/pi-mono/issues/342)) +- **Resuming session resets thinking level to off**: Initial model and thinking level were not saved to session file, causing `--resume`/`--continue` to default to `off`. ([#342](https://github.com/badlogic/pi-mono/issues/342) by [@aliou](https://github.com/aliou)) +- **Hook `tool_result` event ignores errors from custom tools**: The `tool_result` hook event was never emitted when tools threw errors, and always had `isError: false` for successful executions. Now emits the event with correct `isError` value in both success and error cases. ([#374](https://github.com/badlogic/pi-mono/issues/374) by [@nicobailon](https://github.com/nicobailon)) - **Edit tool fails on Windows due to CRLF line endings**: Files with CRLF line endings now match correctly when LLMs send LF-only text. Line endings are normalized before matching and restored to original style on write. ([#355](https://github.com/badlogic/pi-mono/issues/355) by [@Pratham-Dubey](https://github.com/Pratham-Dubey)) - **Use bash instead of sh on Unix**: Fixed shell commands using `/bin/sh` instead of `/bin/bash` on Unix systems. ([#328](https://github.com/badlogic/pi-mono/pull/328) by [@dnouri](https://github.com/dnouri)) - **OAuth login URL clickable**: Made OAuth login URLs clickable in terminal. ([#349](https://github.com/badlogic/pi-mono/pull/349) by [@Cursivez](https://github.com/Cursivez)) diff --git a/packages/coding-agent/docs/custom-tools.md b/packages/coding-agent/docs/custom-tools.md index 8f428efb..4070b463 100644 --- a/packages/coding-agent/docs/custom-tools.md +++ b/packages/coding-agent/docs/custom-tools.md @@ -198,6 +198,29 @@ async execute(toolCallId, params, onUpdate, ctx, signal) { } ``` +### Error Handling + +**Throw an error** when the tool fails. Do not return an error message as content. + +```typescript +async execute(toolCallId, params, onUpdate, ctx, signal) { + const { path } = params as { path: string }; + + // Throw on error - pi will catch it and report to the LLM + if (!fs.existsSync(path)) { + throw new Error(`File not found: ${path}`); + } + + // Return content only on success + return { content: [{ type: "text", text: "Success" }] }; +} +``` + +Thrown errors are: +- Reported to the LLM as tool errors (with `isError: true`) +- Emitted to hooks via `tool_result` event (hooks can inspect `event.isError`) +- Displayed in the TUI with error styling + ## CustomToolContext The `execute` and `onSession` callbacks receive a `CustomToolContext`: diff --git a/packages/coding-agent/docs/hooks.md b/packages/coding-agent/docs/hooks.md index 51629704..76531459 100644 --- a/packages/coding-agent/docs/hooks.md +++ b/packages/coding-agent/docs/hooks.md @@ -25,7 +25,7 @@ See [examples/hooks/](../examples/hooks/) for working implementations, including Create `~/.pi/agent/hooks/my-hook.ts`: ```typescript -import type { HookAPI } from "@mariozechner/pi-coding-agent/hooks"; +import type { HookAPI } from "@mariozechner/pi-coding-agent"; export default function (pi: HookAPI) { pi.on("session_start", async (_event, ctx) => { @@ -80,7 +80,7 @@ Node.js built-ins (`node:fs`, `node:path`, etc.) are also available. A hook exports a default function that receives `HookAPI`: ```typescript -import type { HookAPI } from "@mariozechner/pi-coding-agent/hooks"; +import type { HookAPI } from "@mariozechner/pi-coding-agent"; export default function (pi: HookAPI) { // Subscribe to events @@ -360,14 +360,20 @@ Tool inputs: #### tool_result -Fired after tool executes. **Can modify result.** +Fired after tool executes (including errors). **Can modify result.** + +Check `event.isError` to distinguish successful executions from failures. ```typescript pi.on("tool_result", async (event, ctx) => { // event.toolName, event.toolCallId, event.input // event.content - array of TextContent | ImageContent // event.details - tool-specific (see below) - // event.isError + // event.isError - true if the tool threw an error + + if (event.isError) { + // Handle error case + } // Modify result: return { content: [...], details: {...}, isError: false }; @@ -377,7 +383,7 @@ pi.on("tool_result", async (event, ctx) => { Use type guards for typed details: ```typescript -import { isBashToolResult } from "@mariozechner/pi-coding-agent/hooks"; +import { isBashToolResult } from "@mariozechner/pi-coding-agent"; pi.on("tool_result", async (event, ctx) => { if (isBashToolResult(event)) { @@ -416,25 +422,40 @@ const name = await ctx.ui.input("Name:", "placeholder"); // Notification (non-blocking) ctx.ui.notify("Done!", "info"); // "info" | "warning" | "error" + +// Set the core input editor text (pre-fill prompts, generated content) +ctx.ui.setEditorText("Generated prompt text here..."); + +// Get current editor text +const currentText = ctx.ui.getEditorText(); ``` **Custom components:** -For full control, render your own TUI component with keyboard focus: +Show a custom TUI component with keyboard focus: ```typescript -const handle = ctx.ui.custom(myComponent); -// Returns { close: () => void, requestRender: () => void } +import { BorderedLoader } from "@mariozechner/pi-coding-agent"; + +const result = await ctx.ui.custom((tui, theme, done) => { + const loader = new BorderedLoader(tui, theme, "Working..."); + loader.onAbort = () => done(null); + + doWork(loader.signal).then(done).catch(() => done(null)); + + return loader; +}); ``` Your component can: - Implement `handleInput(data: string)` to receive keyboard input - Implement `render(width: number): string[]` to render lines - Implement `invalidate()` to clear cached render -- Call `handle.requestRender()` to trigger re-render -- Call `handle.close()` when done to restore normal UI +- Implement `dispose()` for cleanup when closed +- Call `tui.requestRender()` to trigger re-render +- Call `done(result)` when done to restore normal UI -See [examples/hooks/snake.ts](../examples/hooks/snake.ts) for a complete example with game loop, keyboard handling, and state persistence. See [tui.md](tui.md) for the full component API. +See [examples/hooks/qna.ts](../examples/hooks/qna.ts) for a loader pattern and [examples/hooks/snake.ts](../examples/hooks/snake.ts) for a game. See [tui.md](tui.md) for the full component API. ### ctx.hasUI @@ -568,6 +589,8 @@ pi.registerCommand("stats", { }); ``` +For long-running commands (e.g., LLM calls), use `ctx.ui.custom()` with a loader. See [examples/hooks/qna.ts](../examples/hooks/qna.ts). + To trigger LLM after command, call `pi.sendMessage(..., true)`. ### pi.registerMessageRenderer(customType, renderer) @@ -620,7 +643,7 @@ const result = await pi.exec("git", ["status"], { ### Permission Gate ```typescript -import type { HookAPI } from "@mariozechner/pi-coding-agent/hooks"; +import type { HookAPI } from "@mariozechner/pi-coding-agent"; export default function (pi: HookAPI) { const dangerous = [/\brm\s+(-rf?|--recursive)/i, /\bsudo\b/i]; @@ -643,7 +666,7 @@ export default function (pi: HookAPI) { ### Protected Paths ```typescript -import type { HookAPI } from "@mariozechner/pi-coding-agent/hooks"; +import type { HookAPI } from "@mariozechner/pi-coding-agent"; export default function (pi: HookAPI) { const protectedPaths = [".env", ".git/", "node_modules/"]; @@ -663,7 +686,7 @@ export default function (pi: HookAPI) { ### Git Checkpoint ```typescript -import type { HookAPI } from "@mariozechner/pi-coding-agent/hooks"; +import type { HookAPI } from "@mariozechner/pi-coding-agent"; export default function (pi: HookAPI) { const checkpoints = new Map(); @@ -708,7 +731,7 @@ See [examples/hooks/snake.ts](../examples/hooks/snake.ts) for a complete example | RPC | JSON protocol | Host handles UI | | Print (`-p`) | No-op (returns null/false) | Hooks run but can't prompt | -In print mode, `select()` returns `undefined`, `confirm()` returns `false`, `input()` returns `undefined`. Design hooks to handle this. +In print mode, `select()` returns `undefined`, `confirm()` returns `false`, `input()` returns `undefined`, `getEditorText()` returns `""`, and `setEditorText()` is a no-op. Design hooks to handle this by checking `ctx.hasUI`. ## Error Handling diff --git a/packages/coding-agent/examples/hooks/README.md b/packages/coding-agent/examples/hooks/README.md index d9785070..cab8d80d 100644 --- a/packages/coding-agent/examples/hooks/README.md +++ b/packages/coding-agent/examples/hooks/README.md @@ -2,97 +2,53 @@ Example hooks for pi-coding-agent. -## Examples - -### permission-gate.ts -Prompts for confirmation before running dangerous bash commands (rm -rf, sudo, chmod 777, etc.). - -### git-checkpoint.ts -Creates git stash checkpoints at each turn, allowing code restoration when branching. - -### protected-paths.ts -Blocks writes to protected paths (.env, .git/, node_modules/). - -### file-trigger.ts -Watches a trigger file and injects its contents into the conversation. Useful for external systems (CI, file watchers, webhooks) to send messages to the agent. - -### confirm-destructive.ts -Prompts for confirmation before destructive session actions (clear, switch, branch). Demonstrates how to cancel `before_*` session events. - -### dirty-repo-guard.ts -Prevents session changes when there are uncommitted git changes. Blocks clear/switch/branch until you commit. - -### auto-commit-on-exit.ts -Automatically commits changes when the agent exits (shutdown event). Uses the last assistant message to generate a commit message. - -### custom-compaction.ts -Custom context compaction that summarizes the entire conversation instead of keeping recent turns. Uses the `before_compact` hook event to intercept compaction and generate a comprehensive summary using `complete()` from the AI package. Useful when you want maximum context window space at the cost of losing exact conversation history. - ## Usage ```bash -# Test directly +# Load a hook with --hook flag pi --hook examples/hooks/permission-gate.ts -# Or copy to hooks directory for persistent use +# Or copy to hooks directory for auto-discovery cp permission-gate.ts ~/.pi/agent/hooks/ ``` +## Examples + +| Hook | Description | +|------|-------------| +| `permission-gate.ts` | Prompts for confirmation before dangerous bash commands (rm -rf, sudo, etc.) | +| `git-checkpoint.ts` | Creates git stash checkpoints at each turn for code restoration on branch | +| `protected-paths.ts` | Blocks writes to protected paths (.env, .git/, node_modules/) | +| `file-trigger.ts` | Watches a trigger file and injects contents into conversation | +| `confirm-destructive.ts` | Confirms before destructive session actions (clear, switch, branch) | +| `dirty-repo-guard.ts` | Prevents session changes with uncommitted git changes | +| `auto-commit-on-exit.ts` | Auto-commits on exit using last assistant message for commit message | +| `custom-compaction.ts` | Custom compaction that summarizes entire conversation | +| `qna.ts` | Extracts questions from last response into editor via `ctx.ui.setEditorText()` | +| `snake.ts` | Snake game with custom UI, keyboard handling, and session persistence | + ## Writing Hooks See [docs/hooks.md](../../docs/hooks.md) for full documentation. -### Key Points - -**Hook structure:** ```typescript import type { HookAPI } from "@mariozechner/pi-coding-agent/hooks"; export default function (pi: HookAPI) { - pi.on("session", async (event, ctx) => { - // event.reason: "start" | "before_switch" | "switch" | "before_clear" | "clear" | - // "before_branch" | "branch" | "shutdown" - // event.targetTurnIndex: number (only for before_branch/branch) - // ctx.ui, ctx.exec, ctx.cwd, ctx.sessionFile, ctx.hasUI - - // Cancel before_* actions: - if (event.reason === "before_clear") { - return { cancel: true }; - } - return undefined; - }); - + // Subscribe to events pi.on("tool_call", async (event, ctx) => { - // Can block tool execution - if (dangerous) { - return { block: true, reason: "Blocked" }; + if (event.toolName === "bash" && event.input.command?.includes("rm -rf")) { + const ok = await ctx.ui.confirm("Dangerous!", "Allow rm -rf?"); + if (!ok) return { block: true, reason: "Blocked by user" }; } - return undefined; }); - pi.on("tool_result", async (event, ctx) => { - // Can modify result - return { result: "modified result" }; + // Register custom commands + pi.registerCommand("hello", { + description: "Say hello", + handler: async (args, ctx) => { + ctx.ui.notify("Hello!", "info"); + }, }); } ``` - -**Available events:** -- `session` - lifecycle events with before/after variants (can cancel before_* actions) -- `agent_start` / `agent_end` - per user prompt -- `turn_start` / `turn_end` - per LLM turn -- `tool_call` - before tool execution (can block) -- `tool_result` - after tool execution (can modify) - -**UI methods:** -```typescript -const choice = await ctx.ui.select("Title", ["Option A", "Option B"]); -const confirmed = await ctx.ui.confirm("Title", "Are you sure?"); -const input = await ctx.ui.input("Title", "placeholder"); -ctx.ui.notify("Message", "info"); // or "warning", "error" -``` - -**Sending messages:** -```typescript -pi.send("Message to inject into conversation"); -``` diff --git a/packages/coding-agent/examples/hooks/auto-commit-on-exit.ts b/packages/coding-agent/examples/hooks/auto-commit-on-exit.ts index bfcc37f7..598ecdc2 100644 --- a/packages/coding-agent/examples/hooks/auto-commit-on-exit.ts +++ b/packages/coding-agent/examples/hooks/auto-commit-on-exit.ts @@ -5,7 +5,7 @@ * Uses the last assistant message to generate a commit message. */ -import type { HookAPI } from "@mariozechner/pi-coding-agent/hooks"; +import type { HookAPI } from "@mariozechner/pi-coding-agent"; export default function (pi: HookAPI) { pi.on("session_shutdown", async (_event, ctx) => { diff --git a/packages/coding-agent/examples/hooks/confirm-destructive.ts b/packages/coding-agent/examples/hooks/confirm-destructive.ts index ef189b23..219c7b2f 100644 --- a/packages/coding-agent/examples/hooks/confirm-destructive.ts +++ b/packages/coding-agent/examples/hooks/confirm-destructive.ts @@ -5,8 +5,7 @@ * Demonstrates how to cancel session events using the before_* events. */ -import type { SessionMessageEntry } from "@mariozechner/pi-coding-agent"; -import type { HookAPI } from "@mariozechner/pi-coding-agent/hooks"; +import type { HookAPI, SessionMessageEntry } from "@mariozechner/pi-coding-agent"; export default function (pi: HookAPI) { pi.on("session_before_new", async (_event, ctx) => { diff --git a/packages/coding-agent/examples/hooks/custom-compaction.ts b/packages/coding-agent/examples/hooks/custom-compaction.ts index 32b965b4..5f413e03 100644 --- a/packages/coding-agent/examples/hooks/custom-compaction.ts +++ b/packages/coding-agent/examples/hooks/custom-compaction.ts @@ -14,8 +14,8 @@ */ import { complete, getModel } from "@mariozechner/pi-ai"; +import type { HookAPI } from "@mariozechner/pi-coding-agent"; import { convertToLlm, serializeConversation } from "@mariozechner/pi-coding-agent"; -import type { HookAPI } from "@mariozechner/pi-coding-agent/hooks"; export default function (pi: HookAPI) { pi.on("session_before_compact", async (event, ctx) => { diff --git a/packages/coding-agent/examples/hooks/dirty-repo-guard.ts b/packages/coding-agent/examples/hooks/dirty-repo-guard.ts index a0031d57..1d7fdc7e 100644 --- a/packages/coding-agent/examples/hooks/dirty-repo-guard.ts +++ b/packages/coding-agent/examples/hooks/dirty-repo-guard.ts @@ -5,7 +5,7 @@ * Useful to ensure work is committed before switching context. */ -import type { HookAPI, HookContext } from "@mariozechner/pi-coding-agent/hooks"; +import type { HookAPI, HookContext } from "@mariozechner/pi-coding-agent"; async function checkDirtyRepo(pi: HookAPI, ctx: HookContext, action: string): Promise<{ cancel: boolean } | undefined> { // Check for uncommitted changes diff --git a/packages/coding-agent/examples/hooks/file-trigger.ts b/packages/coding-agent/examples/hooks/file-trigger.ts index 4363bdce..e3f69b1f 100644 --- a/packages/coding-agent/examples/hooks/file-trigger.ts +++ b/packages/coding-agent/examples/hooks/file-trigger.ts @@ -9,7 +9,7 @@ */ import * as fs from "node:fs"; -import type { HookAPI } from "@mariozechner/pi-coding-agent/hooks"; +import type { HookAPI } from "@mariozechner/pi-coding-agent"; export default function (pi: HookAPI) { pi.on("session_start", async (_event, ctx) => { diff --git a/packages/coding-agent/examples/hooks/git-checkpoint.ts b/packages/coding-agent/examples/hooks/git-checkpoint.ts index 6190be0d..1ea89449 100644 --- a/packages/coding-agent/examples/hooks/git-checkpoint.ts +++ b/packages/coding-agent/examples/hooks/git-checkpoint.ts @@ -5,7 +5,7 @@ * When branching, offers to restore code to that point in history. */ -import type { HookAPI } from "@mariozechner/pi-coding-agent/hooks"; +import type { HookAPI } from "@mariozechner/pi-coding-agent"; export default function (pi: HookAPI) { const checkpoints = new Map(); diff --git a/packages/coding-agent/examples/hooks/permission-gate.ts b/packages/coding-agent/examples/hooks/permission-gate.ts index 6ebe459a..c3619fd0 100644 --- a/packages/coding-agent/examples/hooks/permission-gate.ts +++ b/packages/coding-agent/examples/hooks/permission-gate.ts @@ -5,7 +5,7 @@ * Patterns checked: rm -rf, sudo, chmod/chown 777 */ -import type { HookAPI } from "@mariozechner/pi-coding-agent/hooks"; +import type { HookAPI } from "@mariozechner/pi-coding-agent"; export default function (pi: HookAPI) { const dangerousPatterns = [/\brm\s+(-rf?|--recursive)/i, /\bsudo\b/i, /\b(chmod|chown)\b.*777/i]; diff --git a/packages/coding-agent/examples/hooks/protected-paths.ts b/packages/coding-agent/examples/hooks/protected-paths.ts index 7aec0d46..8431d2fb 100644 --- a/packages/coding-agent/examples/hooks/protected-paths.ts +++ b/packages/coding-agent/examples/hooks/protected-paths.ts @@ -5,7 +5,7 @@ * Useful for preventing accidental modifications to sensitive files. */ -import type { HookAPI } from "@mariozechner/pi-coding-agent/hooks"; +import type { HookAPI } from "@mariozechner/pi-coding-agent"; export default function (pi: HookAPI) { const protectedPaths = [".env", ".git/", "node_modules/"]; diff --git a/packages/coding-agent/examples/hooks/qna.ts b/packages/coding-agent/examples/hooks/qna.ts new file mode 100644 index 00000000..92bb14d7 --- /dev/null +++ b/packages/coding-agent/examples/hooks/qna.ts @@ -0,0 +1,119 @@ +/** + * Q&A extraction hook - extracts questions from assistant responses + * + * Demonstrates the "prompt generator" pattern: + * 1. /qna command gets the last assistant message + * 2. Shows a spinner while extracting (hides editor) + * 3. Loads the result into the editor for user to fill in answers + */ + +import { complete, type UserMessage } from "@mariozechner/pi-ai"; +import type { HookAPI } from "@mariozechner/pi-coding-agent"; +import { BorderedLoader } from "@mariozechner/pi-coding-agent"; + +const SYSTEM_PROMPT = `You are a question extractor. Given text from a conversation, extract any questions that need answering and format them for the user to fill in. + +Output format: +- List each question on its own line, prefixed with "Q: " +- After each question, add a blank line for the answer prefixed with "A: " +- If no questions are found, output "No questions found in the last message." + +Example output: +Q: What is your preferred database? +A: + +Q: Should we use TypeScript or JavaScript? +A: + +Keep questions in the order they appeared. Be concise.`; + +export default function (pi: HookAPI) { + pi.registerCommand("qna", { + description: "Extract questions from last assistant message into editor", + handler: async (_args, ctx) => { + if (!ctx.hasUI) { + ctx.ui.notify("qna requires interactive mode", "error"); + return; + } + + if (!ctx.model) { + ctx.ui.notify("No model selected", "error"); + return; + } + + // Find the last assistant message on the current branch + const branch = ctx.sessionManager.getBranch(); + let lastAssistantText: string | undefined; + + for (let i = branch.length - 1; i >= 0; i--) { + const entry = branch[i]; + if (entry.type === "message") { + const msg = entry.message; + if ("role" in msg && msg.role === "assistant") { + if (msg.stopReason !== "stop") { + ctx.ui.notify(`Last assistant message incomplete (${msg.stopReason})`, "error"); + return; + } + const textParts = msg.content + .filter((c): c is { type: "text"; text: string } => c.type === "text") + .map((c) => c.text); + if (textParts.length > 0) { + lastAssistantText = textParts.join("\n"); + break; + } + } + } + } + + if (!lastAssistantText) { + ctx.ui.notify("No assistant messages found", "error"); + return; + } + + // Run extraction with loader UI + const result = await ctx.ui.custom((tui, theme, done) => { + const loader = new BorderedLoader(tui, theme, `Extracting questions using ${ctx.model!.id}...`); + loader.onAbort = () => done(null); + + // Do the work + const doExtract = async () => { + const apiKey = await ctx.modelRegistry.getApiKey(ctx.model!); + const userMessage: UserMessage = { + role: "user", + content: [{ type: "text", text: lastAssistantText! }], + timestamp: Date.now(), + }; + + const response = await complete( + ctx.model!, + { systemPrompt: SYSTEM_PROMPT, messages: [userMessage] }, + { apiKey, signal: loader.signal }, + ); + + if (response.stopReason === "aborted") { + return null; + } + + return response.content + .filter((c): c is { type: "text"; text: string } => c.type === "text") + .map((c) => c.text) + .join("\n"); + }; + + doExtract() + .then(done) + .catch(() => done(null)); + + return loader; + }); + + if (result === null) { + ctx.ui.notify("Cancelled", "info"); + return; + } + + ctx.ui.setEditorText(result); + ctx.ui.notify("Questions loaded. Edit and submit when ready.", "info"); + }, + }); +} diff --git a/packages/coding-agent/examples/hooks/snake.ts b/packages/coding-agent/examples/hooks/snake.ts index 0837e185..c90cb151 100644 --- a/packages/coding-agent/examples/hooks/snake.ts +++ b/packages/coding-agent/examples/hooks/snake.ts @@ -2,8 +2,8 @@ * Snake game hook - play snake with /snake command */ +import type { HookAPI } from "@mariozechner/pi-coding-agent"; import { isArrowDown, isArrowLeft, isArrowRight, isArrowUp, isEscape, visibleWidth } from "@mariozechner/pi-tui"; -import type { HookAPI } from "../../src/core/hooks/types.js"; const GAME_WIDTH = 40; const GAME_HEIGHT = 15; @@ -56,7 +56,7 @@ class SnakeComponent { private interval: ReturnType | null = null; private onClose: () => void; private onSave: (state: GameState | null) => void; - private requestRender: () => void; + private tui: { requestRender: () => void }; private cachedLines: string[] = []; private cachedWidth = 0; private version = 0; @@ -64,11 +64,12 @@ class SnakeComponent { private paused: boolean; constructor( + tui: { requestRender: () => void }, onClose: () => void, onSave: (state: GameState | null) => void, - requestRender: () => void, savedState?: GameState, ) { + this.tui = tui; if (savedState && !savedState.gameOver) { // Resume from saved state, start paused this.state = savedState; @@ -84,7 +85,6 @@ class SnakeComponent { } this.onClose = onClose; this.onSave = onSave; - this.requestRender = requestRender; } private startGame(): void { @@ -92,7 +92,7 @@ class SnakeComponent { if (!this.state.gameOver) { this.tick(); this.version++; - this.requestRender(); + this.tui.requestRender(); } }, TICK_MS); } @@ -196,7 +196,7 @@ class SnakeComponent { this.state.highScore = highScore; this.onSave(null); // Clear saved state on restart this.version++; - this.requestRender(); + this.tui.requestRender(); } } @@ -327,19 +327,17 @@ export default function (pi: HookAPI) { } } - let ui: { close: () => void; requestRender: () => void } | null = null; - - const component = new SnakeComponent( - () => ui?.close(), - (state) => { - // Save or clear state - pi.appendEntry(SNAKE_SAVE_TYPE, state); - }, - () => ui?.requestRender(), - savedState, - ); - - ui = ctx.ui.custom(component); + await ctx.ui.custom((tui, _theme, done) => { + return new SnakeComponent( + tui, + () => done(undefined), + (state) => { + // Save or clear state + pi.appendEntry(SNAKE_SAVE_TYPE, state); + }, + savedState, + ); + }); }, }); } diff --git a/packages/coding-agent/examples/sdk/01-minimal.ts b/packages/coding-agent/examples/sdk/01-minimal.ts index b257fccc..80045132 100644 --- a/packages/coding-agent/examples/sdk/01-minimal.ts +++ b/packages/coding-agent/examples/sdk/01-minimal.ts @@ -5,7 +5,7 @@ * from cwd and ~/.pi/agent. Model chosen from settings or first available. */ -import { createAgentSession } from "../../src/index.js"; +import { createAgentSession } from "@mariozechner/pi-coding-agent"; const { session } = await createAgentSession(); diff --git a/packages/coding-agent/examples/sdk/02-custom-model.ts b/packages/coding-agent/examples/sdk/02-custom-model.ts index 54c3f8fc..5d5bf656 100644 --- a/packages/coding-agent/examples/sdk/02-custom-model.ts +++ b/packages/coding-agent/examples/sdk/02-custom-model.ts @@ -5,7 +5,7 @@ */ import { getModel } from "@mariozechner/pi-ai"; -import { createAgentSession, discoverAuthStorage, discoverModels } from "../../src/index.js"; +import { createAgentSession, discoverAuthStorage, discoverModels } from "@mariozechner/pi-coding-agent"; // Set up auth storage and model registry const authStorage = discoverAuthStorage(); diff --git a/packages/coding-agent/examples/sdk/03-custom-prompt.ts b/packages/coding-agent/examples/sdk/03-custom-prompt.ts index 9f19d67c..37698f46 100644 --- a/packages/coding-agent/examples/sdk/03-custom-prompt.ts +++ b/packages/coding-agent/examples/sdk/03-custom-prompt.ts @@ -4,7 +4,7 @@ * Shows how to replace or modify the default system prompt. */ -import { createAgentSession, SessionManager } from "../../src/index.js"; +import { createAgentSession, SessionManager } from "@mariozechner/pi-coding-agent"; // Option 1: Replace prompt entirely const { session: session1 } = await createAgentSession({ diff --git a/packages/coding-agent/examples/sdk/04-skills.ts b/packages/coding-agent/examples/sdk/04-skills.ts index 42d1bd8f..bf04633f 100644 --- a/packages/coding-agent/examples/sdk/04-skills.ts +++ b/packages/coding-agent/examples/sdk/04-skills.ts @@ -5,7 +5,7 @@ * Discover, filter, merge, or replace them. */ -import { createAgentSession, discoverSkills, SessionManager, type Skill } from "../../src/index.js"; +import { createAgentSession, discoverSkills, SessionManager, type Skill } from "@mariozechner/pi-coding-agent"; // Discover all skills from cwd/.pi/skills, ~/.pi/agent/skills, etc. const allSkills = discoverSkills(); diff --git a/packages/coding-agent/examples/sdk/05-tools.ts b/packages/coding-agent/examples/sdk/05-tools.ts index f7939688..09592cbf 100644 --- a/packages/coding-agent/examples/sdk/05-tools.ts +++ b/packages/coding-agent/examples/sdk/05-tools.ts @@ -8,7 +8,6 @@ * tools resolve paths relative to your cwd, not process.cwd(). */ -import { Type } from "@sinclair/typebox"; import { bashTool, // read, bash, edit, write - uses process.cwd() type CustomTool, @@ -21,7 +20,8 @@ import { readOnlyTools, // read, grep, find, ls - uses process.cwd() readTool, SessionManager, -} from "../../src/index.js"; +} from "@mariozechner/pi-coding-agent"; +import { Type } from "@sinclair/typebox"; // Read-only mode (no edit/write) - uses process.cwd() await createAgentSession({ diff --git a/packages/coding-agent/examples/sdk/06-hooks.ts b/packages/coding-agent/examples/sdk/06-hooks.ts index 93bfe98d..d0a7a07f 100644 --- a/packages/coding-agent/examples/sdk/06-hooks.ts +++ b/packages/coding-agent/examples/sdk/06-hooks.ts @@ -4,7 +4,7 @@ * Hooks intercept agent events for logging, blocking, or modification. */ -import { createAgentSession, type HookFactory, SessionManager } from "../../src/index.js"; +import { createAgentSession, type HookFactory, SessionManager } from "@mariozechner/pi-coding-agent"; // Logging hook const loggingHook: HookFactory = (api) => { diff --git a/packages/coding-agent/examples/sdk/07-context-files.ts b/packages/coding-agent/examples/sdk/07-context-files.ts index f2460c22..61aa871a 100644 --- a/packages/coding-agent/examples/sdk/07-context-files.ts +++ b/packages/coding-agent/examples/sdk/07-context-files.ts @@ -4,7 +4,7 @@ * Context files provide project-specific instructions loaded into the system prompt. */ -import { createAgentSession, discoverContextFiles, SessionManager } from "../../src/index.js"; +import { createAgentSession, discoverContextFiles, SessionManager } from "@mariozechner/pi-coding-agent"; // Discover AGENTS.md files walking up from cwd const discovered = discoverContextFiles(); diff --git a/packages/coding-agent/examples/sdk/08-slash-commands.ts b/packages/coding-agent/examples/sdk/08-slash-commands.ts index 5415eeaa..8c8dc08b 100644 --- a/packages/coding-agent/examples/sdk/08-slash-commands.ts +++ b/packages/coding-agent/examples/sdk/08-slash-commands.ts @@ -4,7 +4,12 @@ * File-based commands that inject content when invoked with /commandname. */ -import { createAgentSession, discoverSlashCommands, type FileSlashCommand, SessionManager } from "../../src/index.js"; +import { + createAgentSession, + discoverSlashCommands, + type FileSlashCommand, + SessionManager, +} from "@mariozechner/pi-coding-agent"; // Discover commands from cwd/.pi/commands/ and ~/.pi/agent/commands/ const discovered = discoverSlashCommands(); diff --git a/packages/coding-agent/examples/sdk/09-api-keys-and-oauth.ts b/packages/coding-agent/examples/sdk/09-api-keys-and-oauth.ts index 98e05e39..22cbf98b 100644 --- a/packages/coding-agent/examples/sdk/09-api-keys-and-oauth.ts +++ b/packages/coding-agent/examples/sdk/09-api-keys-and-oauth.ts @@ -11,7 +11,7 @@ import { discoverModels, ModelRegistry, SessionManager, -} from "../../src/index.js"; +} from "@mariozechner/pi-coding-agent"; // Default: discoverAuthStorage() uses ~/.pi/agent/auth.json // discoverModels() loads built-in + custom models from ~/.pi/agent/models.json diff --git a/packages/coding-agent/examples/sdk/10-settings.ts b/packages/coding-agent/examples/sdk/10-settings.ts index db11641c..a5451e2e 100644 --- a/packages/coding-agent/examples/sdk/10-settings.ts +++ b/packages/coding-agent/examples/sdk/10-settings.ts @@ -4,7 +4,7 @@ * Override settings using SettingsManager. */ -import { createAgentSession, loadSettings, SessionManager, SettingsManager } from "../../src/index.js"; +import { createAgentSession, loadSettings, SessionManager, SettingsManager } from "@mariozechner/pi-coding-agent"; // Load current settings (merged global + project) const settings = loadSettings(); diff --git a/packages/coding-agent/examples/sdk/11-sessions.ts b/packages/coding-agent/examples/sdk/11-sessions.ts index 7a883fb4..f1bbd047 100644 --- a/packages/coding-agent/examples/sdk/11-sessions.ts +++ b/packages/coding-agent/examples/sdk/11-sessions.ts @@ -4,7 +4,7 @@ * Control session persistence: in-memory, new file, continue, or open specific. */ -import { createAgentSession, SessionManager } from "../../src/index.js"; +import { createAgentSession, SessionManager } from "@mariozechner/pi-coding-agent"; // In-memory (no persistence) const { session: inMemory } = await createAgentSession({ diff --git a/packages/coding-agent/examples/sdk/12-full-control.ts b/packages/coding-agent/examples/sdk/12-full-control.ts index 5dbe7718..8ae7f5a4 100644 --- a/packages/coding-agent/examples/sdk/12-full-control.ts +++ b/packages/coding-agent/examples/sdk/12-full-control.ts @@ -9,7 +9,6 @@ */ import { getModel } from "@mariozechner/pi-ai"; -import { Type } from "@sinclair/typebox"; import { AuthStorage, type CustomTool, @@ -20,7 +19,8 @@ import { ModelRegistry, SessionManager, SettingsManager, -} from "../../src/index.js"; +} from "@mariozechner/pi-coding-agent"; +import { Type } from "@sinclair/typebox"; // Custom auth storage location const authStorage = new AuthStorage("/tmp/my-agent/auth.json"); diff --git a/packages/coding-agent/src/core/custom-tools/loader.ts b/packages/coding-agent/src/core/custom-tools/loader.ts index 0c51ff95..b7c38472 100644 --- a/packages/coding-agent/src/core/custom-tools/loader.ts +++ b/packages/coding-agent/src/core/custom-tools/loader.ts @@ -90,7 +90,9 @@ function createNoOpUIContext(): HookUIContext { confirm: async () => false, input: async () => undefined, notify: () => {}, - custom: () => ({ close: () => {}, requestRender: () => {} }), + custom: async () => undefined as never, + setEditorText: () => {}, + getEditorText: () => "", }; } diff --git a/packages/coding-agent/src/core/hooks/runner.ts b/packages/coding-agent/src/core/hooks/runner.ts index da15fc85..6be8b759 100644 --- a/packages/coding-agent/src/core/hooks/runner.ts +++ b/packages/coding-agent/src/core/hooks/runner.ts @@ -39,7 +39,9 @@ const noOpUIContext: HookUIContext = { confirm: async () => false, input: async () => undefined, notify: () => {}, - custom: () => ({ close: () => {}, requestRender: () => {} }), + custom: async () => undefined as never, + setEditorText: () => {}, + getEditorText: () => "", }; /** diff --git a/packages/coding-agent/src/core/hooks/tool-wrapper.ts b/packages/coding-agent/src/core/hooks/tool-wrapper.ts index c3499d9f..28c718f0 100644 --- a/packages/coding-agent/src/core/hooks/tool-wrapper.ts +++ b/packages/coding-agent/src/core/hooks/tool-wrapper.ts @@ -46,30 +46,46 @@ export function wrapToolWithHooks(tool: AgentTool, hookRunner: HookRu } // Execute the actual tool, forwarding onUpdate for progress streaming - const result = await tool.execute(toolCallId, params, signal, onUpdate); + try { + const result = await tool.execute(toolCallId, params, signal, onUpdate); - // Emit tool_result event - hooks can modify the result - if (hookRunner.hasHandlers("tool_result")) { - const resultResult = (await hookRunner.emit({ - type: "tool_result", - toolName: tool.name, - toolCallId, - input: params, - content: result.content, - details: result.details, - isError: false, - })) as ToolResultEventResult | undefined; + // Emit tool_result event - hooks can modify the result + if (hookRunner.hasHandlers("tool_result")) { + const resultResult = (await hookRunner.emit({ + type: "tool_result", + toolName: tool.name, + toolCallId, + input: params, + content: result.content, + details: result.details, + isError: false, + })) as ToolResultEventResult | undefined; - // Apply modifications if any - if (resultResult) { - return { - content: resultResult.content ?? result.content, - details: (resultResult.details ?? result.details) as T, - }; + // Apply modifications if any + if (resultResult) { + return { + content: resultResult.content ?? result.content, + details: (resultResult.details ?? result.details) as T, + }; + } } - } - return result; + return result; + } catch (err) { + // Emit tool_result event for errors so hooks can observe failures + if (hookRunner.hasHandlers("tool_result")) { + await hookRunner.emit({ + type: "tool_result", + toolName: tool.name, + toolCallId, + input: params, + content: [{ type: "text", text: err instanceof Error ? err.message : String(err) }], + details: undefined, + isError: true, + }); + } + throw err; // Re-throw original error for agent-loop + } }, }; } diff --git a/packages/coding-agent/src/core/hooks/types.ts b/packages/coding-agent/src/core/hooks/types.ts index 6acc843a..65ab9c0d 100644 --- a/packages/coding-agent/src/core/hooks/types.ts +++ b/packages/coding-agent/src/core/hooks/types.ts @@ -7,7 +7,7 @@ import type { AgentMessage } from "@mariozechner/pi-agent-core"; import type { ImageContent, Message, Model, TextContent, ToolResultMessage } from "@mariozechner/pi-ai"; -import type { Component } from "@mariozechner/pi-tui"; +import type { Component, TUI } from "@mariozechner/pi-tui"; import type { Theme } from "../../modes/interactive/theme/theme.js"; import type { CompactionPreparation, CompactionResult } from "../compaction/index.js"; import type { ExecOptions, ExecResult } from "../exec.js"; @@ -59,12 +59,48 @@ export interface HookUIContext { /** * Show a custom component with keyboard focus. - * The component receives keyboard input via handleInput() if implemented. + * The factory receives TUI, theme, and a done() callback to close the component. + * Can be async for fire-and-forget work (don't await the work, just start it). * - * @param component - Component to display (implement handleInput for keyboard, dispose for cleanup) - * @returns Object with close() to restore normal UI and requestRender() to trigger redraw + * @param factory - Function that creates the component. Call done() when finished. + * @returns Promise that resolves with the value passed to done() + * + * @example + * // Sync factory + * const result = await ctx.ui.custom((tui, theme, done) => { + * const component = new MyComponent(tui, theme); + * component.onFinish = (value) => done(value); + * return component; + * }); + * + * // Async factory with fire-and-forget work + * const result = await ctx.ui.custom(async (tui, theme, done) => { + * const loader = new CancellableLoader(tui, theme.fg("accent"), theme.fg("muted"), "Working..."); + * loader.onAbort = () => done(null); + * doWork(loader.signal).then(done); // Don't await - fire and forget + * return loader; + * }); */ - custom(component: Component & { dispose?(): void }): { close: () => void; requestRender: () => void }; + custom( + factory: ( + tui: TUI, + theme: Theme, + done: (result: T) => void, + ) => (Component & { dispose?(): void }) | Promise, + ): Promise; + + /** + * Set the text in the core input editor. + * Use this to pre-fill the input box with generated content (e.g., prompt templates, extracted questions). + * @param text - Text to set in the editor + */ + setEditorText(text: string): void; + + /** + * Get the current text from the core input editor. + * @returns Current editor text + */ + getEditorText(): string; } /** diff --git a/packages/coding-agent/src/core/sdk.ts b/packages/coding-agent/src/core/sdk.ts index 733bcec5..e24cd6e7 100644 --- a/packages/coding-agent/src/core/sdk.ts +++ b/packages/coding-agent/src/core/sdk.ts @@ -140,7 +140,7 @@ export interface CreateAgentSessionResult { // Re-exports export type { CustomTool } from "./custom-tools/types.js"; -export type { HookAPI, HookFactory } from "./hooks/types.js"; +export type { HookAPI, HookContext, HookFactory } from "./hooks/types.js"; export type { Settings, SkillsSettings } from "./settings-manager.js"; export type { Skill } from "./skills.js"; export type { FileSlashCommand } from "./slash-commands.js"; diff --git a/packages/coding-agent/src/index.ts b/packages/coding-agent/src/index.ts index 0eeaef4c..f9265440 100644 --- a/packages/coding-agent/src/index.ts +++ b/packages/coding-agent/src/index.ts @@ -88,6 +88,10 @@ export { discoverSkills, discoverSlashCommands, type FileSlashCommand, + // Hook types + type HookAPI, + type HookContext, + type HookFactory, loadSettings, // Pre-built tools (use process.cwd()) readOnlyTools, @@ -150,5 +154,7 @@ export { } from "./core/tools/index.js"; // Main entry point export { main } from "./main.js"; +// UI components for hooks +export { BorderedLoader } from "./modes/interactive/components/bordered-loader.js"; // Theme utilities for custom tools export { getMarkdownTheme } from "./modes/interactive/theme/theme.js"; diff --git a/packages/coding-agent/src/modes/interactive/components/bordered-loader.ts b/packages/coding-agent/src/modes/interactive/components/bordered-loader.ts new file mode 100644 index 00000000..811ef9f7 --- /dev/null +++ b/packages/coding-agent/src/modes/interactive/components/bordered-loader.ts @@ -0,0 +1,41 @@ +import { CancellableLoader, Container, Spacer, Text, type TUI } from "@mariozechner/pi-tui"; +import type { Theme } from "../theme/theme.js"; +import { DynamicBorder } from "./dynamic-border.js"; + +/** Loader wrapped with borders for hook UI */ +export class BorderedLoader extends Container { + private loader: CancellableLoader; + + constructor(tui: TUI, theme: Theme, message: string) { + super(); + this.addChild(new DynamicBorder()); + this.addChild(new Spacer(1)); + this.loader = new CancellableLoader( + tui, + (s) => theme.fg("accent", s), + (s) => theme.fg("muted", s), + message, + ); + this.addChild(this.loader); + this.addChild(new Spacer(1)); + this.addChild(new Text(theme.fg("muted", "esc cancel"), 1, 0)); + this.addChild(new Spacer(1)); + this.addChild(new DynamicBorder()); + } + + get signal(): AbortSignal { + return this.loader.signal; + } + + set onAbort(fn: (() => void) | undefined) { + this.loader.onAbort = fn; + } + + handleInput(data: string): void { + this.loader.handleInput(data); + } + + dispose(): void { + this.loader.dispose(); + } +} diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index 42ef8be6..eb520f46 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -54,7 +54,15 @@ import { ToolExecutionComponent } from "./components/tool-execution.js"; import { TreeSelectorComponent } from "./components/tree-selector.js"; import { UserMessageComponent } from "./components/user-message.js"; import { UserMessageSelectorComponent } from "./components/user-message-selector.js"; -import { getAvailableThemes, getEditorTheme, getMarkdownTheme, onThemeChange, setTheme, theme } from "./theme/theme.js"; +import { + getAvailableThemes, + getEditorTheme, + getMarkdownTheme, + onThemeChange, + setTheme, + type Theme, + theme, +} from "./theme/theme.js"; /** Interface for components that can be expanded/collapsed */ interface Expandable { @@ -356,7 +364,9 @@ export class InteractiveMode { confirm: (title, message) => this.showHookConfirm(title, message), input: (title, placeholder) => this.showHookInput(title, placeholder), notify: (message, type) => this.showHookNotify(message, type), - custom: (component) => this.showHookCustom(component), + custom: (factory) => this.showHookCustom(factory), + setEditorText: (text) => this.editor.setText(text), + getEditorText: () => this.editor.getText(), }; this.setToolUIContext(uiContext, true); @@ -537,38 +547,37 @@ export class InteractiveMode { /** * Show a custom component with keyboard focus. - * Returns a function to call when done. */ - private showHookCustom(component: Component & { dispose?(): void }): { - close: () => void; - requestRender: () => void; - } { - // Store current editor content + private async showHookCustom( + factory: ( + tui: TUI, + theme: Theme, + done: (result: T) => void, + ) => (Component & { dispose?(): void }) | Promise, + ): Promise { const savedText = this.editor.getText(); - // Replace editor with custom component - this.editorContainer.clear(); - this.editorContainer.addChild(component); - this.ui.setFocus(component); - this.ui.requestRender(); + return new Promise((resolve) => { + let component: Component & { dispose?(): void }; - // Return control object - return { - close: () => { - // Call dispose if available + const close = (result: T) => { component.dispose?.(); - - // Restore editor this.editorContainer.clear(); this.editorContainer.addChild(this.editor); this.editor.setText(savedText); this.ui.setFocus(this.editor); this.ui.requestRender(); - }, - requestRender: () => { + resolve(result); + }; + + Promise.resolve(factory(this.ui, theme, close)).then((c) => { + component = c; + this.editorContainer.clear(); + this.editorContainer.addChild(component); + this.ui.setFocus(component); this.ui.requestRender(); - }, - }; + }); + }); } /** diff --git a/packages/coding-agent/src/modes/rpc/rpc-mode.ts b/packages/coding-agent/src/modes/rpc/rpc-mode.ts index 09a0fde6..75f33db6 100644 --- a/packages/coding-agent/src/modes/rpc/rpc-mode.ts +++ b/packages/coding-agent/src/modes/rpc/rpc-mode.ts @@ -119,9 +119,25 @@ export async function runRpcMode(session: AgentSession): Promise { } as RpcHookUIRequest); }, - custom() { + async custom() { // Custom UI not supported in RPC mode - return { close: () => {}, requestRender: () => {} }; + return undefined as never; + }, + + setEditorText(text: string): void { + // Fire and forget - host can implement editor control + output({ + type: "hook_ui_request", + id: crypto.randomUUID(), + method: "set_editor_text", + text, + } as RpcHookUIRequest); + }, + + getEditorText(): string { + // Synchronous method can't wait for RPC response + // Host should track editor state locally if needed + return ""; }, }); diff --git a/packages/coding-agent/src/modes/rpc/rpc-types.ts b/packages/coding-agent/src/modes/rpc/rpc-types.ts index aa525687..8ab21247 100644 --- a/packages/coding-agent/src/modes/rpc/rpc-types.ts +++ b/packages/coding-agent/src/modes/rpc/rpc-types.ts @@ -181,7 +181,8 @@ export type RpcHookUIRequest = method: "notify"; message: string; notifyType?: "info" | "warning" | "error"; - }; + } + | { type: "hook_ui_request"; id: string; method: "set_editor_text"; text: string }; // ============================================================================ // Hook UI Commands (stdin) diff --git a/packages/coding-agent/test/compaction-hooks.test.ts b/packages/coding-agent/test/compaction-hooks.test.ts index d1fce41e..a4fd1eea 100644 --- a/packages/coding-agent/test/compaction-hooks.test.ts +++ b/packages/coding-agent/test/compaction-hooks.test.ts @@ -108,7 +108,9 @@ describe.skipIf(!API_KEY)("Compaction hooks", () => { confirm: async () => false, input: async () => undefined, notify: () => {}, - custom: () => ({ close: () => {}, requestRender: () => {} }), + custom: async () => undefined as never, + setEditorText: () => {}, + getEditorText: () => "", }, hasUI: false, }); diff --git a/packages/tui/README.md b/packages/tui/README.md index bea046d4..c93a13b2 100644 --- a/packages/tui/README.md +++ b/packages/tui/README.md @@ -247,6 +247,26 @@ loader.setMessage("Still loading..."); loader.stop(); ``` +### CancellableLoader + +Extends Loader with Escape key handling and an AbortSignal for cancelling async operations. + +```typescript +const loader = new CancellableLoader( + tui, // TUI instance for render updates + (s) => chalk.cyan(s), // spinner color function + (s) => chalk.gray(s), // message color function + "Working..." // message +); +loader.onAbort = () => done(null); // Called when user presses Escape +doAsyncWork(loader.signal).then(done); +``` + +**Properties:** +- `signal: AbortSignal` - Aborted when user presses Escape +- `aborted: boolean` - Whether the loader was aborted +- `onAbort?: () => void` - Callback when user presses Escape + ### SelectList Interactive selection list with keyboard navigation. diff --git a/packages/tui/src/components/cancellable-loader.ts b/packages/tui/src/components/cancellable-loader.ts new file mode 100644 index 00000000..8e2621da --- /dev/null +++ b/packages/tui/src/components/cancellable-loader.ts @@ -0,0 +1,39 @@ +import { isEscape } from "../keys.js"; +import { Loader } from "./loader.js"; + +/** + * Loader that can be cancelled with Escape. + * Extends Loader with an AbortSignal for cancelling async operations. + * + * @example + * const loader = new CancellableLoader(tui, cyan, dim, "Working..."); + * loader.onAbort = () => done(null); + * doWork(loader.signal).then(done); + */ +export class CancellableLoader extends Loader { + private abortController = new AbortController(); + + /** Called when user presses Escape */ + onAbort?: () => void; + + /** AbortSignal that is aborted when user presses Escape */ + get signal(): AbortSignal { + return this.abortController.signal; + } + + /** Whether the loader was aborted */ + get aborted(): boolean { + return this.abortController.signal.aborted; + } + + handleInput(data: string): void { + if (isEscape(data)) { + this.abortController.abort(); + this.onAbort?.(); + } + } + + dispose(): void { + this.stop(); + } +} diff --git a/packages/tui/src/index.ts b/packages/tui/src/index.ts index 8fcff1e5..d5a16207 100644 --- a/packages/tui/src/index.ts +++ b/packages/tui/src/index.ts @@ -9,6 +9,7 @@ export { } from "./autocomplete.js"; // Components export { Box } from "./components/box.js"; +export { CancellableLoader } from "./components/cancellable-loader.js"; export { Editor, type EditorTheme } from "./components/editor.js"; export { Image, type ImageOptions, type ImageTheme } from "./components/image.js"; export { Input } from "./components/input.js";