diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 2bbe92b5..67d7561a 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -38,9 +38,8 @@ - Hook API: `pi.getActiveTools()` and `pi.setActiveTools(toolNames)` for dynamically enabling/disabling tools from hooks - Hook API: `pi.getAllTools()` to enumerate all configured tools (built-in via --tools or default, plus custom tools) - Hook API: `pi.registerFlag(name, options)` and `pi.getFlag(name)` for hooks to register custom CLI flags (parsed automatically) -- Hook API: `pi.registerShortcut(shortcut, options)` for hooks to register custom keyboard shortcuts (e.g., `shift+p`, `ctrl+shift+x`). Conflicts with built-in shortcuts are skipped, conflicts between hooks logged as warnings. -- Hook API: `ctx.ui.setWidget(key, lines)` for multi-line status displays above the editor (todo lists, progress tracking) -- Hook API: `ctx.ui.setWidgetComponent(key, factory)` for custom TUI components as widgets (no focus, renders inline) +- Hook API: `pi.registerShortcut(shortcut, options)` for hooks to register custom keyboard shortcuts using `KeyId` (e.g., `Key.shift("p")`). Conflicts with built-in shortcuts are skipped, conflicts between hooks logged as warnings. +- Hook API: `ctx.ui.setWidget(key, content)` for status displays above the editor. Accepts either a string array or a component factory function. - Hook API: `theme.strikethrough(text)` for strikethrough text styling - `/hotkeys` command now shows hook-registered shortcuts in a separate "Hooks" section - New example hook: `plan-mode.ts` - Claude Code-style read-only exploration mode: diff --git a/packages/coding-agent/docs/hooks.md b/packages/coding-agent/docs/hooks.md index ac8d70c1..1cc0f506 100644 --- a/packages/coding-agent/docs/hooks.md +++ b/packages/coding-agent/docs/hooks.md @@ -446,22 +446,22 @@ const currentText = ctx.ui.getEditorText(); **Widget notes:** - Widgets are multi-line displays shown above the editor (below "Working..." indicator) - Multiple hooks can set widgets using unique keys (all widgets are displayed, stacked vertically) -- Use `setWidget()` for simple styled text, `setWidgetComponent()` for custom components +- `setWidget()` accepts either a string array or a component factory function - Supports ANSI styling via `ctx.ui.theme` (including `strikethrough`) - **Caution:** Keep widgets small (a few lines). Large widgets from multiple hooks can cause viewport overflow and TUI flicker. Max 10 lines total across all string widgets. **Custom widget components:** -For more complex widgets, use `setWidgetComponent()` to render a custom TUI component: +For more complex widgets, pass a factory function to `setWidget()`: ```typescript -ctx.ui.setWidgetComponent("my-widget", (tui, theme) => { +ctx.ui.setWidget("my-widget", (tui, theme) => { // Return any Component that implements render(width): string[] return new MyCustomComponent(tui, theme); }); // Clear the widget -ctx.ui.setWidgetComponent("my-widget", undefined); +ctx.ui.setWidget("my-widget", undefined); ``` Unlike `ctx.ui.custom()`, widget components do NOT take keyboard focus - they render inline above the editor. @@ -815,7 +815,7 @@ pi.setActiveTools(["read", "bash", "grep", "find", "ls"]); pi.setActiveTools(["read", "bash", "edit", "write"]); ``` -Only built-in tools can be enabled/disabled. Custom tools are always active. Unknown tool names are ignored. +Both built-in and custom tools can be enabled/disabled. Unknown tool names are ignored. ### pi.registerFlag(name, options) diff --git a/packages/coding-agent/examples/hooks/plan-mode.ts b/packages/coding-agent/examples/hooks/plan-mode.ts index b05a8646..56a2ea35 100644 --- a/packages/coding-agent/examples/hooks/plan-mode.ts +++ b/packages/coding-agent/examples/hooks/plan-mode.ts @@ -20,6 +20,7 @@ */ import type { HookAPI, HookContext } from "@mariozechner/pi-coding-agent/hooks"; +import { Key } from "@mariozechner/pi-tui"; // Read-only tools for plan mode const PLAN_MODE_TOOLS = ["read", "bash", "grep", "find", "ls"]; @@ -292,7 +293,7 @@ export default function planModeHook(pi: HookAPI) { }); // Register Shift+P shortcut - pi.registerShortcut("shift+p", { + pi.registerShortcut(Key.shift("p"), { description: "Toggle plan mode", handler: async (ctx) => { togglePlanMode(ctx); diff --git a/packages/coding-agent/src/core/custom-tools/loader.ts b/packages/coding-agent/src/core/custom-tools/loader.ts index 517f3f42..1f72c41e 100644 --- a/packages/coding-agent/src/core/custom-tools/loader.ts +++ b/packages/coding-agent/src/core/custom-tools/loader.ts @@ -93,7 +93,6 @@ function createNoOpUIContext(): HookUIContext { notify: () => {}, setStatus: () => {}, setWidget: () => {}, - setWidgetComponent: () => {}, custom: async () => undefined as never, setEditorText: () => {}, getEditorText: () => "", diff --git a/packages/coding-agent/src/core/hooks/loader.ts b/packages/coding-agent/src/core/hooks/loader.ts index 6e66b47c..aad9cff7 100644 --- a/packages/coding-agent/src/core/hooks/loader.ts +++ b/packages/coding-agent/src/core/hooks/loader.ts @@ -7,6 +7,7 @@ import { createRequire } from "node:module"; import * as os from "node:os"; import * as path from "node:path"; import { fileURLToPath } from "node:url"; +import type { KeyId } from "@mariozechner/pi-tui"; import { createJiti } from "jiti"; import { getAgentDir } from "../../config.js"; import type { HookMessage } from "../messages.js"; @@ -103,8 +104,8 @@ export interface HookFlag { * Keyboard shortcut registered by a hook. */ export interface HookShortcut { - /** Shortcut string (e.g., "shift+p", "ctrl+shift+x") */ - shortcut: string; + /** Key identifier (e.g., Key.shift("p"), "ctrl+x") */ + shortcut: KeyId; /** Description for help */ description?: string; /** Handler function */ @@ -153,7 +154,7 @@ export interface LoadedHook { /** Flag values (set after CLI parsing) */ flagValues: Map; /** Keyboard shortcuts registered by this hook */ - shortcuts: Map; + shortcuts: Map; /** Set the send message handler for this hook's pi.sendMessage() */ setSendMessageHandler: (handler: SendMessageHandler) => void; /** Set the append entry handler for this hook's pi.appendEntry() */ @@ -226,7 +227,7 @@ function createHookAPI( commands: Map; flags: Map; flagValues: Map; - shortcuts: Map; + shortcuts: Map; setSendMessageHandler: (handler: SendMessageHandler) => void; setAppendEntryHandler: (handler: AppendEntryHandler) => void; setGetActiveToolsHandler: (handler: GetActiveToolsHandler) => void; @@ -249,7 +250,7 @@ function createHookAPI( const commands = new Map(); const flags = new Map(); const flagValues = new Map(); - const shortcuts = new Map(); + const shortcuts = new Map(); // Cast to HookAPI - the implementation is more general (string event names) // but the interface has specific overloads for type safety in hooks @@ -300,7 +301,7 @@ function createHookAPI( return flagValues.get(name); }, registerShortcut( - shortcut: string, + shortcut: KeyId, options: { description?: string; handler: (ctx: HookContext) => Promise | void; diff --git a/packages/coding-agent/src/core/hooks/runner.ts b/packages/coding-agent/src/core/hooks/runner.ts index 620e5367..d3589e31 100644 --- a/packages/coding-agent/src/core/hooks/runner.ts +++ b/packages/coding-agent/src/core/hooks/runner.ts @@ -4,6 +4,7 @@ import type { AgentMessage } from "@mariozechner/pi-agent-core"; import type { ImageContent, Model } from "@mariozechner/pi-ai"; +import type { KeyId } from "@mariozechner/pi-tui"; import { theme } from "../../modes/interactive/theme/theme.js"; import type { ModelRegistry } from "../model-registry.js"; import type { SessionManager } from "../session-manager.js"; @@ -52,7 +53,6 @@ const noOpUIContext: HookUIContext = { notify: () => {}, setStatus: () => {}, setWidget: () => {}, - setWidgetComponent: () => {}, custom: async () => undefined as never, setEditorText: () => {}, getEditorText: () => "", @@ -223,11 +223,12 @@ export class HookRunner { * Conflicts with built-in shortcuts are skipped with a warning. * Conflicts between hooks are logged as warnings. */ - getShortcuts(): Map { - const allShortcuts = new Map(); + getShortcuts(): Map { + const allShortcuts = new Map(); for (const hook of this.hooks) { for (const [key, shortcut] of hook.shortcuts) { - const normalizedKey = key.toLowerCase(); + // Normalize to lowercase for comparison (KeyId is string at runtime) + const normalizedKey = key.toLowerCase() as KeyId; // Check for built-in shortcut conflicts if (HookRunner.RESERVED_SHORTCUTS.has(normalizedKey)) { diff --git a/packages/coding-agent/src/core/hooks/types.ts b/packages/coding-agent/src/core/hooks/types.ts index 0b59ce67..0f98865c 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, Model, TextContent, ToolResultMessage } from "@mariozechner/pi-ai"; -import type { Component, TUI } from "@mariozechner/pi-tui"; +import type { Component, KeyId, 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"; @@ -79,13 +79,13 @@ export interface HookUIContext { * Supports multi-line content. Pass undefined to clear. * Text can include ANSI escape codes for styling. * - * For simple text displays, use this method. For custom components, use setWidgetComponent(). + * Accepts either an array of styled strings, or a factory function that creates a Component. * * @param key - Unique key to identify this widget (e.g., hook name) - * @param lines - Array of lines to display, or undefined to clear + * @param content - Array of lines to display, or undefined to clear * * @example - * // Show a todo list + * // Show a todo list with styled strings * ctx.ui.setWidget("plan-todos", [ * theme.fg("accent", "Plan Progress:"), * "☑ " + theme.fg("muted", theme.strikethrough("Step 1: Read files")), @@ -96,7 +96,7 @@ export interface HookUIContext { * // Clear the widget * ctx.ui.setWidget("plan-todos", undefined); */ - setWidget(key: string, lines: string[] | undefined): void; + setWidget(key: string, content: string[] | undefined): void; /** * Set a custom component as a widget (above the editor, below "Working..." indicator). @@ -107,21 +107,18 @@ export interface HookUIContext { * Components are rendered inline without taking focus - they cannot handle keyboard input. * * @param key - Unique key to identify this widget (e.g., hook name) - * @param factory - Function that creates the component, or undefined to clear + * @param content - Factory function that creates the component, or undefined to clear * * @example * // Show a custom progress component - * ctx.ui.setWidgetComponent("my-progress", (tui, theme) => { + * ctx.ui.setWidget("my-progress", (tui, theme) => { * return new MyProgressComponent(tui, theme); * }); * * // Clear the widget - * ctx.ui.setWidgetComponent("my-progress", undefined); + * ctx.ui.setWidget("my-progress", undefined); */ - setWidgetComponent( - key: string, - factory: ((tui: TUI, theme: Theme) => Component & { dispose?(): void }) | undefined, - ): void; + setWidget(key: string, content: ((tui: TUI, theme: Theme) => Component & { dispose?(): void }) | undefined): void; /** * Show a custom component with keyboard focus. @@ -813,7 +810,7 @@ export interface HookAPI { /** * Set the active tools by name. - * Only built-in tools can be enabled/disabled. Custom tools are always active. + * Both built-in and custom tools can be enabled/disabled. * Changes take effect on the next agent turn. * Note: This will invalidate prompt caching for the next request. * @@ -871,11 +868,13 @@ export interface HookAPI { * Register a keyboard shortcut for this hook. * The handler is called when the shortcut is pressed in interactive mode. * - * @param shortcut - Shortcut definition (e.g., "shift+p", "ctrl+shift+x") + * @param shortcut - Key identifier (e.g., Key.shift("p"), "ctrl+x") * @param options - Shortcut configuration * * @example - * pi.registerShortcut("shift+p", { + * import { Key } from "@mariozechner/pi-tui"; + * + * pi.registerShortcut(Key.shift("p"), { * description: "Toggle plan mode", * handler: async (ctx) => { * // toggle plan mode @@ -883,7 +882,7 @@ export interface HookAPI { * }); */ registerShortcut( - shortcut: string, + shortcut: KeyId, options: { /** Description shown in help */ description?: string; diff --git a/packages/coding-agent/src/core/sdk.ts b/packages/coding-agent/src/core/sdk.ts index 5d6ee273..c929311c 100644 --- a/packages/coding-agent/src/core/sdk.ts +++ b/packages/coding-agent/src/core/sdk.ts @@ -31,6 +31,7 @@ import { Agent, type AgentTool, type ThinkingLevel } from "@mariozechner/pi-agent-core"; import type { Model } from "@mariozechner/pi-ai"; +import type { KeyId } from "@mariozechner/pi-tui"; import { join } from "path"; import { getAgentDir } from "../config.js"; import { AgentSession } from "./agent-session.js"; @@ -349,7 +350,7 @@ function createLoadedHooksFromDefinitions(definitions: Array<{ path?: string; fa const commands = new Map(); const flags = new Map(); const flagValues = new Map(); - const shortcuts = new Map(); + const shortcuts = new Map(); let sendMessageHandler: ( message: any, options?: { triggerTurn?: boolean; deliverAs?: "steer" | "followUp" }, @@ -389,7 +390,7 @@ function createLoadedHooksFromDefinitions(definitions: Array<{ path?: string; fa } }, getFlag: (name: string) => flagValues.get(name), - registerShortcut: (shortcut: string, options: any) => { + registerShortcut: (shortcut: KeyId, options: any) => { shortcuts.set(shortcut, { shortcut, hookPath, ...options }); }, newSession: (options?: any) => newSessionHandler(options), diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index db69e5f6..6ba524a8 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -141,9 +141,8 @@ export class InteractiveMode { private hookInput: HookInputComponent | undefined = undefined; private hookEditor: HookEditorComponent | undefined = undefined; - // Hook widgets (multi-line status displays or custom components) - private hookWidgets = new Map(); - private hookWidgetComponents = new Map(); + // Hook widgets (components rendered above the editor) + private hookWidgets = new Map(); private widgetContainer!: Container; // Custom tools for custom rendering @@ -627,41 +626,32 @@ export class InteractiveMode { } /** - * Set a hook widget (multi-line status display). + * Set a hook widget (string array or custom component). */ - private setHookWidget(key: string, lines: string[] | undefined): void { - if (lines === undefined) { - this.hookWidgets.delete(key); - } else { - // Clear any component widget with same key - const existing = this.hookWidgetComponents.get(key); - if (existing?.dispose) existing.dispose(); - this.hookWidgetComponents.delete(key); - - this.hookWidgets.set(key, lines); - } - this.renderWidgets(); - } - - /** - * Set a hook widget component (custom component without focus). - */ - private setHookWidgetComponent( + private setHookWidget( key: string, - factory: ((tui: TUI, thm: Theme) => Component & { dispose?(): void }) | undefined, + content: string[] | ((tui: TUI, thm: Theme) => Component & { dispose?(): void }) | undefined, ): void { - // Dispose existing component - const existing = this.hookWidgetComponents.get(key); + // Dispose and remove existing widget + const existing = this.hookWidgets.get(key); if (existing?.dispose) existing.dispose(); - if (factory === undefined) { - this.hookWidgetComponents.delete(key); - } else { - // Clear any string widget with same key + if (content === undefined) { this.hookWidgets.delete(key); - - const component = factory(this.ui, theme); - this.hookWidgetComponents.set(key, component); + } else if (Array.isArray(content)) { + // Wrap string array in a Container with Text components + const container = new Container(); + for (const line of content.slice(0, InteractiveMode.MAX_WIDGET_LINES)) { + container.addChild(new Text(line, 1, 0)); + } + if (content.length > InteractiveMode.MAX_WIDGET_LINES) { + container.addChild(new Text(theme.fg("muted", "... (widget truncated)"), 1, 0)); + } + this.hookWidgets.set(key, container); + } else { + // Factory function - create component + const component = content(this.ui, theme); + this.hookWidgets.set(key, component); } this.renderWidgets(); } @@ -676,30 +666,12 @@ export class InteractiveMode { if (!this.widgetContainer) return; this.widgetContainer.clear(); - const hasStringWidgets = this.hookWidgets.size > 0; - const hasComponentWidgets = this.hookWidgetComponents.size > 0; - - if (!hasStringWidgets && !hasComponentWidgets) { + if (this.hookWidgets.size === 0) { this.ui.requestRender(); return; } - // Render string widgets first, respecting max lines - let totalLines = 0; - for (const [_key, lines] of this.hookWidgets) { - for (const line of lines) { - if (totalLines >= InteractiveMode.MAX_WIDGET_LINES) { - this.widgetContainer.addChild(new Text(theme.fg("muted", "... (widget truncated)"), 1, 0)); - this.ui.requestRender(); - return; - } - this.widgetContainer.addChild(new Text(line, 1, 0)); - totalLines++; - } - } - - // Render component widgets - for (const [_key, component] of this.hookWidgetComponents) { + for (const [_key, component] of this.hookWidgets) { this.widgetContainer.addChild(component); } @@ -716,8 +688,7 @@ export class InteractiveMode { input: (title, placeholder) => this.showHookInput(title, placeholder), notify: (message, type) => this.showHookNotify(message, type), setStatus: (key, text) => this.setHookStatus(key, text), - setWidget: (key, lines) => this.setHookWidget(key, lines), - setWidgetComponent: (key, factory) => this.setHookWidgetComponent(key, factory), + setWidget: (key, content) => this.setHookWidget(key, content), custom: (factory) => this.showHookCustom(factory), setEditorText: (text) => this.editor.setText(text), getEditorText: () => this.editor.getText(), diff --git a/packages/coding-agent/src/modes/rpc/rpc-mode.ts b/packages/coding-agent/src/modes/rpc/rpc-mode.ts index c35e6222..1bc4ef10 100644 --- a/packages/coding-agent/src/modes/rpc/rpc-mode.ts +++ b/packages/coding-agent/src/modes/rpc/rpc-mode.ts @@ -131,19 +131,18 @@ export async function runRpcMode(session: AgentSession): Promise { } as RpcHookUIRequest); }, - setWidget(key: string, lines: string[] | undefined): void { - // Fire and forget - host can implement widget display - output({ - type: "hook_ui_request", - id: crypto.randomUUID(), - method: "setWidget", - widgetKey: key, - widgetLines: lines, - } as RpcHookUIRequest); - }, - - setWidgetComponent(): void { - // Custom components not supported in RPC mode - host would need to implement + setWidget(key: string, content: unknown): void { + // Only support string arrays in RPC mode - factory functions are ignored + if (content === undefined || Array.isArray(content)) { + output({ + type: "hook_ui_request", + id: crypto.randomUUID(), + method: "setWidget", + widgetKey: key, + widgetLines: content as string[] | undefined, + } as RpcHookUIRequest); + } + // Component factories are not supported in RPC mode - would need TUI access }, async custom() { diff --git a/packages/coding-agent/test/compaction-hooks.test.ts b/packages/coding-agent/test/compaction-hooks.test.ts index 7c2a21e7..99f2c1ab 100644 --- a/packages/coding-agent/test/compaction-hooks.test.ts +++ b/packages/coding-agent/test/compaction-hooks.test.ts @@ -121,7 +121,6 @@ describe.skipIf(!API_KEY)("Compaction hooks", () => { notify: () => {}, setStatus: () => {}, setWidget: () => {}, - setWidgetComponent: () => {}, custom: async () => undefined as never, setEditorText: () => {}, getEditorText: () => "",