From c956a726ed4ccfe1a22940764945bd780afc293d Mon Sep 17 00:00:00 2001 From: Helmut Januschka Date: Sat, 3 Jan 2026 09:52:13 +0100 Subject: [PATCH] feat(coding-agent): add hook API for CLI flags, shortcuts, and tool control Hook API additions: - pi.getTools() / pi.setTools(toolNames) - dynamically enable/disable tools - pi.registerFlag(name, options) / pi.getFlag(name) - register custom CLI flags - pi.registerShortcut(shortcut, options) - register keyboard shortcuts Plan mode hook (examples/hooks/plan-mode.ts): - /plan command or Shift+P shortcut to toggle - --plan CLI flag to start in plan mode - Read-only tools: read, bash, grep, find, ls - Bash restricted to non-destructive commands (blocks rm, mv, git commit, etc.) - Interactive prompt after each response: execute, stay, or refine - Shows plan indicator in footer when active - State persists across sessions --- packages/coding-agent/CHANGELOG.md | 4 +- packages/coding-agent/docs/hooks.md | 38 +++ .../coding-agent/examples/hooks/plan-mode.ts | 224 ++++++++++++++++-- packages/coding-agent/src/cli/args.ts | 19 +- packages/coding-agent/src/core/hooks/index.ts | 2 + .../coding-agent/src/core/hooks/loader.ts | 84 ++++++- .../coding-agent/src/core/hooks/runner.ts | 37 +++ packages/coding-agent/src/core/hooks/types.ts | 64 +++++ packages/coding-agent/src/core/sdk.ts | 31 ++- packages/coding-agent/src/main.ts | 42 +++- .../interactive/components/custom-editor.ts | 7 + .../src/modes/interactive/interactive-mode.ts | 114 +++++++-- .../test/compaction-hooks.test.ts | 16 ++ 13 files changed, 636 insertions(+), 46 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 52d63759..c3ee1280 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -38,7 +38,9 @@ - `$ARGUMENTS` syntax for custom slash commands as alternative to `$@` for all arguments joined. Aligns with patterns used by Claude, Codex, and OpenCode. Both syntaxes remain fully supported. ([#418](https://github.com/badlogic/pi-mono/pull/418) by [@skuridin](https://github.com/skuridin)) - Hook API: `pi.getTools()` and `pi.setTools(toolNames)` for dynamically enabling/disabling tools from hooks -- New example hook: `plan-mode.ts` - Claude Code-style read-only exploration mode with `/plan` command +- Hook API: `pi.registerFlag(name, options)` and `pi.getFlag(name)` for hooks to register custom CLI flags +- Hook API: `pi.registerShortcut(shortcut, options)` for hooks to register custom keyboard shortcuts +- New example hook: `plan-mode.ts` - Claude Code-style read-only exploration mode with `/plan` command, `--plan` flag, and Shift+P shortcut ### Changed diff --git a/packages/coding-agent/docs/hooks.md b/packages/coding-agent/docs/hooks.md index 7f30213c..ffd5bf5f 100644 --- a/packages/coding-agent/docs/hooks.md +++ b/packages/coding-agent/docs/hooks.md @@ -775,6 +775,44 @@ pi.setTools(["read", "bash", "edit", "write"]); Only built-in tools can be enabled/disabled. Custom tools are always active. Unknown tool names are ignored. +### pi.registerFlag(name, options) + +Register a CLI flag for this hook. Flag values are accessible via `pi.getFlag()`. + +```typescript +pi.registerFlag("plan", { + description: "Start in plan mode (read-only)", + type: "boolean", // or "string" + default: false, +}); +``` + +### pi.getFlag(name) + +Get the value of a CLI flag registered by this hook. + +```typescript +if (pi.getFlag("plan") === true) { + // plan mode enabled via --plan flag +} +``` + +### pi.registerShortcut(shortcut, options) + +Register a keyboard shortcut for this hook. The handler is called when the shortcut is pressed. + +```typescript +pi.registerShortcut("shift+p", { + description: "Toggle plan mode", + handler: async (ctx) => { + // toggle mode + ctx.ui.notify("Plan mode toggled"); + }, +}); +``` + +Shortcut format: `modifier+key` where modifier can be `shift`, `ctrl`, `alt`, or combinations like `ctrl+shift`. + ## Examples ### Permission Gate diff --git a/packages/coding-agent/examples/hooks/plan-mode.ts b/packages/coding-agent/examples/hooks/plan-mode.ts index a8fe073d..29f2aadb 100644 --- a/packages/coding-agent/examples/hooks/plan-mode.ts +++ b/packages/coding-agent/examples/hooks/plan-mode.ts @@ -9,13 +9,15 @@ * - In plan mode: only read, bash (read-only), grep, find, ls are available * - Injects system context telling the agent about the restrictions * - After each agent response, prompts to execute the plan or continue planning + * - Shows "plan" indicator in footer when active * * Usage: * 1. Copy this file to ~/.pi/agent/hooks/ or your project's .pi/hooks/ * 2. Use /plan to toggle plan mode on/off + * 3. Or start in plan mode: PI_PLAN_MODE=1 pi */ -import type { HookAPI } from "@mariozechner/pi-coding-agent/hooks"; +import type { HookAPI, HookContext } from "@mariozechner/pi-coding-agent/hooks"; // Read-only tools for plan mode const PLAN_MODE_TOOLS = ["read", "bash", "grep", "find", "ls"]; @@ -23,28 +25,190 @@ const PLAN_MODE_TOOLS = ["read", "bash", "grep", "find", "ls"]; // Full set of tools for normal mode const NORMAL_MODE_TOOLS = ["read", "bash", "edit", "write"]; +// Patterns for destructive bash commands that should be blocked in plan mode +const DESTRUCTIVE_PATTERNS = [ + // File/directory modification + /\brm\b/i, + /\brmdir\b/i, + /\bmv\b/i, + /\bcp\b/i, // cp can overwrite files + /\bmkdir\b/i, + /\btouch\b/i, + /\bchmod\b/i, + /\bchown\b/i, + /\bchgrp\b/i, + /\bln\b/i, // symlinks + // File content modification + /\btee\b/i, + /\btruncate\b/i, + /\bdd\b/i, + /\bshred\b/i, + // Redirects that write to files + /[^<]>(?!>)/, // > but not >> or <> + />>/, // append + // Package managers / installers + /\bnpm\s+(install|uninstall|update|ci|link|publish)/i, + /\byarn\s+(add|remove|install|publish)/i, + /\bpnpm\s+(add|remove|install|publish)/i, + /\bpip\s+(install|uninstall)/i, + /\bapt(-get)?\s+(install|remove|purge|update|upgrade)/i, + /\bbrew\s+(install|uninstall|upgrade)/i, + // Git write operations + /\bgit\s+(add|commit|push|pull|merge|rebase|reset|checkout\s+-b|branch\s+-[dD]|stash|cherry-pick|revert|tag|init|clone)/i, + // Other dangerous commands + /\bsudo\b/i, + /\bsu\b/i, + /\bkill\b/i, + /\bpkill\b/i, + /\bkillall\b/i, + /\breboot\b/i, + /\bshutdown\b/i, + /\bsystemctl\s+(start|stop|restart|enable|disable)/i, + /\bservice\s+\S+\s+(start|stop|restart)/i, + // Editors (interactive, could modify files) + /\b(vim?|nano|emacs|code|subl)\b/i, +]; + +// Read-only commands that are always safe +const SAFE_COMMANDS = [ + /^\s*cat\b/, + /^\s*head\b/, + /^\s*tail\b/, + /^\s*less\b/, + /^\s*more\b/, + /^\s*grep\b/, + /^\s*find\b/, + /^\s*ls\b/, + /^\s*pwd\b/, + /^\s*echo\b/, + /^\s*printf\b/, + /^\s*wc\b/, + /^\s*sort\b/, + /^\s*uniq\b/, + /^\s*diff\b/, + /^\s*file\b/, + /^\s*stat\b/, + /^\s*du\b/, + /^\s*df\b/, + /^\s*tree\b/, + /^\s*which\b/, + /^\s*whereis\b/, + /^\s*type\b/, + /^\s*env\b/, + /^\s*printenv\b/, + /^\s*uname\b/, + /^\s*whoami\b/, + /^\s*id\b/, + /^\s*date\b/, + /^\s*cal\b/, + /^\s*uptime\b/, + /^\s*ps\b/, + /^\s*top\b/, + /^\s*htop\b/, + /^\s*free\b/, + /^\s*git\s+(status|log|diff|show|branch|remote|config\s+--get)/i, + /^\s*git\s+ls-/i, + /^\s*npm\s+(list|ls|view|info|search|outdated|audit)/i, + /^\s*yarn\s+(list|info|why|audit)/i, + /^\s*node\s+--version/i, + /^\s*python\s+--version/i, + /^\s*curl\s/i, // curl without -o is usually safe (reading) + /^\s*wget\s+-O\s*-/i, // wget to stdout only + /^\s*jq\b/, + /^\s*sed\s+-n/i, // sed with -n (no auto-print) for reading only + /^\s*awk\b/, + /^\s*rg\b/, // ripgrep + /^\s*fd\b/, // fd-find + /^\s*bat\b/, // bat (cat clone) + /^\s*exa\b/, // exa (ls clone) +]; + +/** + * Check if a bash command is safe (read-only) for plan mode. + */ +function isSafeCommand(command: string): boolean { + // Check if it's an explicitly safe command + if (SAFE_COMMANDS.some((pattern) => pattern.test(command))) { + // But still check for destructive patterns (e.g., cat > file) + if (!DESTRUCTIVE_PATTERNS.some((pattern) => pattern.test(command))) { + return true; + } + } + + // Check for destructive patterns + if (DESTRUCTIVE_PATTERNS.some((pattern) => pattern.test(command))) { + return false; + } + + // Allow commands that don't match any destructive pattern + // This is permissive - unknown commands are allowed + return true; +} + export default function planModeHook(pi: HookAPI) { // Track plan mode state let planModeEnabled = false; + // Register --plan CLI flag + pi.registerFlag("plan", { + description: "Start in plan mode (read-only exploration)", + type: "boolean", + default: false, + }); + + // Helper to update footer status + function updateStatus(ctx: HookContext) { + if (planModeEnabled) { + ctx.ui.setStatus("plan-mode", ctx.ui.theme.fg("warning", "⏸ plan")); + } else { + ctx.ui.setStatus("plan-mode", undefined); + } + } + + // Helper to toggle plan mode + function togglePlanMode(ctx: HookContext) { + planModeEnabled = !planModeEnabled; + + if (planModeEnabled) { + pi.setTools(PLAN_MODE_TOOLS); + ctx.ui.notify(`Plan mode enabled. Tools: ${PLAN_MODE_TOOLS.join(", ")}`); + } else { + pi.setTools(NORMAL_MODE_TOOLS); + ctx.ui.notify("Plan mode disabled. Full access restored."); + } + updateStatus(ctx); + } + // Register /plan command pi.registerCommand("plan", { description: "Toggle plan mode (read-only exploration)", handler: async (_args, ctx) => { - planModeEnabled = !planModeEnabled; - - if (planModeEnabled) { - // Switch to read-only tools - pi.setTools(PLAN_MODE_TOOLS); - ctx.ui.notify(`Plan mode enabled. Tools: ${PLAN_MODE_TOOLS.join(", ")}`); - } else { - // Switch back to normal tools - pi.setTools(NORMAL_MODE_TOOLS); - ctx.ui.notify("Plan mode disabled. Full access restored."); - } + togglePlanMode(ctx); }, }); + // Register Shift+P shortcut + pi.registerShortcut("shift+p", { + description: "Toggle plan mode", + handler: async (ctx) => { + togglePlanMode(ctx); + }, + }); + + // Block destructive bash commands in plan mode + pi.on("tool_call", async (event) => { + if (!planModeEnabled) return; + if (event.toolName !== "bash") return; + + const command = event.input.command as string; + if (!isSafeCommand(command)) { + return { + block: true, + reason: `Plan mode: destructive command blocked. Use /plan to disable plan mode first.\nCommand: ${command}`, + }; + } + }); + // Inject plan mode context at the start of each turn via before_agent_start pi.on("before_agent_start", async () => { if (!planModeEnabled) return; @@ -57,8 +221,10 @@ export default function planModeHook(pi: HookAPI) { You are in plan mode - a read-only exploration mode for safe code analysis. Restrictions: -- You can only use: read, bash (read-only commands), grep, find, ls +- You can only use: read, bash, grep, find, ls - You CANNOT use: edit, write (file modifications are disabled) +- Bash is restricted to READ-ONLY commands (cat, ls, grep, git status, etc.) +- Destructive bash commands are BLOCKED (rm, mv, cp, git commit, npm install, etc.) - Focus on analysis, planning, and understanding the codebase Your task is to explore, analyze, and create a detailed plan. @@ -84,10 +250,17 @@ When you have a complete plan, I will switch to normal mode to execute it.`, // Switch to normal mode planModeEnabled = false; pi.setTools(NORMAL_MODE_TOOLS); - ctx.ui.notify("Switched to normal mode. Full access restored."); + updateStatus(ctx); - // Set editor text to prompt execution - ctx.ui.setEditorText("Execute the plan you just created. Proceed step by step."); + // Send message to trigger execution immediately + pi.sendMessage( + { + customType: "plan-mode-execute", + content: "Execute the plan you just created. Proceed step by step.", + display: true, + }, + { triggerTurn: true }, + ); } else if (choice === "Refine the plan") { const refinement = await ctx.ui.input("What should be refined?"); if (refinement) { @@ -97,17 +270,28 @@ When you have a complete plan, I will switch to normal mode to execute it.`, // "Stay in plan mode" - do nothing, just continue }); - // Persist plan mode state across sessions + // Initialize plan mode state on session start pi.on("session_start", async (_event, ctx) => { - // Check if there's persisted plan mode state + // Check --plan flag first + if (pi.getFlag("plan") === true) { + planModeEnabled = true; + } + + // Check if there's persisted plan mode state (from previous session) const entries = ctx.sessionManager.getEntries(); const planModeEntry = entries .filter((e: { type: string; customType?: string }) => e.type === "custom" && e.customType === "plan-mode") .pop() as { data?: { enabled: boolean } } | undefined; - if (planModeEntry?.data?.enabled) { - planModeEnabled = true; + // Restore from session (overrides flag if session has state) + if (planModeEntry?.data?.enabled !== undefined) { + planModeEnabled = planModeEntry.data.enabled; + } + + // Apply initial state if plan mode is enabled + if (planModeEnabled) { pi.setTools(PLAN_MODE_TOOLS); + updateStatus(ctx); } }); diff --git a/packages/coding-agent/src/cli/args.ts b/packages/coding-agent/src/cli/args.ts index 7094ab54..d51866e4 100644 --- a/packages/coding-agent/src/cli/args.ts +++ b/packages/coding-agent/src/cli/args.ts @@ -35,6 +35,8 @@ export interface Args { listModels?: string | true; messages: string[]; fileArgs: string[]; + /** Unknown flags (potentially hook flags) - map of flag name to value */ + unknownFlags: Map; } const VALID_THINKING_LEVELS = ["off", "minimal", "low", "medium", "high", "xhigh"] as const; @@ -43,10 +45,11 @@ export function isValidThinkingLevel(level: string): level is ThinkingLevel { return VALID_THINKING_LEVELS.includes(level as ThinkingLevel); } -export function parseArgs(args: string[]): Args { +export function parseArgs(args: string[], hookFlags?: Map): Args { const result: Args = { messages: [], fileArgs: [], + unknownFlags: new Map(), }; for (let i = 0; i < args.length; i++) { @@ -131,6 +134,18 @@ export function parseArgs(args: string[]): Args { } } else if (arg.startsWith("@")) { result.fileArgs.push(arg.slice(1)); // Remove @ prefix + } else if (arg.startsWith("--") && hookFlags) { + // Check if it's a hook-registered flag + const flagName = arg.slice(2); + const hookFlag = hookFlags.get(flagName); + if (hookFlag) { + if (hookFlag.type === "boolean") { + result.unknownFlags.set(flagName, true); + } else if (hookFlag.type === "string" && i + 1 < args.length) { + result.unknownFlags.set(flagName, args[++i]); + } + } + // Unknown flags without hookFlags are silently ignored (first pass) } else if (!arg.startsWith("-")) { result.messages.push(arg); } @@ -172,6 +187,8 @@ ${chalk.bold("Options:")} --help, -h Show this help --version, -v Show version number +Hooks can register additional flags (e.g., --plan from plan-mode hook). + ${chalk.bold("Examples:")} # Interactive mode ${APP_NAME} diff --git a/packages/coding-agent/src/core/hooks/index.ts b/packages/coding-agent/src/core/hooks/index.ts index 6d000397..04fc6d52 100644 --- a/packages/coding-agent/src/core/hooks/index.ts +++ b/packages/coding-agent/src/core/hooks/index.ts @@ -5,6 +5,8 @@ export { type AppendEntryHandler, type BranchHandler, type GetToolsHandler, + type HookFlag, + type HookShortcut, type LoadedHook, type LoadHooksResult, type NavigateTreeHandler, diff --git a/packages/coding-agent/src/core/hooks/loader.ts b/packages/coding-agent/src/core/hooks/loader.ts index 497a6e85..09a629e7 100644 --- a/packages/coding-agent/src/core/hooks/loader.ts +++ b/packages/coding-agent/src/core/hooks/loader.ts @@ -71,6 +71,36 @@ export type GetToolsHandler = () => string[]; */ export type SetToolsHandler = (toolNames: string[]) => void; +/** + * CLI flag definition registered by a hook. + */ +export interface HookFlag { + /** Flag name (without --) */ + name: string; + /** Description for --help */ + description?: string; + /** Type: boolean or string */ + type: "boolean" | "string"; + /** Default value */ + default?: boolean | string; + /** Hook path that registered this flag */ + hookPath: string; +} + +/** + * Keyboard shortcut registered by a hook. + */ +export interface HookShortcut { + /** Shortcut string (e.g., "shift+p", "ctrl+shift+x") */ + shortcut: string; + /** Description for help */ + description?: string; + /** Handler function */ + handler: (ctx: import("./types.js").HookContext) => Promise | void; + /** Hook path that registered this shortcut */ + hookPath: string; +} + /** * New session handler type for ctx.newSession() in HookCommandContext. */ @@ -106,6 +136,12 @@ export interface LoadedHook { messageRenderers: Map; /** Map of command name to registered command */ commands: Map; + /** CLI flags registered by this hook */ + flags: Map; + /** Flag values (set after CLI parsing) */ + flagValues: Map; + /** Keyboard shortcuts registered by this hook */ + 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() */ @@ -114,6 +150,8 @@ export interface LoadedHook { setGetToolsHandler: (handler: GetToolsHandler) => void; /** Set the set tools handler for this hook's pi.setTools() */ setSetToolsHandler: (handler: SetToolsHandler) => void; + /** Set a flag value (called after CLI parsing) */ + setFlagValue: (name: string, value: boolean | string) => void; } /** @@ -167,14 +205,19 @@ function resolveHookPath(hookPath: string, cwd: string): string { function createHookAPI( handlers: Map, cwd: string, + hookPath: string, ): { api: HookAPI; messageRenderers: Map; commands: Map; + flags: Map; + flagValues: Map; + shortcuts: Map; setSendMessageHandler: (handler: SendMessageHandler) => void; setAppendEntryHandler: (handler: AppendEntryHandler) => void; setGetToolsHandler: (handler: GetToolsHandler) => void; setSetToolsHandler: (handler: SetToolsHandler) => void; + setFlagValue: (name: string, value: boolean | string) => void; } { let sendMessageHandler: SendMessageHandler = () => { // Default no-op until mode sets the handler @@ -188,6 +231,9 @@ function createHookAPI( }; const messageRenderers = new Map(); const commands = new Map(); + const flags = new Map(); + const flagValues = 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 @@ -221,12 +267,37 @@ function createHookAPI( setTools(toolNames: string[]): void { setToolsHandler(toolNames); }, + registerFlag( + name: string, + options: { description?: string; type: "boolean" | "string"; default?: boolean | string }, + ): void { + flags.set(name, { name, hookPath, ...options }); + // Set default value if provided + if (options.default !== undefined) { + flagValues.set(name, options.default); + } + }, + getFlag(name: string): boolean | string | undefined { + return flagValues.get(name); + }, + registerShortcut( + shortcut: string, + options: { + description?: string; + handler: (ctx: import("./types.js").HookContext) => Promise | void; + }, + ): void { + shortcuts.set(shortcut, { shortcut, hookPath, ...options }); + }, } as HookAPI; return { api, messageRenderers, commands, + flags, + flagValues, + shortcuts, setSendMessageHandler: (handler: SendMessageHandler) => { sendMessageHandler = handler; }, @@ -239,6 +310,9 @@ function createHookAPI( setSetToolsHandler: (handler: SetToolsHandler) => { setToolsHandler = handler; }, + setFlagValue: (name: string, value: boolean | string) => { + flagValues.set(name, value); + }, }; } @@ -270,11 +344,15 @@ async function loadHook(hookPath: string, cwd: string): Promise<{ hook: LoadedHo api, messageRenderers, commands, + flags, + flagValues, + shortcuts, setSendMessageHandler, setAppendEntryHandler, setGetToolsHandler, setSetToolsHandler, - } = createHookAPI(handlers, cwd); + setFlagValue, + } = createHookAPI(handlers, cwd, hookPath); // Call factory to register handlers factory(api); @@ -286,10 +364,14 @@ async function loadHook(hookPath: string, cwd: string): Promise<{ hook: LoadedHo handlers, messageRenderers, commands, + flags, + flagValues, + shortcuts, setSendMessageHandler, setAppendEntryHandler, setGetToolsHandler, setSetToolsHandler, + setFlagValue, }, error: null, }; diff --git a/packages/coding-agent/src/core/hooks/runner.ts b/packages/coding-agent/src/core/hooks/runner.ts index 08f0d49f..b6e54843 100644 --- a/packages/coding-agent/src/core/hooks/runner.ts +++ b/packages/coding-agent/src/core/hooks/runner.ts @@ -168,6 +168,43 @@ export class HookRunner { return this.hooks.map((h) => h.path); } + /** + * Get all CLI flags registered by hooks. + */ + getFlags(): Map { + const allFlags = new Map(); + for (const hook of this.hooks) { + for (const [name, flag] of hook.flags) { + allFlags.set(name, flag); + } + } + return allFlags; + } + + /** + * Set a flag value (after CLI parsing). + */ + setFlagValue(name: string, value: boolean | string): void { + for (const hook of this.hooks) { + if (hook.flags.has(name)) { + hook.setFlagValue(name, value); + } + } + } + + /** + * Get all keyboard shortcuts registered by hooks. + */ + getShortcuts(): Map { + const allShortcuts = new Map(); + for (const hook of this.hooks) { + for (const [key, shortcut] of hook.shortcuts) { + allShortcuts.set(key, shortcut); + } + } + return allShortcuts; + } + /** * Subscribe to hook errors. * @returns Unsubscribe function diff --git a/packages/coding-agent/src/core/hooks/types.ts b/packages/coding-agent/src/core/hooks/types.ts index 9b22eb76..592e7ea8 100644 --- a/packages/coding-agent/src/core/hooks/types.ts +++ b/packages/coding-agent/src/core/hooks/types.ts @@ -771,6 +771,70 @@ export interface HookAPI { * pi.setTools(["read", "bash", "edit", "write"]); */ setTools(toolNames: string[]): void; + + /** + * Register a CLI flag for this hook. + * Flags are parsed from command line and values accessible via getFlag(). + * + * @param name - Flag name (will be --name on CLI) + * @param options - Flag configuration + * + * @example + * pi.registerFlag("plan", { + * description: "Start in plan mode (read-only)", + * type: "boolean", + * }); + */ + registerFlag( + name: string, + options: { + /** Description shown in --help */ + description?: string; + /** Flag type: boolean (--flag) or string (--flag value) */ + type: "boolean" | "string"; + /** Default value */ + default?: boolean | string; + }, + ): void; + + /** + * Get the value of a CLI flag registered by this hook. + * Returns undefined if flag was not provided and has no default. + * + * @param name - Flag name (without --) + * @returns Flag value, or undefined + * + * @example + * if (pi.getFlag("plan")) { + * // plan mode enabled + * } + */ + getFlag(name: string): boolean | string | undefined; + + /** + * 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 options - Shortcut configuration + * + * @example + * pi.registerShortcut("shift+p", { + * description: "Toggle plan mode", + * handler: async (ctx) => { + * // toggle plan mode + * }, + * }); + */ + registerShortcut( + shortcut: string, + options: { + /** Description shown in help */ + description?: string; + /** Handler called when shortcut is pressed */ + handler: (ctx: HookContext) => Promise | void; + }, + ): void; } /** diff --git a/packages/coding-agent/src/core/sdk.ts b/packages/coding-agent/src/core/sdk.ts index 3870484f..8c621d08 100644 --- a/packages/coding-agent/src/core/sdk.ts +++ b/packages/coding-agent/src/core/sdk.ts @@ -112,6 +112,8 @@ export interface CreateAgentSessionOptions { hooks?: Array<{ path?: string; factory: HookFactory }>; /** Additional hook paths to load (merged with discovery). */ additionalHookPaths?: string[]; + /** Pre-loaded hooks (skips loading, used when hooks were loaded early for CLI flags). */ + preloadedHooks?: LoadedHook[]; /** Skills. Default: discovered from multiple locations */ skills?: Skill[]; @@ -341,9 +343,13 @@ function createFactoryFromLoadedHook(loaded: LoadedHook): HookFactory { */ function createLoadedHooksFromDefinitions(definitions: Array<{ path?: string; factory: HookFactory }>): LoadedHook[] { return definitions.map((def) => { + const hookPath = def.path ?? ""; const handlers = new Map Promise>>(); const messageRenderers = new Map(); const commands = new Map(); + const flags = new Map(); + const flagValues = new Map(); + const shortcuts = new Map(); let sendMessageHandler: ( message: any, options?: { triggerTurn?: boolean; deliverAs?: "steer" | "followUp" }, @@ -375,6 +381,16 @@ function createLoadedHooksFromDefinitions(definitions: Array<{ path?: string; fa registerCommand: (name: string, options: any) => { commands.set(name, { name, ...options }); }, + registerFlag: (name: string, options: any) => { + flags.set(name, { name, hookPath, ...options }); + if (options.default !== undefined) { + flagValues.set(name, options.default); + } + }, + getFlag: (name: string) => flagValues.get(name), + registerShortcut: (shortcut: string, options: any) => { + shortcuts.set(shortcut, { shortcut, hookPath, ...options }); + }, newSession: (options?: any) => newSessionHandler(options), branch: (entryId: string) => branchHandler(entryId), navigateTree: (targetId: string, options?: any) => navigateTreeHandler(targetId, options), @@ -385,11 +401,14 @@ function createLoadedHooksFromDefinitions(definitions: Array<{ path?: string; fa def.factory(api as any); return { - path: def.path ?? "", - resolvedPath: def.path ?? "", + path: hookPath, + resolvedPath: hookPath, handlers, messageRenderers, commands, + flags, + flagValues, + shortcuts, setSendMessageHandler: ( handler: (message: any, options?: { triggerTurn?: boolean; deliverAs?: "steer" | "followUp" }) => void, ) => { @@ -413,6 +432,9 @@ function createLoadedHooksFromDefinitions(definitions: Array<{ path?: string; fa setSetToolsHandler: (handler: (toolNames: string[]) => void) => { setToolsHandler = handler; }, + setFlagValue: (name: string, value: boolean | string) => { + flagValues.set(name, value); + }, }; }); } @@ -566,7 +588,10 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {} } let hookRunner: HookRunner | undefined; - if (options.hooks !== undefined) { + if (options.preloadedHooks !== undefined && options.preloadedHooks.length > 0) { + // Use pre-loaded hooks (from early CLI flag discovery) + hookRunner = new HookRunner(options.preloadedHooks, cwd, sessionManager, modelRegistry); + } else if (options.hooks !== undefined) { if (options.hooks.length > 0) { const loadedHooks = createLoadedHooksFromDefinitions(options.hooks); hookRunner = new HookRunner(loadedHooks, cwd, sessionManager, modelRegistry); diff --git a/packages/coding-agent/src/main.ts b/packages/coding-agent/src/main.ts index dc1778ca..b266654d 100644 --- a/packages/coding-agent/src/main.ts +++ b/packages/coding-agent/src/main.ts @@ -18,6 +18,7 @@ import type { AgentSession } from "./core/agent-session.js"; import type { LoadedCustomTool } from "./core/custom-tools/index.js"; import { exportFromFile } from "./core/export-html/index.js"; +import { discoverAndLoadHooks } from "./core/hooks/index.js"; import type { HookUIContext } from "./core/index.js"; import type { ModelRegistry } from "./core/model-registry.js"; import { resolveModelScope, type ScopedModel } from "./core/model-resolver.js"; @@ -212,6 +213,7 @@ function buildSessionOptions( scopedModels: ScopedModel[], sessionManager: SessionManager | undefined, modelRegistry: ModelRegistry, + preloadedHooks?: import("./core/hooks/index.js").LoadedHook[], ): CreateAgentSessionOptions { const options: CreateAgentSessionOptions = {}; @@ -270,9 +272,9 @@ function buildSessionOptions( options.skills = []; } - // Additional hook paths from CLI - if (parsed.hooks && parsed.hooks.length > 0) { - options.additionalHookPaths = parsed.hooks; + // Pre-loaded hooks (from early CLI flag discovery) + if (preloadedHooks && preloadedHooks.length > 0) { + options.preloadedHooks = preloadedHooks; } // Additional custom tool paths from CLI @@ -294,9 +296,38 @@ export async function main(args: string[]) { const modelRegistry = discoverModels(authStorage); time("discoverModels"); - const parsed = parseArgs(args); + // First pass: parse args to get --hook paths + const firstPass = parseArgs(args); + time("parseArgs-firstPass"); + + // Early load hooks to discover their CLI flags + const cwd = process.cwd(); + const agentDir = getAgentDir(); + const hookPaths = firstPass.hooks ?? []; + const { hooks: loadedHooks } = await discoverAndLoadHooks(hookPaths, cwd, agentDir); + time("discoverHookFlags"); + + // Collect all hook flags + const hookFlags = new Map(); + for (const hook of loadedHooks) { + for (const [name, flag] of hook.flags) { + hookFlags.set(name, { type: flag.type }); + } + } + + // Second pass: parse args with hook flags + const parsed = parseArgs(args, hookFlags); time("parseArgs"); + // Pass flag values to hooks + for (const [name, value] of parsed.unknownFlags) { + for (const hook of loadedHooks) { + if (hook.flags.has(name)) { + hook.setFlagValue(name, value); + } + } + } + if (parsed.version) { console.log(VERSION); return; @@ -331,7 +362,6 @@ export async function main(args: string[]) { process.exit(1); } - const cwd = process.cwd(); const settingsManager = SettingsManager.create(cwd); time("SettingsManager.create"); const { initialMessage, initialImages } = await prepareInitialMessage(parsed, settingsManager.getImageAutoResize()); @@ -369,7 +399,7 @@ export async function main(args: string[]) { sessionManager = SessionManager.open(selectedPath); } - const sessionOptions = buildSessionOptions(parsed, scopedModels, sessionManager, modelRegistry); + const sessionOptions = buildSessionOptions(parsed, scopedModels, sessionManager, modelRegistry, loadedHooks); sessionOptions.authStorage = authStorage; sessionOptions.modelRegistry = modelRegistry; diff --git a/packages/coding-agent/src/modes/interactive/components/custom-editor.ts b/packages/coding-agent/src/modes/interactive/components/custom-editor.ts index 00afa5e7..bb080a64 100644 --- a/packages/coding-agent/src/modes/interactive/components/custom-editor.ts +++ b/packages/coding-agent/src/modes/interactive/components/custom-editor.ts @@ -12,6 +12,8 @@ export class CustomEditor extends Editor { public onEscape?: () => void; public onCtrlD?: () => void; public onPasteImage?: () => void; + /** Handler for hook-registered shortcuts. Returns true if handled. */ + public onHookShortcut?: (data: string) => boolean; constructor(theme: EditorTheme, keybindings: KeybindingsManager) { super(theme); @@ -26,6 +28,11 @@ export class CustomEditor extends Editor { } handleInput(data: string): void { + // Check hook-registered shortcuts first + if (this.onHookShortcut?.(data)) { + return; + } + // Check for Ctrl+V to handle clipboard image paste if (matchesKey(data, "ctrl+v")) { this.onPasteImage?.(); diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index cc34b462..b9fc8085 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -406,20 +406,7 @@ export class InteractiveMode { } // Create and set hook & tool UI context - const uiContext: HookUIContext = { - select: (title, options) => this.showHookSelector(title, options), - confirm: (title, message) => this.showHookConfirm(title, message), - input: (title, placeholder) => this.showHookInput(title, placeholder), - notify: (message, type) => this.showHookNotify(message, type), - setStatus: (key, text) => this.setHookStatus(key, text), - custom: (factory) => this.showHookCustom(factory), - setEditorText: (text) => this.editor.setText(text), - getEditorText: () => this.editor.getText(), - editor: (title, prefill) => this.showHookEditor(title, prefill), - get theme() { - return theme; - }, - }; + const uiContext = this.createHookUIContext(); this.setToolUIContext(uiContext, true); // Notify custom tools of session start @@ -532,6 +519,9 @@ export class InteractiveMode { this.showHookError(error.hookPath, error.error); }); + // Set up hook-registered shortcuts + this.setupHookShortcuts(hookRunner); + // Show loaded hooks const hookPaths = hookRunner.getHookPaths(); if (hookPaths.length > 0) { @@ -579,6 +569,82 @@ export class InteractiveMode { this.ui.requestRender(); } + /** + * Set up keyboard shortcuts registered by hooks. + */ + private setupHookShortcuts(hookRunner: import("../../core/hooks/index.js").HookRunner): void { + const shortcuts = hookRunner.getShortcuts(); + if (shortcuts.size === 0) return; + + // Create a context for shortcut handlers + const createContext = (): import("../../core/hooks/types.js").HookContext => ({ + ui: this.createHookUIContext(), + hasUI: true, + cwd: process.cwd(), + sessionManager: this.sessionManager, + modelRegistry: this.session.modelRegistry, + model: this.session.model, + isIdle: () => !this.session.isStreaming, + abort: () => this.session.abort(), + hasPendingMessages: () => this.session.pendingMessageCount > 0, + }); + + // Set up the hook shortcut handler on the editor + this.editor.onHookShortcut = (data: string) => { + for (const [shortcutStr, shortcut] of shortcuts) { + if (this.matchShortcut(data, shortcutStr)) { + // Run handler async, don't block input + Promise.resolve(shortcut.handler(createContext())).catch((err) => { + this.showError(`Shortcut handler error: ${err instanceof Error ? err.message : String(err)}`); + }); + return true; + } + } + return false; + }; + } + + /** + * Match a key input against a shortcut string like "shift+p" or "ctrl+shift+x". + */ + private matchShortcut(data: string, shortcut: string): boolean { + const parts = shortcut.toLowerCase().split("+"); + const key = parts.pop() ?? ""; + const modifiers = new Set(parts); + + const hasShift = modifiers.has("shift"); + const hasCtrl = modifiers.has("ctrl"); + const hasAlt = modifiers.has("alt"); + + // Get the key codepoint + const keyCode = key.length === 1 ? key.charCodeAt(0) : 0; + if (keyCode === 0) return false; + + // Calculate expected modifier bits for Kitty protocol + // Kitty modifier bits: 1=shift, 2=alt, 4=ctrl + let expectedMod = 0; + if (hasShift) expectedMod |= 1; + if (hasAlt) expectedMod |= 2; + if (hasCtrl) expectedMod |= 4; + + // Try to match Kitty protocol: \x1b[;u + // With modifier offset: mod in sequence = expectedMod + 1 + const kittyPattern = new RegExp(`^\x1b\\[${keyCode};(\\d+)u$`); + const kittyMatch = data.match(kittyPattern); + if (kittyMatch) { + const actualMod = parseInt(kittyMatch[1], 10) - 1; // Subtract 1 for the offset + // Mask out lock bits (8=capslock, 16=numlock) + return (actualMod & 0x7) === expectedMod; + } + + // Try uppercase letter for shift+letter (legacy terminals) + if (hasShift && !hasCtrl && !hasAlt && key.length === 1) { + return data === key.toUpperCase(); + } + + return false; + } + /** * Set hook status text in the footer. */ @@ -587,6 +653,26 @@ export class InteractiveMode { this.ui.requestRender(); } + /** + * Create the HookUIContext for hooks and tools. + */ + private createHookUIContext(): HookUIContext { + return { + select: (title, options) => this.showHookSelector(title, options), + confirm: (title, message) => this.showHookConfirm(title, message), + input: (title, placeholder) => this.showHookInput(title, placeholder), + notify: (message, type) => this.showHookNotify(message, type), + setStatus: (key, text) => this.setHookStatus(key, text), + custom: (factory) => this.showHookCustom(factory), + setEditorText: (text) => this.editor.setText(text), + getEditorText: () => this.editor.getText(), + editor: (title, prefill) => this.showHookEditor(title, prefill), + get theme() { + return theme; + }, + }; + } + /** * Show a selector for hooks. */ diff --git a/packages/coding-agent/test/compaction-hooks.test.ts b/packages/coding-agent/test/compaction-hooks.test.ts index 696e269c..bf601371 100644 --- a/packages/coding-agent/test/compaction-hooks.test.ts +++ b/packages/coding-agent/test/compaction-hooks.test.ts @@ -78,10 +78,14 @@ describe.skipIf(!API_KEY)("Compaction hooks", () => { handlers, messageRenderers: new Map(), commands: new Map(), + flags: new Map(), + flagValues: new Map(), + shortcuts: new Map(), setSendMessageHandler: () => {}, setAppendEntryHandler: () => {}, setGetToolsHandler: () => {}, setSetToolsHandler: () => {}, + setFlagValue: () => {}, }; } @@ -269,10 +273,14 @@ describe.skipIf(!API_KEY)("Compaction hooks", () => { ]), messageRenderers: new Map(), commands: new Map(), + flags: new Map(), + flagValues: new Map(), + shortcuts: new Map(), setSendMessageHandler: () => {}, setAppendEntryHandler: () => {}, setGetToolsHandler: () => {}, setSetToolsHandler: () => {}, + setFlagValue: () => {}, }; createSession([throwingHook]); @@ -318,10 +326,14 @@ describe.skipIf(!API_KEY)("Compaction hooks", () => { ]), messageRenderers: new Map(), commands: new Map(), + flags: new Map(), + flagValues: new Map(), + shortcuts: new Map(), setSendMessageHandler: () => {}, setAppendEntryHandler: () => {}, setGetToolsHandler: () => {}, setSetToolsHandler: () => {}, + setFlagValue: () => {}, }; const hook2: LoadedHook = { @@ -349,10 +361,14 @@ describe.skipIf(!API_KEY)("Compaction hooks", () => { ]), messageRenderers: new Map(), commands: new Map(), + flags: new Map(), + flagValues: new Map(), + shortcuts: new Map(), setSendMessageHandler: () => {}, setAppendEntryHandler: () => {}, setGetToolsHandler: () => {}, setSetToolsHandler: () => {}, + setFlagValue: () => {}, }; createSession([hook1, hook2]);