From 57bba4e32b9053780040992f4a677f57a256d6b6 Mon Sep 17 00:00:00 2001 From: Helmut Januschka Date: Sat, 3 Jan 2026 09:31:39 +0100 Subject: [PATCH 01/30] WIP: Add hook API for dynamic tool control with plan-mode hook example - Add pi.getTools() and pi.setTools(toolNames) to HookAPI - Hooks can now enable/disable tools dynamically - Changes take effect on next agent turn New example hook: plan-mode.ts - Claude Code-style read-only exploration mode - /plan command toggles plan mode on/off - Plan mode tools: read, bash, grep, find, ls - Edit/write tools disabled in plan mode - Injects context telling agent about restrictions - After each response, prompts to execute/stay/refine - State persists across sessions --- packages/coding-agent/CHANGELOG.md | 2 + packages/coding-agent/docs/hooks.md | 23 ++++ .../coding-agent/examples/hooks/README.md | 1 + .../coding-agent/examples/hooks/plan-mode.ts | 119 ++++++++++++++++++ .../coding-agent/src/core/agent-session.ts | 39 +++++- packages/coding-agent/src/core/hooks/index.ts | 2 + .../coding-agent/src/core/hooks/loader.ts | 47 ++++++- .../coding-agent/src/core/hooks/runner.ts | 8 +- packages/coding-agent/src/core/hooks/types.ts | 22 ++++ packages/coding-agent/src/core/sdk.ts | 33 ++++- .../src/modes/interactive/interactive-mode.ts | 2 + packages/coding-agent/src/modes/print-mode.ts | 2 + .../coding-agent/src/modes/rpc/rpc-mode.ts | 2 + .../test/compaction-hooks.test.ts | 10 ++ 14 files changed, 304 insertions(+), 8 deletions(-) create mode 100644 packages/coding-agent/examples/hooks/plan-mode.ts diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 3826cb37..299b6f3f 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -35,6 +35,8 @@ ### Added - `$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 ### Changed diff --git a/packages/coding-agent/docs/hooks.md b/packages/coding-agent/docs/hooks.md index 7313bd48..7f30213c 100644 --- a/packages/coding-agent/docs/hooks.md +++ b/packages/coding-agent/docs/hooks.md @@ -752,6 +752,29 @@ const result = await pi.exec("git", ["status"], { // result.stdout, result.stderr, result.code, result.killed ``` +### pi.getTools() + +Get the names of currently active tools: + +```typescript +const toolNames = pi.getTools(); +// ["read", "bash", "edit", "write"] +``` + +### pi.setTools(toolNames) + +Set the active tools by name. Changes take effect on the next agent turn. + +```typescript +// Switch to read-only mode (plan mode) +pi.setTools(["read", "bash", "grep", "find", "ls"]); + +// Restore full access +pi.setTools(["read", "bash", "edit", "write"]); +``` + +Only built-in tools can be enabled/disabled. Custom tools are always active. Unknown tool names are ignored. + ## Examples ### Permission Gate diff --git a/packages/coding-agent/examples/hooks/README.md b/packages/coding-agent/examples/hooks/README.md index e084206c..cdab51d4 100644 --- a/packages/coding-agent/examples/hooks/README.md +++ b/packages/coding-agent/examples/hooks/README.md @@ -16,6 +16,7 @@ cp permission-gate.ts ~/.pi/agent/hooks/ | Hook | Description | |------|-------------| +| `plan-mode.ts` | Claude Code-style plan mode for read-only exploration with `/plan` command | | `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/) | diff --git a/packages/coding-agent/examples/hooks/plan-mode.ts b/packages/coding-agent/examples/hooks/plan-mode.ts new file mode 100644 index 00000000..a8fe073d --- /dev/null +++ b/packages/coding-agent/examples/hooks/plan-mode.ts @@ -0,0 +1,119 @@ +/** + * Plan Mode Hook + * + * Provides a Claude Code-style "plan mode" for safe code exploration. + * When enabled, the agent can only use read-only tools and cannot modify files. + * + * Features: + * - /plan command to toggle plan mode + * - 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 + * + * Usage: + * 1. Copy this file to ~/.pi/agent/hooks/ or your project's .pi/hooks/ + * 2. Use /plan to toggle plan mode on/off + */ + +import type { HookAPI } from "@mariozechner/pi-coding-agent/hooks"; + +// Read-only tools for plan mode +const PLAN_MODE_TOOLS = ["read", "bash", "grep", "find", "ls"]; + +// Full set of tools for normal mode +const NORMAL_MODE_TOOLS = ["read", "bash", "edit", "write"]; + +export default function planModeHook(pi: HookAPI) { + // Track plan mode state + let planModeEnabled = false; + + // 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."); + } + }, + }); + + // Inject plan mode context at the start of each turn via before_agent_start + pi.on("before_agent_start", async () => { + if (!planModeEnabled) return; + + // Return a message to inject into context + return { + message: { + customType: "plan-mode-context", + content: `[PLAN MODE ACTIVE] +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 CANNOT use: edit, write (file modifications are disabled) +- Focus on analysis, planning, and understanding the codebase + +Your task is to explore, analyze, and create a detailed plan. +Do NOT attempt to make changes - just describe what you would do. +When you have a complete plan, I will switch to normal mode to execute it.`, + display: false, // Don't show in TUI, just inject into context + }, + }; + }); + + // After agent finishes, offer to execute the plan + pi.on("agent_end", async (_event, ctx) => { + if (!planModeEnabled) return; + if (!ctx.hasUI) return; + + const choice = await ctx.ui.select("Plan mode - what next?", [ + "Execute the plan", + "Stay in plan mode", + "Refine the plan", + ]); + + if (choice === "Execute the plan") { + // Switch to normal mode + planModeEnabled = false; + pi.setTools(NORMAL_MODE_TOOLS); + ctx.ui.notify("Switched to normal mode. Full access restored."); + + // Set editor text to prompt execution + ctx.ui.setEditorText("Execute the plan you just created. Proceed step by step."); + } else if (choice === "Refine the plan") { + const refinement = await ctx.ui.input("What should be refined?"); + if (refinement) { + ctx.ui.setEditorText(refinement); + } + } + // "Stay in plan mode" - do nothing, just continue + }); + + // Persist plan mode state across sessions + pi.on("session_start", async (_event, ctx) => { + // Check if there's persisted plan mode state + 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; + pi.setTools(PLAN_MODE_TOOLS); + } + }); + + // Save state when plan mode changes (via tool_call or other events) + pi.on("turn_start", async () => { + // Persist current state + pi.appendEntry("plan-mode", { enabled: planModeEnabled }); + }); +} diff --git a/packages/coding-agent/src/core/agent-session.ts b/packages/coding-agent/src/core/agent-session.ts index 5fb852bd..689d6ca7 100644 --- a/packages/coding-agent/src/core/agent-session.ts +++ b/packages/coding-agent/src/core/agent-session.ts @@ -13,7 +13,14 @@ * Modes use this class and add their own I/O layer on top. */ -import type { Agent, AgentEvent, AgentMessage, AgentState, ThinkingLevel } from "@mariozechner/pi-agent-core"; +import type { + Agent, + AgentEvent, + AgentMessage, + AgentState, + AgentTool, + ThinkingLevel, +} from "@mariozechner/pi-agent-core"; import type { AssistantMessage, ImageContent, Message, Model, TextContent } from "@mariozechner/pi-ai"; import { isContextOverflow, modelsAreEqual, supportsXhigh } from "@mariozechner/pi-ai"; import { getAuthPath } from "../config.js"; @@ -75,6 +82,8 @@ export interface AgentSessionConfig { skillsSettings?: Required; /** Model registry for API key resolution and model discovery */ modelRegistry: ModelRegistry; + /** Tool registry for hook getTools/setTools - maps name to tool */ + toolRegistry?: Map; } /** Options for AgentSession.prompt() */ @@ -174,6 +183,9 @@ export class AgentSession { // Model registry for API key resolution private _modelRegistry: ModelRegistry; + // Tool registry for hook getTools/setTools + private _toolRegistry: Map; + constructor(config: AgentSessionConfig) { this.agent = config.agent; this.sessionManager = config.sessionManager; @@ -184,6 +196,7 @@ export class AgentSession { this._customTools = config.customTools ?? []; this._skillsSettings = config.skillsSettings; this._modelRegistry = config.modelRegistry; + this._toolRegistry = config.toolRegistry ?? new Map(); // Always subscribe to agent events for internal handling // (session persistence, hooks, auto-compaction, retry logic) @@ -417,6 +430,30 @@ export class AgentSession { return this.agent.state.isStreaming; } + /** + * Get the names of currently active tools. + * Returns the names of tools currently set on the agent. + */ + getActiveToolNames(): string[] { + return this.agent.state.tools.map((t) => t.name); + } + + /** + * Set active tools by name. + * Only tools in the registry can be enabled. Unknown tool names are ignored. + * Changes take effect on the next agent turn. + */ + setActiveToolsByName(toolNames: string[]): void { + const tools: AgentTool[] = []; + for (const name of toolNames) { + const tool = this._toolRegistry.get(name); + if (tool) { + tools.push(tool); + } + } + this.agent.setTools(tools); + } + /** Whether auto-compaction is currently running */ get isCompacting(): boolean { return this._autoCompactionAbortController !== undefined || this._compactionAbortController !== undefined; diff --git a/packages/coding-agent/src/core/hooks/index.ts b/packages/coding-agent/src/core/hooks/index.ts index 9729ecbc..6d000397 100644 --- a/packages/coding-agent/src/core/hooks/index.ts +++ b/packages/coding-agent/src/core/hooks/index.ts @@ -4,11 +4,13 @@ export { loadHooks, type AppendEntryHandler, type BranchHandler, + type GetToolsHandler, type LoadedHook, type LoadHooksResult, type NavigateTreeHandler, type NewSessionHandler, type SendMessageHandler, + type SetToolsHandler, } from "./loader.js"; export { execCommand, HookRunner, type HookErrorListener } from "./runner.js"; export { wrapToolsWithHooks, wrapToolWithHooks } from "./tool-wrapper.js"; diff --git a/packages/coding-agent/src/core/hooks/loader.ts b/packages/coding-agent/src/core/hooks/loader.ts index e0bbe9ac..497a6e85 100644 --- a/packages/coding-agent/src/core/hooks/loader.ts +++ b/packages/coding-agent/src/core/hooks/loader.ts @@ -61,6 +61,16 @@ export type SendMessageHandler = ( */ export type AppendEntryHandler = (customType: string, data?: T) => void; +/** + * Get tools handler type for pi.getTools(). + */ +export type GetToolsHandler = () => string[]; + +/** + * Set tools handler type for pi.setTools(). + */ +export type SetToolsHandler = (toolNames: string[]) => void; + /** * New session handler type for ctx.newSession() in HookCommandContext. */ @@ -100,6 +110,10 @@ export interface LoadedHook { setSendMessageHandler: (handler: SendMessageHandler) => void; /** Set the append entry handler for this hook's pi.appendEntry() */ setAppendEntryHandler: (handler: AppendEntryHandler) => void; + /** Set the get tools handler for this hook's pi.getTools() */ + setGetToolsHandler: (handler: GetToolsHandler) => void; + /** Set the set tools handler for this hook's pi.setTools() */ + setSetToolsHandler: (handler: SetToolsHandler) => void; } /** @@ -159,6 +173,8 @@ function createHookAPI( commands: Map; setSendMessageHandler: (handler: SendMessageHandler) => void; setAppendEntryHandler: (handler: AppendEntryHandler) => void; + setGetToolsHandler: (handler: GetToolsHandler) => void; + setSetToolsHandler: (handler: SetToolsHandler) => void; } { let sendMessageHandler: SendMessageHandler = () => { // Default no-op until mode sets the handler @@ -166,6 +182,10 @@ function createHookAPI( let appendEntryHandler: AppendEntryHandler = () => { // Default no-op until mode sets the handler }; + let getToolsHandler: GetToolsHandler = () => []; + let setToolsHandler: SetToolsHandler = () => { + // Default no-op until mode sets the handler + }; const messageRenderers = new Map(); const commands = new Map(); @@ -195,6 +215,12 @@ function createHookAPI( exec(command: string, args: string[], options?: ExecOptions) { return execCommand(command, args, options?.cwd ?? cwd, options); }, + getTools(): string[] { + return getToolsHandler(); + }, + setTools(toolNames: string[]): void { + setToolsHandler(toolNames); + }, } as HookAPI; return { @@ -207,6 +233,12 @@ function createHookAPI( setAppendEntryHandler: (handler: AppendEntryHandler) => { appendEntryHandler = handler; }, + setGetToolsHandler: (handler: GetToolsHandler) => { + getToolsHandler = handler; + }, + setSetToolsHandler: (handler: SetToolsHandler) => { + setToolsHandler = handler; + }, }; } @@ -234,10 +266,15 @@ async function loadHook(hookPath: string, cwd: string): Promise<{ hook: LoadedHo // Create handlers map and API const handlers = new Map(); - const { api, messageRenderers, commands, setSendMessageHandler, setAppendEntryHandler } = createHookAPI( - handlers, - cwd, - ); + const { + api, + messageRenderers, + commands, + setSendMessageHandler, + setAppendEntryHandler, + setGetToolsHandler, + setSetToolsHandler, + } = createHookAPI(handlers, cwd); // Call factory to register handlers factory(api); @@ -251,6 +288,8 @@ async function loadHook(hookPath: string, cwd: string): Promise<{ hook: LoadedHo commands, setSendMessageHandler, setAppendEntryHandler, + setGetToolsHandler, + setSetToolsHandler, }, error: null, }; diff --git a/packages/coding-agent/src/core/hooks/runner.ts b/packages/coding-agent/src/core/hooks/runner.ts index 65dfe3f7..08f0d49f 100644 --- a/packages/coding-agent/src/core/hooks/runner.ts +++ b/packages/coding-agent/src/core/hooks/runner.ts @@ -98,6 +98,10 @@ export class HookRunner { sendMessageHandler: SendMessageHandler; /** Handler for hooks to append entries */ appendEntryHandler: AppendEntryHandler; + /** Handler for getting current tools */ + getToolsHandler: () => string[]; + /** Handler for setting tools */ + setToolsHandler: (toolNames: string[]) => void; /** Handler for creating new sessions (for HookCommandContext) */ newSessionHandler?: NewSessionHandler; /** Handler for branching sessions (for HookCommandContext) */ @@ -132,10 +136,12 @@ export class HookRunner { if (options.navigateTreeHandler) { this.navigateTreeHandler = options.navigateTreeHandler; } - // Set per-hook handlers for pi.sendMessage() and pi.appendEntry() + // Set per-hook handlers for pi.sendMessage(), pi.appendEntry(), pi.getTools(), pi.setTools() for (const hook of this.hooks) { hook.setSendMessageHandler(options.sendMessageHandler); hook.setAppendEntryHandler(options.appendEntryHandler); + hook.setGetToolsHandler(options.getToolsHandler); + hook.setSetToolsHandler(options.setToolsHandler); } this.uiContext = options.uiContext ?? noOpUIContext; this.hasUI = options.hasUI ?? false; diff --git a/packages/coding-agent/src/core/hooks/types.ts b/packages/coding-agent/src/core/hooks/types.ts index 50597c42..9b22eb76 100644 --- a/packages/coding-agent/src/core/hooks/types.ts +++ b/packages/coding-agent/src/core/hooks/types.ts @@ -749,6 +749,28 @@ export interface HookAPI { * Supports timeout and abort signal. */ exec(command: string, args: string[], options?: ExecOptions): Promise; + + /** + * Get the list of currently active tool names. + * @returns Array of tool names (e.g., ["read", "bash", "edit", "write"]) + */ + getTools(): string[]; + + /** + * Set the active tools by name. + * Only built-in tools can be enabled/disabled. Custom tools are always active. + * Changes take effect on the next agent turn. + * + * @param toolNames - Array of tool names to enable (e.g., ["read", "bash", "grep", "find", "ls"]) + * + * @example + * // Switch to read-only mode (plan mode) + * pi.setTools(["read", "bash", "grep", "find", "ls"]); + * + * // Restore full access + * pi.setTools(["read", "bash", "edit", "write"]); + */ + setTools(toolNames: string[]): void; } /** diff --git a/packages/coding-agent/src/core/sdk.ts b/packages/coding-agent/src/core/sdk.ts index 5d0749d2..3870484f 100644 --- a/packages/coding-agent/src/core/sdk.ts +++ b/packages/coding-agent/src/core/sdk.ts @@ -29,7 +29,7 @@ * ``` */ -import { Agent, type ThinkingLevel } from "@mariozechner/pi-agent-core"; +import { Agent, type AgentTool, type ThinkingLevel } from "@mariozechner/pi-agent-core"; import type { Model } from "@mariozechner/pi-ai"; import { join } from "path"; import { getAgentDir } from "../config.js"; @@ -349,6 +349,8 @@ function createLoadedHooksFromDefinitions(definitions: Array<{ path?: string; fa options?: { triggerTurn?: boolean; deliverAs?: "steer" | "followUp" }, ) => void = () => {}; let appendEntryHandler: (customType: string, data?: any) => void = () => {}; + let getToolsHandler: () => string[] = () => []; + let setToolsHandler: (toolNames: string[]) => void = () => {}; let newSessionHandler: (options?: any) => Promise<{ cancelled: boolean }> = async () => ({ cancelled: false }); let branchHandler: (entryId: string) => Promise<{ cancelled: boolean }> = async () => ({ cancelled: false }); let navigateTreeHandler: (targetId: string, options?: any) => Promise<{ cancelled: boolean }> = async () => ({ @@ -376,6 +378,8 @@ function createLoadedHooksFromDefinitions(definitions: Array<{ path?: string; fa newSession: (options?: any) => newSessionHandler(options), branch: (entryId: string) => branchHandler(entryId), navigateTree: (targetId: string, options?: any) => navigateTreeHandler(targetId, options), + getTools: () => getToolsHandler(), + setTools: (toolNames: string[]) => setToolsHandler(toolNames), }; def.factory(api as any); @@ -403,6 +407,12 @@ function createLoadedHooksFromDefinitions(definitions: Array<{ path?: string; fa setNavigateTreeHandler: (handler: (targetId: string, options?: any) => Promise<{ cancelled: boolean }>) => { navigateTreeHandler = handler; }, + setGetToolsHandler: (handler: () => string[]) => { + getToolsHandler = handler; + }, + setSetToolsHandler: (handler: (toolNames: string[]) => void) => { + setToolsHandler = handler; + }, }; }); } @@ -588,10 +598,28 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {} }, })); + // Create tool registry mapping name -> tool (for hook getTools/setTools) + // Cast to AgentTool since createCodingTools actually returns AgentTool[] (type is just Tool[]) + const toolRegistry = new Map(); + for (const tool of builtInTools as AgentTool[]) { + toolRegistry.set(tool.name, tool); + } + for (const tool of wrappedCustomTools as AgentTool[]) { + toolRegistry.set(tool.name, tool); + } + let allToolsArray: Tool[] = [...builtInTools, ...wrappedCustomTools]; time("combineTools"); + + // Wrap tools with hooks if available + let wrappedToolRegistry: Map | undefined; if (hookRunner) { - allToolsArray = wrapToolsWithHooks(allToolsArray, hookRunner) as Tool[]; + allToolsArray = wrapToolsWithHooks(allToolsArray as AgentTool[], hookRunner); + // Also create a wrapped version of the registry for setTools + wrappedToolRegistry = new Map(); + for (const tool of allToolsArray as AgentTool[]) { + wrappedToolRegistry.set(tool.name, tool); + } } let systemPrompt: string; @@ -670,6 +698,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {} customTools: customToolsResult.tools, skillsSettings: settingsManager.getSkillsSettings(), modelRegistry, + toolRegistry: wrappedToolRegistry ?? toolRegistry, }); time("createAgentSession"); diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index 82bc764b..7effc9f5 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -457,6 +457,8 @@ export class InteractiveMode { appendEntryHandler: (customType, data) => { this.sessionManager.appendCustomEntry(customType, data); }, + getToolsHandler: () => this.session.getActiveToolNames(), + setToolsHandler: (toolNames) => this.session.setActiveToolsByName(toolNames), newSessionHandler: async (options) => { // Stop any loading animation if (this.loadingAnimation) { diff --git a/packages/coding-agent/src/modes/print-mode.ts b/packages/coding-agent/src/modes/print-mode.ts index 56c53420..4da05491 100644 --- a/packages/coding-agent/src/modes/print-mode.ts +++ b/packages/coding-agent/src/modes/print-mode.ts @@ -40,6 +40,8 @@ export async function runPrintMode( appendEntryHandler: (customType, data) => { session.sessionManager.appendCustomEntry(customType, data); }, + getToolsHandler: () => session.getActiveToolNames(), + setToolsHandler: (toolNames) => session.setActiveToolsByName(toolNames), }); hookRunner.onError((err) => { console.error(`Hook error (${err.hookPath}): ${err.error}`); diff --git a/packages/coding-agent/src/modes/rpc/rpc-mode.ts b/packages/coding-agent/src/modes/rpc/rpc-mode.ts index e7392f31..e4d57d9e 100644 --- a/packages/coding-agent/src/modes/rpc/rpc-mode.ts +++ b/packages/coding-agent/src/modes/rpc/rpc-mode.ts @@ -189,6 +189,8 @@ export async function runRpcMode(session: AgentSession): Promise { appendEntryHandler: (customType, data) => { session.sessionManager.appendCustomEntry(customType, data); }, + getToolsHandler: () => session.getActiveToolNames(), + setToolsHandler: (toolNames) => session.setActiveToolsByName(toolNames), uiContext: createHookUIContext(), hasUI: false, }); diff --git a/packages/coding-agent/test/compaction-hooks.test.ts b/packages/coding-agent/test/compaction-hooks.test.ts index 5d5a5130..696e269c 100644 --- a/packages/coding-agent/test/compaction-hooks.test.ts +++ b/packages/coding-agent/test/compaction-hooks.test.ts @@ -80,6 +80,8 @@ describe.skipIf(!API_KEY)("Compaction hooks", () => { commands: new Map(), setSendMessageHandler: () => {}, setAppendEntryHandler: () => {}, + setGetToolsHandler: () => {}, + setSetToolsHandler: () => {}, }; } @@ -104,6 +106,8 @@ describe.skipIf(!API_KEY)("Compaction hooks", () => { getModel: () => session.model, sendMessageHandler: async () => {}, appendEntryHandler: async () => {}, + getToolsHandler: () => [], + setToolsHandler: () => {}, uiContext: { select: async () => undefined, confirm: async () => false, @@ -267,6 +271,8 @@ describe.skipIf(!API_KEY)("Compaction hooks", () => { commands: new Map(), setSendMessageHandler: () => {}, setAppendEntryHandler: () => {}, + setGetToolsHandler: () => {}, + setSetToolsHandler: () => {}, }; createSession([throwingHook]); @@ -314,6 +320,8 @@ describe.skipIf(!API_KEY)("Compaction hooks", () => { commands: new Map(), setSendMessageHandler: () => {}, setAppendEntryHandler: () => {}, + setGetToolsHandler: () => {}, + setSetToolsHandler: () => {}, }; const hook2: LoadedHook = { @@ -343,6 +351,8 @@ describe.skipIf(!API_KEY)("Compaction hooks", () => { commands: new Map(), setSendMessageHandler: () => {}, setAppendEntryHandler: () => {}, + setGetToolsHandler: () => {}, + setSetToolsHandler: () => {}, }; createSession([hook1, hook2]); From db312d2eedd6702c28109d179af6625cb38d82a3 Mon Sep 17 00:00:00 2001 From: Helmut Januschka Date: Sat, 3 Jan 2026 09:52:13 +0100 Subject: [PATCH 02/30] 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 299b6f3f..41749005 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -36,7 +36,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 7effc9f5..70021b71 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -410,20 +410,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 @@ -536,6 +523,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) { @@ -583,6 +573,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. */ @@ -591,6 +657,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]); From f808047564c216eda18793f4956da0d87c622184 Mon Sep 17 00:00:00 2001 From: Helmut Januschka Date: Sat, 3 Jan 2026 15:34:19 +0100 Subject: [PATCH 03/30] feat(coding-agent): show hook shortcuts in /hotkeys command --- .../src/modes/interactive/interactive-mode.ts | 67 +++++++------------ 1 file changed, 23 insertions(+), 44 deletions(-) diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index 70021b71..35f920ae 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -10,7 +10,7 @@ import * as path from "node:path"; import Clipboard from "@crosscopy/clipboard"; import type { AgentMessage } from "@mariozechner/pi-agent-core"; import type { AssistantMessage, Message, OAuthProvider } from "@mariozechner/pi-ai"; -import type { SlashCommand } from "@mariozechner/pi-tui"; +import type { KeyId, SlashCommand } from "@mariozechner/pi-tui"; import { CombinedAutocompleteProvider, type Component, @@ -19,6 +19,7 @@ import { Input, Loader, Markdown, + matchesKey, ProcessTerminal, Spacer, Text, @@ -596,7 +597,8 @@ export class InteractiveMode { // 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)) { + // Cast to KeyId - hook shortcuts use the same format + if (matchesKey(data, shortcutStr as KeyId)) { // 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)}`); @@ -608,47 +610,6 @@ export class InteractiveMode { }; } - /** - * 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. */ @@ -2493,7 +2454,7 @@ export class InteractiveMode { const externalEditor = this.getAppKeyDisplay("externalEditor"); const followUp = this.getAppKeyDisplay("followUp"); - const hotkeys = ` + let hotkeys = ` **Navigation** | Key | Action | |-----|--------| @@ -2529,6 +2490,24 @@ export class InteractiveMode { | \`/\` | Slash commands | | \`!\` | Run bash command | `; + + // Add hook-registered shortcuts + const hookRunner = this.session.hookRunner; + if (hookRunner) { + const shortcuts = hookRunner.getShortcuts(); + if (shortcuts.size > 0) { + hotkeys += ` +**Hooks** +| Key | Action | +|-----|--------| +`; + for (const [key, shortcut] of shortcuts) { + const description = shortcut.description ?? shortcut.hookPath; + hotkeys += `| \`${key}\` | ${description} |\n`; + } + } + } + this.chatContainer.addChild(new Spacer(1)); this.chatContainer.addChild(new DynamicBorder()); this.chatContainer.addChild(new Text(theme.bold(theme.fg("accent", "Keyboard Shortcuts")), 1, 0)); From c01414d191075774101afe8e2abb1b332da482d0 Mon Sep 17 00:00:00 2001 From: Helmut Januschka Date: Sat, 3 Jan 2026 15:36:18 +0100 Subject: [PATCH 04/30] docs: update changelog with full hook API additions --- packages/coding-agent/CHANGELOG.md | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 41749005..eecdb50b 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -36,9 +36,16 @@ - `$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 -- 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 +- 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`) +- `/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: + - Toggle via `/plan` command, `Shift+P` shortcut, or `--plan` CLI flag + - Read-only tools: `read`, `bash`, `grep`, `find`, `ls` (no `edit`/`write`) + - Bash commands restricted to non-destructive operations (blocks `rm`, `mv`, `git commit`, `npm install`, etc.) + - Interactive prompt after each response: execute plan, stay in plan mode, or refine + - Shows `⏸ plan` indicator in footer when active + - State persists across sessions ### Changed From 4cee51e46a25af4d2b377b920b52a174267d2e78 Mon Sep 17 00:00:00 2001 From: Helmut Januschka Date: Sat, 3 Jan 2026 15:43:04 +0100 Subject: [PATCH 05/30] feat(plan-mode): add todo list extraction and progress tracking MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Extract numbered steps from agent's plan response - Track progress during execution with footer indicator (📋 2/5) - /todos command to view current plan progress - State persists across sessions including todo progress - Agent prompted to format plans as numbered lists for tracking --- packages/coding-agent/CHANGELOG.md | 4 +- .../coding-agent/examples/hooks/plan-mode.ts | 199 ++++++++++++++++-- 2 files changed, 186 insertions(+), 17 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index eecdb50b..d48e8cb1 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -44,8 +44,10 @@ - Read-only tools: `read`, `bash`, `grep`, `find`, `ls` (no `edit`/`write`) - Bash commands restricted to non-destructive operations (blocks `rm`, `mv`, `git commit`, `npm install`, etc.) - Interactive prompt after each response: execute plan, stay in plan mode, or refine + - Todo list extraction from numbered plans with progress tracking (`📋 2/5` in footer) + - `/todos` command to view current plan progress - Shows `⏸ plan` indicator in footer when active - - State persists across sessions + - State persists across sessions (including todo progress) ### Changed diff --git a/packages/coding-agent/examples/hooks/plan-mode.ts b/packages/coding-agent/examples/hooks/plan-mode.ts index 29f2aadb..e1b40aac 100644 --- a/packages/coding-agent/examples/hooks/plan-mode.ts +++ b/packages/coding-agent/examples/hooks/plan-mode.ts @@ -10,11 +10,12 @@ * - 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 + * - Extracts todo list from plan and tracks progress during execution * * 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 + * 3. Or start in plan mode with --plan flag */ import type { HookAPI, HookContext } from "@mariozechner/pi-coding-agent/hooks"; @@ -145,10 +146,82 @@ function isSafeCommand(command: string): boolean { return true; } +// Todo item for plan execution tracking +interface TodoItem { + text: string; + completed: boolean; +} + +/** + * Extract todo items from assistant message. + * Looks for numbered lists like: + * 1. First task + * 2. Second task + * Or bullet points with step indicators: + * - Step 1: Do something + * - Step 2: Do another thing + */ +function extractTodoItems(message: string): TodoItem[] { + const items: TodoItem[] = []; + + // Match numbered lists: "1. Task" or "1) Task" + const numberedPattern = /^\s*(\d+)[.)]\s+(.+)$/gm; + for (const match of message.matchAll(numberedPattern)) { + const text = match[2].trim(); + // Skip if it's just a file path or code reference + if (text.length > 5 && !text.startsWith("`") && !text.startsWith("/")) { + items.push({ text, completed: false }); + } + } + + // If no numbered items found, try bullet points with "Step" prefix + if (items.length === 0) { + const stepPattern = /^\s*[-*]\s*(?:Step\s*\d+[:.])?\s*(.+)$/gim; + for (const match of message.matchAll(stepPattern)) { + const text = match[1].trim(); + if (text.length > 10 && !text.startsWith("`")) { + items.push({ text, completed: false }); + } + } + } + + return items; +} + +/** + * Try to match a tool call or message to a todo item. + * Returns the index of the matching item, or -1 if no match. + */ +function matchTodoItem(todos: TodoItem[], action: string): number { + const actionLower = action.toLowerCase(); + + for (let i = 0; i < todos.length; i++) { + if (todos[i].completed) continue; + + const todoLower = todos[i].text.toLowerCase(); + // Check for keyword overlap + const todoWords = todoLower.split(/\s+/).filter((w) => w.length > 3); + const matchCount = todoWords.filter((w) => actionLower.includes(w)).length; + + // If more than 30% of significant words match, consider it a match + if (todoWords.length > 0 && matchCount / todoWords.length > 0.3) { + return i; + } + } + + return -1; +} + export default function planModeHook(pi: HookAPI) { // Track plan mode state let planModeEnabled = false; + // Track execution mode (after plan confirmed) + let executionMode = false; + + // Todo list extracted from plan + let todoItems: TodoItem[] = []; + // Register --plan CLI flag pi.registerFlag("plan", { description: "Start in plan mode (read-only exploration)", @@ -158,7 +231,12 @@ export default function planModeHook(pi: HookAPI) { // Helper to update footer status function updateStatus(ctx: HookContext) { - if (planModeEnabled) { + if (executionMode && todoItems.length > 0) { + const completed = todoItems.filter((t) => t.completed).length; + const total = todoItems.length; + const progress = `${completed}/${total}`; + ctx.ui.setStatus("plan-mode", ctx.ui.theme.fg("accent", `📋 ${progress}`)); + } else if (planModeEnabled) { ctx.ui.setStatus("plan-mode", ctx.ui.theme.fg("warning", "⏸ plan")); } else { ctx.ui.setStatus("plan-mode", undefined); @@ -168,6 +246,8 @@ export default function planModeHook(pi: HookAPI) { // Helper to toggle plan mode function togglePlanMode(ctx: HookContext) { planModeEnabled = !planModeEnabled; + executionMode = false; + todoItems = []; if (planModeEnabled) { pi.setTools(PLAN_MODE_TOOLS); @@ -187,6 +267,29 @@ export default function planModeHook(pi: HookAPI) { }, }); + // Register /todos command to show current todo list + pi.registerCommand("todos", { + description: "Show current plan todo list", + handler: async (_args, ctx) => { + if (todoItems.length === 0) { + ctx.ui.notify("No todos. Create a plan first with /plan", "info"); + return; + } + + const todoList = todoItems + .map((item, i) => { + const checkbox = item.completed ? "✓" : "○"; + const style = item.completed + ? ctx.ui.theme.fg("muted", `${checkbox} ${item.text}`) + : `${checkbox} ${item.text}`; + return `${i + 1}. ${style}`; + }) + .join("\n"); + + ctx.ui.notify(`Plan Progress:\n${todoList}`, "info"); + }, + }); + // Register Shift+P shortcut pi.registerShortcut("shift+p", { description: "Toggle plan mode", @@ -196,7 +299,17 @@ export default function planModeHook(pi: HookAPI) { }); // Block destructive bash commands in plan mode - pi.on("tool_call", async (event) => { + pi.on("tool_call", async (event, ctx) => { + // Track progress in execution mode + if (executionMode && todoItems.length > 0) { + const action = `${event.toolName}: ${JSON.stringify(event.input).slice(0, 200)}`; + const matchIdx = matchTodoItem(todoItems, action); + if (matchIdx >= 0) { + todoItems[matchIdx].completed = true; + updateStatus(ctx); + } + } + if (!planModeEnabled) return; if (event.toolName !== "bash") return; @@ -228,35 +341,77 @@ Restrictions: - Focus on analysis, planning, and understanding the codebase Your task is to explore, analyze, and create a detailed plan. -Do NOT attempt to make changes - just describe what you would do. -When you have a complete plan, I will switch to normal mode to execute it.`, + +IMPORTANT: When you have a complete plan, format it as a numbered list: +1. First step description +2. Second step description +3. Third step description +... + +This format allows tracking progress during execution. +Do NOT attempt to make changes - just describe what you would do.`, display: false, // Don't show in TUI, just inject into context }, }; }); // After agent finishes, offer to execute the plan - pi.on("agent_end", async (_event, ctx) => { + pi.on("agent_end", async (event, ctx) => { + // In execution mode, check if all todos are complete + if (executionMode && todoItems.length > 0) { + const allComplete = todoItems.every((t) => t.completed); + if (allComplete) { + ctx.ui.notify("Plan execution complete!", "info"); + executionMode = false; + todoItems = []; + updateStatus(ctx); + } + return; + } + if (!planModeEnabled) return; if (!ctx.hasUI) return; - const choice = await ctx.ui.select("Plan mode - what next?", [ - "Execute the plan", + // Try to extract todo items from the last message + const messages = event.messages; + const lastAssistant = [...messages].reverse().find((m) => m.role === "assistant"); + if (lastAssistant && typeof lastAssistant.content === "string") { + const extracted = extractTodoItems(lastAssistant.content); + if (extracted.length > 0) { + todoItems = extracted; + } + } + + const hasTodos = todoItems.length > 0; + const todoPreview = hasTodos + ? `\n\nExtracted ${todoItems.length} steps:\n${todoItems + .slice(0, 3) + .map((t, i) => ` ${i + 1}. ${t.text.slice(0, 50)}...`) + .join("\n")}${todoItems.length > 3 ? `\n ... and ${todoItems.length - 3} more` : ""}` + : ""; + + const choice = await ctx.ui.select(`Plan mode - what next?${todoPreview}`, [ + hasTodos ? "Execute the plan (track progress)" : "Execute the plan", "Stay in plan mode", "Refine the plan", ]); - if (choice === "Execute the plan") { + if (choice?.startsWith("Execute")) { // Switch to normal mode planModeEnabled = false; + executionMode = hasTodos; pi.setTools(NORMAL_MODE_TOOLS); updateStatus(ctx); // Send message to trigger execution immediately + const execMessage = hasTodos + ? `Execute the plan you just created. There are ${todoItems.length} steps to complete. Proceed step by step, announcing each step as you work on it.` + : "Execute the plan you just created. Proceed step by step."; + pi.sendMessage( { customType: "plan-mode-execute", - content: "Execute the plan you just created. Proceed step by step.", + content: execMessage, display: true, }, { triggerTurn: true }, @@ -281,23 +436,35 @@ When you have a complete plan, I will switch to normal mode to execute it.`, 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; + .pop() as { data?: { enabled: boolean; todos?: TodoItem[]; executing?: boolean } } | undefined; // Restore from session (overrides flag if session has state) - if (planModeEntry?.data?.enabled !== undefined) { - planModeEnabled = planModeEntry.data.enabled; + if (planModeEntry?.data) { + if (planModeEntry.data.enabled !== undefined) { + planModeEnabled = planModeEntry.data.enabled; + } + if (planModeEntry.data.todos) { + todoItems = planModeEntry.data.todos; + } + if (planModeEntry.data.executing) { + executionMode = planModeEntry.data.executing; + } } // Apply initial state if plan mode is enabled if (planModeEnabled) { pi.setTools(PLAN_MODE_TOOLS); - updateStatus(ctx); } + updateStatus(ctx); }); // Save state when plan mode changes (via tool_call or other events) pi.on("turn_start", async () => { - // Persist current state - pi.appendEntry("plan-mode", { enabled: planModeEnabled }); + // Persist current state including todos + pi.appendEntry("plan-mode", { + enabled: planModeEnabled, + todos: todoItems, + executing: executionMode, + }); }); } From 51d396b3fd0ecb5583ffa72a1b14a24dc46d3d7a Mon Sep 17 00:00:00 2001 From: Helmut Januschka Date: Sat, 3 Jan 2026 16:01:05 +0100 Subject: [PATCH 06/30] feat(hooks): add setWidget API for multi-line status displays - ctx.ui.setWidget(key, lines) for multi-line displays above editor - Widgets appear below 'Working...' indicator, above editor - Supports ANSI styling including strikethrough - Added theme.strikethrough() method - Plan-mode hook now shows todo list with checkboxes - Completed items show checked box and strikethrough text --- packages/coding-agent/CHANGELOG.md | 6 ++- packages/coding-agent/docs/hooks.md | 15 +++++++ .../coding-agent/examples/hooks/plan-mode.ts | 24 +++++++++-- .../src/core/custom-tools/loader.ts | 1 + .../coding-agent/src/core/hooks/runner.ts | 1 + packages/coding-agent/src/core/hooks/types.ts | 22 ++++++++++ .../src/modes/interactive/interactive-mode.ts | 41 +++++++++++++++++++ .../src/modes/interactive/theme/theme.ts | 4 ++ .../coding-agent/src/modes/rpc/rpc-mode.ts | 11 +++++ .../coding-agent/src/modes/rpc/rpc-types.ts | 7 ++++ .../test/compaction-hooks.test.ts | 1 + 11 files changed, 127 insertions(+), 6 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index d48e8cb1..3805c04f 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -38,15 +38,17 @@ - Hook API: `pi.getTools()` and `pi.setTools(toolNames)` for dynamically enabling/disabling tools from hooks - 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`) +- Hook API: `ctx.ui.setWidget(key, lines)` for multi-line status displays above the editor (todo lists, progress tracking) +- 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: - Toggle via `/plan` command, `Shift+P` shortcut, or `--plan` CLI flag - Read-only tools: `read`, `bash`, `grep`, `find`, `ls` (no `edit`/`write`) - Bash commands restricted to non-destructive operations (blocks `rm`, `mv`, `git commit`, `npm install`, etc.) - Interactive prompt after each response: execute plan, stay in plan mode, or refine - - Todo list extraction from numbered plans with progress tracking (`📋 2/5` in footer) + - Todo list widget showing progress with checkboxes and strikethrough for completed items - `/todos` command to view current plan progress - - Shows `⏸ plan` indicator in footer when active + - Shows `⏸ plan` indicator in footer when in plan mode, `📋 2/5` when executing - State persists across sessions (including todo progress) ### Changed diff --git a/packages/coding-agent/docs/hooks.md b/packages/coding-agent/docs/hooks.md index ffd5bf5f..c062af0e 100644 --- a/packages/coding-agent/docs/hooks.md +++ b/packages/coding-agent/docs/hooks.md @@ -421,6 +421,15 @@ ctx.ui.notify("Done!", "info"); // "info" | "warning" | "error" ctx.ui.setStatus("my-hook", "Processing 5/10..."); // Set status ctx.ui.setStatus("my-hook", undefined); // Clear status +// Set a multi-line widget (displayed above editor, below "Working..." indicator) +ctx.ui.setWidget("my-todos", [ + theme.fg("accent", "Plan Progress:"), + theme.fg("success", "☑ ") + theme.fg("muted", theme.strikethrough("Read files")), + theme.fg("muted", "☐ ") + "Modify code", + theme.fg("muted", "☐ ") + "Run tests", +]); +ctx.ui.setWidget("my-todos", undefined); // Clear widget + // Set the core input editor text (pre-fill prompts, generated content) ctx.ui.setEditorText("Generated prompt text here..."); @@ -434,6 +443,12 @@ const currentText = ctx.ui.getEditorText(); - Text is sanitized (newlines/tabs replaced with spaces) and truncated to terminal width - Use `ctx.ui.theme` to style status text with theme colors (see below) +**Widget notes:** +- Widgets are multi-line displays shown above the editor (below "Working..." indicator) +- Multiple hooks can set widgets using unique keys +- Use for progress lists, todo tracking, or any multi-line status +- Supports ANSI styling via `ctx.ui.theme` (including `strikethrough`) + **Styling with theme colors:** Use `ctx.ui.theme` to apply consistent colors that respect the user's theme: diff --git a/packages/coding-agent/examples/hooks/plan-mode.ts b/packages/coding-agent/examples/hooks/plan-mode.ts index e1b40aac..1e9e92c3 100644 --- a/packages/coding-agent/examples/hooks/plan-mode.ts +++ b/packages/coding-agent/examples/hooks/plan-mode.ts @@ -229,18 +229,34 @@ export default function planModeHook(pi: HookAPI) { default: false, }); - // Helper to update footer status + // Helper to update status displays function updateStatus(ctx: HookContext) { + // Update footer status if (executionMode && todoItems.length > 0) { const completed = todoItems.filter((t) => t.completed).length; - const total = todoItems.length; - const progress = `${completed}/${total}`; - ctx.ui.setStatus("plan-mode", ctx.ui.theme.fg("accent", `📋 ${progress}`)); + ctx.ui.setStatus("plan-mode", ctx.ui.theme.fg("accent", `📋 ${completed}/${todoItems.length}`)); } else if (planModeEnabled) { ctx.ui.setStatus("plan-mode", ctx.ui.theme.fg("warning", "⏸ plan")); } else { ctx.ui.setStatus("plan-mode", undefined); } + + // Update widget with todo list + if (executionMode && todoItems.length > 0) { + const lines: string[] = []; + for (const item of todoItems) { + if (item.completed) { + lines.push( + ctx.ui.theme.fg("success", "☑ ") + ctx.ui.theme.fg("muted", ctx.ui.theme.strikethrough(item.text)), + ); + } else { + lines.push(ctx.ui.theme.fg("muted", "☐ ") + item.text); + } + } + ctx.ui.setWidget("plan-todos", lines); + } else { + ctx.ui.setWidget("plan-todos", undefined); + } } // Helper to toggle plan mode diff --git a/packages/coding-agent/src/core/custom-tools/loader.ts b/packages/coding-agent/src/core/custom-tools/loader.ts index be936c03..1f72c41e 100644 --- a/packages/coding-agent/src/core/custom-tools/loader.ts +++ b/packages/coding-agent/src/core/custom-tools/loader.ts @@ -92,6 +92,7 @@ function createNoOpUIContext(): HookUIContext { input: async () => undefined, notify: () => {}, setStatus: () => {}, + setWidget: () => {}, 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 b6e54843..a957115c 100644 --- a/packages/coding-agent/src/core/hooks/runner.ts +++ b/packages/coding-agent/src/core/hooks/runner.ts @@ -49,6 +49,7 @@ const noOpUIContext: HookUIContext = { input: async () => undefined, notify: () => {}, setStatus: () => {}, + setWidget: () => {}, custom: async () => undefined as never, setEditorText: () => {}, getEditorText: () => "", diff --git a/packages/coding-agent/src/core/hooks/types.ts b/packages/coding-agent/src/core/hooks/types.ts index 592e7ea8..4f336aee 100644 --- a/packages/coding-agent/src/core/hooks/types.ts +++ b/packages/coding-agent/src/core/hooks/types.ts @@ -74,6 +74,28 @@ export interface HookUIContext { */ setStatus(key: string, text: string | undefined): void; + /** + * Set a widget to display in the status area (above the editor, below "Working..." indicator). + * Supports multi-line content. Pass undefined to clear. + * Text can include ANSI escape codes for styling. + * + * @param key - Unique key to identify this widget (e.g., hook name) + * @param lines - Array of lines to display, or undefined to clear + * + * @example + * // Show a todo list + * ctx.ui.setWidget("plan-todos", [ + * theme.fg("accent", "Plan Progress:"), + * "☑ " + theme.fg("muted", theme.strikethrough("Step 1: Read files")), + * "☐ Step 2: Modify code", + * "☐ Step 3: Run tests", + * ]); + * + * // Clear the widget + * ctx.ui.setWidget("plan-todos", undefined); + */ + setWidget(key: string, lines: string[] | undefined): void; + /** * Show a custom component with keyboard focus. * The factory receives TUI, theme, and a done() callback to close the component. diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index 35f920ae..087368aa 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -141,6 +141,10 @@ export class InteractiveMode { private hookInput: HookInputComponent | undefined = undefined; private hookEditor: HookEditorComponent | undefined = undefined; + // Hook widgets (multi-line status displays) + private hookWidgets = new Map(); + private widgetContainer!: Container; + // Custom tools for custom rendering private customTools: Map; @@ -175,6 +179,7 @@ export class InteractiveMode { this.chatContainer = new Container(); this.pendingMessagesContainer = new Container(); this.statusContainer = new Container(); + this.widgetContainer = new Container(); this.keybindings = KeybindingsManager.create(); this.editor = new CustomEditor(getEditorTheme(), this.keybindings); this.editorContainer = new Container(); @@ -330,6 +335,7 @@ export class InteractiveMode { this.ui.addChild(this.chatContainer); this.ui.addChild(this.pendingMessagesContainer); this.ui.addChild(this.statusContainer); + this.ui.addChild(this.widgetContainer); this.ui.addChild(new Spacer(1)); this.ui.addChild(this.editorContainer); this.ui.addChild(this.footer); @@ -618,6 +624,40 @@ export class InteractiveMode { this.ui.requestRender(); } + /** + * Set a hook widget (multi-line status display). + */ + private setHookWidget(key: string, lines: string[] | undefined): void { + if (lines === undefined) { + this.hookWidgets.delete(key); + } else { + this.hookWidgets.set(key, lines); + } + this.renderWidgets(); + } + + /** + * Render all hook widgets to the widget container. + */ + private renderWidgets(): void { + if (!this.widgetContainer) return; + this.widgetContainer.clear(); + + if (this.hookWidgets.size === 0) { + this.ui.requestRender(); + return; + } + + // Render each widget + for (const [_key, lines] of this.hookWidgets) { + for (const line of lines) { + this.widgetContainer.addChild(new Text(line, 1, 0)); + } + } + + this.ui.requestRender(); + } + /** * Create the HookUIContext for hooks and tools. */ @@ -628,6 +668,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), custom: (factory) => this.showHookCustom(factory), setEditorText: (text) => this.editor.setText(text), getEditorText: () => this.editor.getText(), diff --git a/packages/coding-agent/src/modes/interactive/theme/theme.ts b/packages/coding-agent/src/modes/interactive/theme/theme.ts index 5b5155e6..69b3db7a 100644 --- a/packages/coding-agent/src/modes/interactive/theme/theme.ts +++ b/packages/coding-agent/src/modes/interactive/theme/theme.ts @@ -376,6 +376,10 @@ export class Theme { return chalk.inverse(text); } + strikethrough(text: string): string { + return chalk.strikethrough(text); + } + getFgAnsi(color: ThemeColor): string { const ansi = this.fgColors.get(color); if (!ansi) throw new Error(`Unknown theme color: ${color}`); diff --git a/packages/coding-agent/src/modes/rpc/rpc-mode.ts b/packages/coding-agent/src/modes/rpc/rpc-mode.ts index e4d57d9e..10a31d0d 100644 --- a/packages/coding-agent/src/modes/rpc/rpc-mode.ts +++ b/packages/coding-agent/src/modes/rpc/rpc-mode.ts @@ -131,6 +131,17 @@ 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); + }, + async custom() { // Custom UI not supported in RPC mode return undefined as never; diff --git a/packages/coding-agent/src/modes/rpc/rpc-types.ts b/packages/coding-agent/src/modes/rpc/rpc-types.ts index 69e88236..172b745e 100644 --- a/packages/coding-agent/src/modes/rpc/rpc-types.ts +++ b/packages/coding-agent/src/modes/rpc/rpc-types.ts @@ -189,6 +189,13 @@ export type RpcHookUIRequest = notifyType?: "info" | "warning" | "error"; } | { type: "hook_ui_request"; id: string; method: "setStatus"; statusKey: string; statusText: string | undefined } + | { + type: "hook_ui_request"; + id: string; + method: "setWidget"; + widgetKey: string; + widgetLines: string[] | undefined; + } | { type: "hook_ui_request"; id: string; method: "set_editor_text"; text: string }; // ============================================================================ diff --git a/packages/coding-agent/test/compaction-hooks.test.ts b/packages/coding-agent/test/compaction-hooks.test.ts index bf601371..62bd7315 100644 --- a/packages/coding-agent/test/compaction-hooks.test.ts +++ b/packages/coding-agent/test/compaction-hooks.test.ts @@ -118,6 +118,7 @@ describe.skipIf(!API_KEY)("Compaction hooks", () => { input: async () => undefined, notify: () => {}, setStatus: () => {}, + setWidget: () => {}, custom: async () => undefined as never, setEditorText: () => {}, getEditorText: () => "", From ff32bd1540c1401ebe95f93c934429bc31f282b8 Mon Sep 17 00:00:00 2001 From: Helmut Januschka Date: Sat, 3 Jan 2026 16:05:16 +0100 Subject: [PATCH 07/30] fix(plan-mode): show widget after extracting todos and in plan mode --- packages/coding-agent/examples/hooks/plan-mode.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/coding-agent/examples/hooks/plan-mode.ts b/packages/coding-agent/examples/hooks/plan-mode.ts index 1e9e92c3..8ff16467 100644 --- a/packages/coding-agent/examples/hooks/plan-mode.ts +++ b/packages/coding-agent/examples/hooks/plan-mode.ts @@ -241,8 +241,8 @@ export default function planModeHook(pi: HookAPI) { ctx.ui.setStatus("plan-mode", undefined); } - // Update widget with todo list - if (executionMode && todoItems.length > 0) { + // Update widget with todo list (show in both plan mode and execution mode) + if (todoItems.length > 0) { const lines: string[] = []; for (const item of todoItems) { if (item.completed) { @@ -395,6 +395,7 @@ Do NOT attempt to make changes - just describe what you would do.`, const extracted = extractTodoItems(lastAssistant.content); if (extracted.length > 0) { todoItems = extracted; + updateStatus(ctx); // Show the extracted todos } } From 764d9d4efcc2c320497e4273d743137c5e3ab422 Mon Sep 17 00:00:00 2001 From: Helmut Januschka Date: Sat, 3 Jan 2026 16:10:53 +0100 Subject: [PATCH 08/30] fix(plan-mode): fix todo extraction from assistant messages - AssistantMessage.content is an array, not string - Handle markdown bold formatting in numbered lists - Extract text content blocks properly --- .../coding-agent/examples/hooks/plan-mode.ts | 35 ++++++++++++------- 1 file changed, 23 insertions(+), 12 deletions(-) diff --git a/packages/coding-agent/examples/hooks/plan-mode.ts b/packages/coding-agent/examples/hooks/plan-mode.ts index 8ff16467..717158bd 100644 --- a/packages/coding-agent/examples/hooks/plan-mode.ts +++ b/packages/coding-agent/examples/hooks/plan-mode.ts @@ -164,21 +164,24 @@ interface TodoItem { function extractTodoItems(message: string): TodoItem[] { const items: TodoItem[] = []; - // Match numbered lists: "1. Task" or "1) Task" - const numberedPattern = /^\s*(\d+)[.)]\s+(.+)$/gm; + // Match numbered lists: "1. Task" or "1) Task" (handles markdown bold like "1. **Task**") + const numberedPattern = /^\s*(\d+)[.)]\s+\*{0,2}([^*\n]+)/gm; for (const match of message.matchAll(numberedPattern)) { - const text = match[2].trim(); - // Skip if it's just a file path or code reference - if (text.length > 5 && !text.startsWith("`") && !text.startsWith("/")) { + let text = match[2].trim(); + // Remove trailing ** if present + text = text.replace(/\*{1,2}$/, "").trim(); + // Skip if it's just a file path, code reference, or sub-item + if (text.length > 5 && !text.startsWith("`") && !text.startsWith("/") && !text.startsWith("-")) { items.push({ text, completed: false }); } } // If no numbered items found, try bullet points with "Step" prefix if (items.length === 0) { - const stepPattern = /^\s*[-*]\s*(?:Step\s*\d+[:.])?\s*(.+)$/gim; + const stepPattern = /^\s*[-*]\s*(?:Step\s*\d+[:.])?\s*\*{0,2}([^*\n]+)/gim; for (const match of message.matchAll(stepPattern)) { - const text = match[1].trim(); + let text = match[1].trim(); + text = text.replace(/\*{1,2}$/, "").trim(); if (text.length > 10 && !text.startsWith("`")) { items.push({ text, completed: false }); } @@ -391,11 +394,19 @@ Do NOT attempt to make changes - just describe what you would do.`, // Try to extract todo items from the last message const messages = event.messages; const lastAssistant = [...messages].reverse().find((m) => m.role === "assistant"); - if (lastAssistant && typeof lastAssistant.content === "string") { - const extracted = extractTodoItems(lastAssistant.content); - if (extracted.length > 0) { - todoItems = extracted; - updateStatus(ctx); // Show the extracted todos + if (lastAssistant && Array.isArray(lastAssistant.content)) { + // Extract text from content blocks + const textContent = lastAssistant.content + .filter((block): block is { type: "text"; text: string } => block.type === "text") + .map((block) => block.text) + .join("\n"); + + if (textContent) { + const extracted = extractTodoItems(textContent); + if (extracted.length > 0) { + todoItems = extracted; + updateStatus(ctx); // Show the extracted todos + } } } From 435acf1c188d0b49199338230c360bca4b1765d5 Mon Sep 17 00:00:00 2001 From: Helmut Januschka Date: Sat, 3 Jan 2026 16:11:57 +0100 Subject: [PATCH 09/30] feat(plan-mode): show todo list in chat after planning, widget during execution - After agent creates plan: show todo list as a message in chat - During execution: show widget under Working indicator with checkboxes - Check off items as they complete with strikethrough --- .../coding-agent/examples/hooks/plan-mode.ts | 28 +++++++++++-------- 1 file changed, 17 insertions(+), 11 deletions(-) diff --git a/packages/coding-agent/examples/hooks/plan-mode.ts b/packages/coding-agent/examples/hooks/plan-mode.ts index 717158bd..af45ce9e 100644 --- a/packages/coding-agent/examples/hooks/plan-mode.ts +++ b/packages/coding-agent/examples/hooks/plan-mode.ts @@ -244,8 +244,8 @@ export default function planModeHook(pi: HookAPI) { ctx.ui.setStatus("plan-mode", undefined); } - // Update widget with todo list (show in both plan mode and execution mode) - if (todoItems.length > 0) { + // Update widget with todo list (only during execution mode) + if (executionMode && todoItems.length > 0) { const lines: string[] = []; for (const item of todoItems) { if (item.completed) { @@ -405,20 +405,26 @@ Do NOT attempt to make changes - just describe what you would do.`, const extracted = extractTodoItems(textContent); if (extracted.length > 0) { todoItems = extracted; - updateStatus(ctx); // Show the extracted todos } } } const hasTodos = todoItems.length > 0; - const todoPreview = hasTodos - ? `\n\nExtracted ${todoItems.length} steps:\n${todoItems - .slice(0, 3) - .map((t, i) => ` ${i + 1}. ${t.text.slice(0, 50)}...`) - .join("\n")}${todoItems.length > 3 ? `\n ... and ${todoItems.length - 3} more` : ""}` - : ""; - const choice = await ctx.ui.select(`Plan mode - what next?${todoPreview}`, [ + // Show todo list in chat if we extracted items + if (hasTodos) { + const todoListText = todoItems.map((t, i) => `☐ ${i + 1}. ${t.text}`).join("\n"); + pi.sendMessage( + { + customType: "plan-todo-list", + content: `**Plan Steps (${todoItems.length}):**\n\n${todoListText}`, + display: true, + }, + { triggerTurn: false }, + ); + } + + const choice = await ctx.ui.select("Plan mode - what next?", [ hasTodos ? "Execute the plan (track progress)" : "Execute the plan", "Stay in plan mode", "Refine the plan", @@ -429,7 +435,7 @@ Do NOT attempt to make changes - just describe what you would do.`, planModeEnabled = false; executionMode = hasTodos; pi.setTools(NORMAL_MODE_TOOLS); - updateStatus(ctx); + updateStatus(ctx); // This will now show the widget during execution // Send message to trigger execution immediately const execMessage = hasTodos From ed8f82b5f02bd01858b59583d5010b37f9905532 Mon Sep 17 00:00:00 2001 From: Helmut Januschka Date: Sat, 3 Jan 2026 16:20:26 +0100 Subject: [PATCH 10/30] feat(plan-mode): use ID-based todo tracking with [DONE:id] tags - Each todo item gets a unique ID (e.g., abc123) - Agent marks items complete by outputting [DONE:id] - IDs shown in chat and in execution context - Agent instructed to output [DONE:id] after each step - Removed unreliable tool-counting heuristics --- .../coding-agent/examples/hooks/plan-mode.ts | 228 ++++++++---------- 1 file changed, 105 insertions(+), 123 deletions(-) diff --git a/packages/coding-agent/examples/hooks/plan-mode.ts b/packages/coding-agent/examples/hooks/plan-mode.ts index af45ce9e..1d6abad4 100644 --- a/packages/coding-agent/examples/hooks/plan-mode.ts +++ b/packages/coding-agent/examples/hooks/plan-mode.ts @@ -11,6 +11,7 @@ * - After each agent response, prompts to execute the plan or continue planning * - Shows "plan" indicator in footer when active * - Extracts todo list from plan and tracks progress during execution + * - Agent marks steps complete by outputting [DONE:id] tags * * Usage: * 1. Copy this file to ~/.pi/agent/hooks/ or your project's .pi/hooks/ @@ -28,35 +29,29 @@ 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 + /\bcp\b/i, /\bmkdir\b/i, /\btouch\b/i, /\bchmod\b/i, /\bchown\b/i, /\bchgrp\b/i, - /\bln\b/i, // symlinks - // File content modification + /\bln\b/i, /\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, @@ -66,7 +61,6 @@ const DESTRUCTIVE_PATTERNS = [ /\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, ]; @@ -113,77 +107,65 @@ const SAFE_COMMANDS = [ /^\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*curl\s/i, + /^\s*wget\s+-O\s*-/i, /^\s*jq\b/, - /^\s*sed\s+-n/i, // sed with -n (no auto-print) for reading only + /^\s*sed\s+-n/i, /^\s*awk\b/, - /^\s*rg\b/, // ripgrep - /^\s*fd\b/, // fd-find - /^\s*bat\b/, // bat (cat clone) - /^\s*exa\b/, // exa (ls clone) + /^\s*rg\b/, + /^\s*fd\b/, + /^\s*bat\b/, + /^\s*exa\b/, ]; -/** - * 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; } -// Todo item for plan execution tracking +// Todo item with unique ID interface TodoItem { + id: string; text: string; completed: boolean; } +// Generate a short unique ID +function generateId(): string { + return Math.random().toString(36).substring(2, 8); +} + /** - * Extract todo items from assistant message. - * Looks for numbered lists like: - * 1. First task - * 2. Second task - * Or bullet points with step indicators: - * - Step 1: Do something - * - Step 2: Do another thing + * Extract todo items from assistant message and assign IDs. */ function extractTodoItems(message: string): TodoItem[] { const items: TodoItem[] = []; - // Match numbered lists: "1. Task" or "1) Task" (handles markdown bold like "1. **Task**") + // Match numbered lists: "1. Task" or "1) Task" const numberedPattern = /^\s*(\d+)[.)]\s+\*{0,2}([^*\n]+)/gm; for (const match of message.matchAll(numberedPattern)) { let text = match[2].trim(); - // Remove trailing ** if present text = text.replace(/\*{1,2}$/, "").trim(); - // Skip if it's just a file path, code reference, or sub-item if (text.length > 5 && !text.startsWith("`") && !text.startsWith("/") && !text.startsWith("-")) { - items.push({ text, completed: false }); + items.push({ id: generateId(), text, completed: false }); } } - // If no numbered items found, try bullet points with "Step" prefix + // If no numbered items, try bullet points if (items.length === 0) { const stepPattern = /^\s*[-*]\s*(?:Step\s*\d+[:.])?\s*\*{0,2}([^*\n]+)/gim; for (const match of message.matchAll(stepPattern)) { let text = match[1].trim(); text = text.replace(/\*{1,2}$/, "").trim(); if (text.length > 10 && !text.startsWith("`")) { - items.push({ text, completed: false }); + items.push({ id: generateId(), text, completed: false }); } } } @@ -192,37 +174,20 @@ function extractTodoItems(message: string): TodoItem[] { } /** - * Try to match a tool call or message to a todo item. - * Returns the index of the matching item, or -1 if no match. + * Find [DONE:id] tags in text and return the IDs. */ -function matchTodoItem(todos: TodoItem[], action: string): number { - const actionLower = action.toLowerCase(); - - for (let i = 0; i < todos.length; i++) { - if (todos[i].completed) continue; - - const todoLower = todos[i].text.toLowerCase(); - // Check for keyword overlap - const todoWords = todoLower.split(/\s+/).filter((w) => w.length > 3); - const matchCount = todoWords.filter((w) => actionLower.includes(w)).length; - - // If more than 30% of significant words match, consider it a match - if (todoWords.length > 0 && matchCount / todoWords.length > 0.3) { - return i; - } +function findDoneTags(text: string): string[] { + const pattern = /\[DONE:([a-z0-9]+)\]/gi; + const ids: string[] = []; + for (const match of text.matchAll(pattern)) { + ids.push(match[1].toLowerCase()); } - - return -1; + return ids; } export default function planModeHook(pi: HookAPI) { - // Track plan mode state let planModeEnabled = false; - - // Track execution mode (after plan confirmed) let executionMode = false; - - // Todo list extracted from plan let todoItems: TodoItem[] = []; // Register --plan CLI flag @@ -234,7 +199,6 @@ export default function planModeHook(pi: HookAPI) { // Helper to update status displays function updateStatus(ctx: HookContext) { - // Update footer status if (executionMode && todoItems.length > 0) { const completed = todoItems.filter((t) => t.completed).length; ctx.ui.setStatus("plan-mode", ctx.ui.theme.fg("accent", `📋 ${completed}/${todoItems.length}`)); @@ -244,7 +208,7 @@ export default function planModeHook(pi: HookAPI) { ctx.ui.setStatus("plan-mode", undefined); } - // Update widget with todo list (only during execution mode) + // Show widget during execution if (executionMode && todoItems.length > 0) { const lines: string[] = []; for (const item of todoItems) { @@ -262,7 +226,6 @@ export default function planModeHook(pi: HookAPI) { } } - // Helper to toggle plan mode function togglePlanMode(ctx: HookContext) { planModeEnabled = !planModeEnabled; executionMode = false; @@ -286,7 +249,7 @@ export default function planModeHook(pi: HookAPI) { }, }); - // Register /todos command to show current todo list + // Register /todos command pi.registerCommand("todos", { description: "Show current plan todo list", handler: async (_args, ctx) => { @@ -296,12 +259,9 @@ export default function planModeHook(pi: HookAPI) { } const todoList = todoItems - .map((item, i) => { + .map((item) => { const checkbox = item.completed ? "✓" : "○"; - const style = item.completed - ? ctx.ui.theme.fg("muted", `${checkbox} ${item.text}`) - : `${checkbox} ${item.text}`; - return `${i + 1}. ${style}`; + return `[${item.id}] ${checkbox} ${item.text}`; }) .join("\n"); @@ -317,18 +277,8 @@ export default function planModeHook(pi: HookAPI) { }, }); - // Block destructive bash commands in plan mode - pi.on("tool_call", async (event, ctx) => { - // Track progress in execution mode - if (executionMode && todoItems.length > 0) { - const action = `${event.toolName}: ${JSON.stringify(event.input).slice(0, 200)}`; - const matchIdx = matchTodoItem(todoItems, action); - if (matchIdx >= 0) { - todoItems[matchIdx].completed = true; - updateStatus(ctx); - } - } - + // Block destructive bash in plan mode + pi.on("tool_call", async (event) => { if (!planModeEnabled) return; if (event.toolName !== "bash") return; @@ -341,48 +291,89 @@ export default function planModeHook(pi: HookAPI) { } }); - // Inject plan mode context at the start of each turn via before_agent_start - pi.on("before_agent_start", async () => { - if (!planModeEnabled) return; + // Check for [DONE:id] tags after each tool result (agent may output them in tool-related text) + pi.on("tool_result", async (_event, ctx) => { + if (!executionMode || todoItems.length === 0) return; + // The actual checking happens in agent_end when we have the full message + // But we update status here to keep UI responsive + updateStatus(ctx); + }); - // Return a message to inject into context - return { - message: { - customType: "plan-mode-context", - content: `[PLAN MODE ACTIVE] + // Inject plan mode context + pi.on("before_agent_start", async () => { + if (!planModeEnabled && !executionMode) return; + + if (planModeEnabled) { + return { + message: { + customType: "plan-mode-context", + content: `[PLAN MODE ACTIVE] You are in plan mode - a read-only exploration mode for safe code analysis. Restrictions: - 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.) +- Bash is restricted to READ-ONLY commands - Focus on analysis, planning, and understanding the codebase -Your task is to explore, analyze, and create a detailed plan. - -IMPORTANT: When you have a complete plan, format it as a numbered list: +Create a detailed numbered plan: 1. First step description 2. Second step description -3. Third step description ... -This format allows tracking progress during execution. Do NOT attempt to make changes - just describe what you would do.`, - display: false, // Don't show in TUI, just inject into context - }, - }; + display: false, + }, + }; + } + + if (executionMode && todoItems.length > 0) { + const todoList = todoItems.map((t) => `- [${t.id}] ${t.completed ? "☑" : "☐"} ${t.text}`).join("\n"); + return { + message: { + customType: "plan-execution-context", + content: `[EXECUTING PLAN] +You have a plan with ${todoItems.length} steps. After completing each step, output [DONE:id] to mark it complete. + +Current plan status: +${todoList} + +IMPORTANT: After completing each step, output [DONE:id] where id is the step's ID (e.g., [DONE:${todoItems.find((t) => !t.completed)?.id || todoItems[0].id}]).`, + display: false, + }, + }; + } }); - // After agent finishes, offer to execute the plan + // After agent finishes in plan mode pi.on("agent_end", async (event, ctx) => { - // In execution mode, check if all todos are complete + // Check for done tags in the final message too if (executionMode && todoItems.length > 0) { + const messages = event.messages; + const lastAssistant = [...messages].reverse().find((m) => m.role === "assistant"); + if (lastAssistant && Array.isArray(lastAssistant.content)) { + const textContent = lastAssistant.content + .filter((block): block is { type: "text"; text: string } => block.type === "text") + .map((block) => block.text) + .join("\n"); + + const doneIds = findDoneTags(textContent); + for (const id of doneIds) { + const item = todoItems.find((t) => t.id === id); + if (item && !item.completed) { + item.completed = true; + } + } + updateStatus(ctx); + } + + // Check if all complete const allComplete = todoItems.every((t) => t.completed); if (allComplete) { ctx.ui.notify("Plan execution complete!", "info"); executionMode = false; todoItems = []; + pi.setTools(NORMAL_MODE_TOOLS); updateStatus(ctx); } return; @@ -391,11 +382,10 @@ Do NOT attempt to make changes - just describe what you would do.`, if (!planModeEnabled) return; if (!ctx.hasUI) return; - // Try to extract todo items from the last message + // Extract todos from last message const messages = event.messages; const lastAssistant = [...messages].reverse().find((m) => m.role === "assistant"); if (lastAssistant && Array.isArray(lastAssistant.content)) { - // Extract text from content blocks const textContent = lastAssistant.content .filter((block): block is { type: "text"; text: string } => block.type === "text") .map((block) => block.text) @@ -411,9 +401,9 @@ Do NOT attempt to make changes - just describe what you would do.`, const hasTodos = todoItems.length > 0; - // Show todo list in chat if we extracted items + // Show todo list in chat with IDs if (hasTodos) { - const todoListText = todoItems.map((t, i) => `☐ ${i + 1}. ${t.text}`).join("\n"); + const todoListText = todoItems.map((t) => `☐ [${t.id}] ${t.text}`).join("\n"); pi.sendMessage( { customType: "plan-todo-list", @@ -431,15 +421,13 @@ Do NOT attempt to make changes - just describe what you would do.`, ]); if (choice?.startsWith("Execute")) { - // Switch to normal mode planModeEnabled = false; executionMode = hasTodos; pi.setTools(NORMAL_MODE_TOOLS); - updateStatus(ctx); // This will now show the widget during execution + updateStatus(ctx); - // Send message to trigger execution immediately const execMessage = hasTodos - ? `Execute the plan you just created. There are ${todoItems.length} steps to complete. Proceed step by step, announcing each step as you work on it.` + ? `Execute the plan. After completing each step, output [DONE:id] where id is the step's ID. Start with step [${todoItems[0].id}]: ${todoItems[0].text}` : "Execute the plan you just created. Proceed step by step."; pi.sendMessage( @@ -456,23 +444,19 @@ Do NOT attempt to make changes - just describe what you would do.`, ctx.ui.setEditorText(refinement); } } - // "Stay in plan mode" - do nothing, just continue }); - // Initialize plan mode state on session start + // Initialize state on session start pi.on("session_start", async (_event, ctx) => { - // 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; todos?: TodoItem[]; executing?: boolean } } | undefined; - // Restore from session (overrides flag if session has state) if (planModeEntry?.data) { if (planModeEntry.data.enabled !== undefined) { planModeEnabled = planModeEntry.data.enabled; @@ -485,16 +469,14 @@ Do NOT attempt to make changes - just describe what you would do.`, } } - // Apply initial state if plan mode is enabled if (planModeEnabled) { pi.setTools(PLAN_MODE_TOOLS); } updateStatus(ctx); }); - // Save state when plan mode changes (via tool_call or other events) + // Persist state pi.on("turn_start", async () => { - // Persist current state including todos pi.appendEntry("plan-mode", { enabled: planModeEnabled, todos: todoItems, From 4d060c38310c31e039face734e3817a7e15050cc Mon Sep 17 00:00:00 2001 From: Helmut Januschka Date: Sat, 3 Jan 2026 16:26:36 +0100 Subject: [PATCH 11/30] feat(hooks): add text_delta event for streaming text monitoring - New text_delta hook event fires for each chunk of streaming text - Enables real-time monitoring of agent output - Plan-mode hook now updates todo progress as [DONE:id] tags stream in - Each todo item has unique ID for reliable tracking --- packages/coding-agent/CHANGELOG.md | 3 +++ packages/coding-agent/docs/hooks.md | 15 ++++++++++++ .../coding-agent/examples/hooks/plan-mode.ts | 23 +++++++++++++++---- .../coding-agent/src/core/agent-session.ts | 9 ++++++++ packages/coding-agent/src/core/hooks/types.ts | 12 ++++++++++ 5 files changed, 57 insertions(+), 5 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 3805c04f..0c6fbdcb 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -40,6 +40,7 @@ - Hook API: `pi.registerShortcut(shortcut, options)` for hooks to register custom keyboard shortcuts (e.g., `shift+p`, `ctrl+shift+x`) - Hook API: `ctx.ui.setWidget(key, lines)` for multi-line status displays above the editor (todo lists, progress tracking) - Hook API: `theme.strikethrough(text)` for strikethrough text styling +- Hook API: `text_delta` event for monitoring streaming assistant text in real-time - `/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: - Toggle via `/plan` command, `Shift+P` shortcut, or `--plan` CLI flag @@ -47,6 +48,8 @@ - Bash commands restricted to non-destructive operations (blocks `rm`, `mv`, `git commit`, `npm install`, etc.) - Interactive prompt after each response: execute plan, stay in plan mode, or refine - Todo list widget showing progress with checkboxes and strikethrough for completed items + - Each todo has a unique ID; agent marks items done by outputting `[DONE:id]` + - Real-time progress updates via streaming text monitoring - `/todos` command to view current plan progress - Shows `⏸ plan` indicator in footer when in plan mode, `📋 2/5` when executing - State persists across sessions (including todo progress) diff --git a/packages/coding-agent/docs/hooks.md b/packages/coding-agent/docs/hooks.md index c062af0e..1b6b57c9 100644 --- a/packages/coding-agent/docs/hooks.md +++ b/packages/coding-agent/docs/hooks.md @@ -306,6 +306,21 @@ pi.on("turn_end", async (event, ctx) => { }); ``` +#### text_delta + +Fired for each chunk of streaming text from the assistant. Useful for real-time monitoring of agent output. + +```typescript +pi.on("text_delta", async (event, ctx) => { + // event.text - the new text chunk + + // Example: watch for specific patterns in streaming output + if (event.text.includes("[DONE:")) { + // Handle completion marker + } +}); +``` + #### context Fired before each LLM call. Modify messages non-destructively (session unchanged). diff --git a/packages/coding-agent/examples/hooks/plan-mode.ts b/packages/coding-agent/examples/hooks/plan-mode.ts index 1d6abad4..ee9c2a46 100644 --- a/packages/coding-agent/examples/hooks/plan-mode.ts +++ b/packages/coding-agent/examples/hooks/plan-mode.ts @@ -291,12 +291,25 @@ export default function planModeHook(pi: HookAPI) { } }); - // Check for [DONE:id] tags after each tool result (agent may output them in tool-related text) - pi.on("tool_result", async (_event, ctx) => { + // Watch for [DONE:id] tags in streaming text + pi.on("text_delta", async (event, ctx) => { if (!executionMode || todoItems.length === 0) return; - // The actual checking happens in agent_end when we have the full message - // But we update status here to keep UI responsive - updateStatus(ctx); + + const doneIds = findDoneTags(event.text); + if (doneIds.length === 0) return; + + let changed = false; + for (const id of doneIds) { + const item = todoItems.find((t) => t.id === id); + if (item && !item.completed) { + item.completed = true; + changed = true; + } + } + + if (changed) { + updateStatus(ctx); + } }); // Inject plan mode context diff --git a/packages/coding-agent/src/core/agent-session.ts b/packages/coding-agent/src/core/agent-session.ts index 689d6ca7..8bf3d96b 100644 --- a/packages/coding-agent/src/core/agent-session.ts +++ b/packages/coding-agent/src/core/agent-session.ts @@ -224,6 +224,15 @@ export class AgentSession { /** Internal handler for agent events - shared by subscribe and reconnect */ private _handleAgentEvent = async (event: AgentEvent): Promise => { + // Emit text_delta events to hooks for streaming text monitoring + if ( + event.type === "message_update" && + event.assistantMessageEvent.type === "text_delta" && + this._hookRunner + ) { + await this._hookRunner.emit({ type: "text_delta", text: event.assistantMessageEvent.delta }); + } + // When a user message starts, check if it's from either queue and remove it BEFORE emitting // This ensures the UI sees the updated queue state if (event.type === "message_start" && event.message.role === "user") { diff --git a/packages/coding-agent/src/core/hooks/types.ts b/packages/coding-agent/src/core/hooks/types.ts index 4f336aee..f2a895fa 100644 --- a/packages/coding-agent/src/core/hooks/types.ts +++ b/packages/coding-agent/src/core/hooks/types.ts @@ -392,6 +392,16 @@ export interface AgentEndEvent { messages: AgentMessage[]; } +/** + * Event data for text_delta event. + * Fired when new text is streamed from the assistant. + */ +export interface TextDeltaEvent { + type: "text_delta"; + /** The new text chunk */ + text: string; +} + /** * Event data for turn_start event. */ @@ -535,6 +545,7 @@ export type HookEvent = | BeforeAgentStartEvent | AgentStartEvent | AgentEndEvent + | TextDeltaEvent | TurnStartEvent | TurnEndEvent | ToolCallEvent @@ -701,6 +712,7 @@ export interface HookAPI { on(event: "turn_end", handler: HookHandler): void; on(event: "tool_call", handler: HookHandler): void; on(event: "tool_result", handler: HookHandler): void; + on(event: "text_delta", handler: HookHandler): void; /** * Send a custom message to the session. Creates a CustomMessageEntry that From 5a6c194df8d0ed7fb80d9ec807d72e2037474490 Mon Sep 17 00:00:00 2001 From: Helmut Januschka Date: Sat, 3 Jan 2026 16:28:53 +0100 Subject: [PATCH 12/30] fix(plan-mode): buffer text_delta to handle split [DONE:id] patterns The [DONE:id] pattern may be split across multiple streaming chunks. Now accumulates text in a buffer and scans for complete patterns. --- .../coding-agent/examples/hooks/plan-mode.ts | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/packages/coding-agent/examples/hooks/plan-mode.ts b/packages/coding-agent/examples/hooks/plan-mode.ts index ee9c2a46..c817db63 100644 --- a/packages/coding-agent/examples/hooks/plan-mode.ts +++ b/packages/coding-agent/examples/hooks/plan-mode.ts @@ -291,11 +291,18 @@ export default function planModeHook(pi: HookAPI) { } }); + // Buffer for accumulating text to handle [DONE:id] split across chunks + let textBuffer = ""; + // Watch for [DONE:id] tags in streaming text pi.on("text_delta", async (event, ctx) => { if (!executionMode || todoItems.length === 0) return; - const doneIds = findDoneTags(event.text); + // Accumulate text in buffer + textBuffer += event.text; + + // Look for complete [DONE:id] patterns + const doneIds = findDoneTags(textBuffer); if (doneIds.length === 0) return; let changed = false; @@ -307,6 +314,11 @@ export default function planModeHook(pi: HookAPI) { } } + // Clear processed patterns from buffer (keep last 20 chars for partial matches) + if (textBuffer.length > 50) { + textBuffer = textBuffer.slice(-20); + } + if (changed) { updateStatus(ctx); } @@ -360,6 +372,9 @@ IMPORTANT: After completing each step, output [DONE:id] where id is the step's I // After agent finishes in plan mode pi.on("agent_end", async (event, ctx) => { + // Clear text buffer + textBuffer = ""; + // Check for done tags in the final message too if (executionMode && todoItems.length > 0) { const messages = event.messages; From d7546f08ce71fa4652b41fe7a298f7b0e0e7643d Mon Sep 17 00:00:00 2001 From: Helmut Januschka Date: Sat, 3 Jan 2026 16:34:06 +0100 Subject: [PATCH 13/30] feat(plan-mode): show final completed list in chat when plan finishes Displays all completed items with strikethrough markdown when all todos are done. --- packages/coding-agent/examples/hooks/plan-mode.ts | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/packages/coding-agent/examples/hooks/plan-mode.ts b/packages/coding-agent/examples/hooks/plan-mode.ts index c817db63..2645f27d 100644 --- a/packages/coding-agent/examples/hooks/plan-mode.ts +++ b/packages/coding-agent/examples/hooks/plan-mode.ts @@ -398,8 +398,21 @@ IMPORTANT: After completing each step, output [DONE:id] where id is the step's I // Check if all complete const allComplete = todoItems.every((t) => t.completed); if (allComplete) { - ctx.ui.notify("Plan execution complete!", "info"); + // Show final completed list in chat + const completedList = todoItems + .map((t) => `~~${t.text}~~`) + .join("\n"); + pi.sendMessage( + { + customType: "plan-complete", + content: `**Plan Complete!** ✓\n\n${completedList}`, + display: true, + }, + { triggerTurn: false }, + ); + executionMode = false; + const completedItems = [...todoItems]; // Keep for reference todoItems = []; pi.setTools(NORMAL_MODE_TOOLS); updateStatus(ctx); From 4a8d92ff73c5f1242f9b1fdb52956fbd00e149da Mon Sep 17 00:00:00 2001 From: Helmut Januschka Date: Sat, 3 Jan 2026 21:30:19 +0100 Subject: [PATCH 14/30] refactor(hooks): address PR feedback - Rename getTools/setTools to getActiveTools/setActiveTools - Add getAllTools to enumerate all configured tools - Remove text_delta event (use turn_end/agent_end instead) - Add shortcut conflict detection: - Skip shortcuts that conflict with built-in shortcuts (with warning) - Log warnings when hooks register same shortcut (last wins) - Add note about prompt cache invalidation in setActiveTools - Update plan-mode hook to use agent_end for [DONE:id] parsing --- packages/coding-agent/CHANGELOG.md | 8 +-- packages/coding-agent/docs/hooks.md | 35 +++++----- .../coding-agent/examples/hooks/plan-mode.ts | 54 +++------------ .../coding-agent/src/core/agent-session.ts | 16 ++--- packages/coding-agent/src/core/hooks/index.ts | 5 +- .../coding-agent/src/core/hooks/loader.ts | 65 ++++++++++++------- .../coding-agent/src/core/hooks/runner.ts | 57 +++++++++++++--- packages/coding-agent/src/core/hooks/types.ts | 27 ++++---- packages/coding-agent/src/core/sdk.ts | 21 +++--- .../src/modes/interactive/interactive-mode.ts | 5 +- packages/coding-agent/src/modes/print-mode.ts | 5 +- .../coding-agent/src/modes/rpc/rpc-mode.ts | 5 +- .../test/compaction-hooks.test.ts | 25 ++++--- 13 files changed, 175 insertions(+), 153 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 0c6fbdcb..2c6b81b7 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -35,12 +35,12 @@ ### Added - `$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 +- 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`) +- 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: `theme.strikethrough(text)` for strikethrough text styling -- Hook API: `text_delta` event for monitoring streaming assistant text in real-time - `/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: - Toggle via `/plan` command, `Shift+P` shortcut, or `--plan` CLI flag @@ -49,7 +49,7 @@ - Interactive prompt after each response: execute plan, stay in plan mode, or refine - Todo list widget showing progress with checkboxes and strikethrough for completed items - Each todo has a unique ID; agent marks items done by outputting `[DONE:id]` - - Real-time progress updates via streaming text monitoring + - Progress updates via `agent_end` hook (parses completed items from final message) - `/todos` command to view current plan progress - Shows `⏸ plan` indicator in footer when in plan mode, `📋 2/5` when executing - State persists across sessions (including todo progress) diff --git a/packages/coding-agent/docs/hooks.md b/packages/coding-agent/docs/hooks.md index 1b6b57c9..bc302b3e 100644 --- a/packages/coding-agent/docs/hooks.md +++ b/packages/coding-agent/docs/hooks.md @@ -306,21 +306,6 @@ pi.on("turn_end", async (event, ctx) => { }); ``` -#### text_delta - -Fired for each chunk of streaming text from the assistant. Useful for real-time monitoring of agent output. - -```typescript -pi.on("text_delta", async (event, ctx) => { - // event.text - the new text chunk - - // Example: watch for specific patterns in streaming output - if (event.text.includes("[DONE:")) { - // Handle completion marker - } -}); -``` - #### context Fired before each LLM call. Modify messages non-destructively (session unchanged). @@ -782,25 +767,35 @@ const result = await pi.exec("git", ["status"], { // result.stdout, result.stderr, result.code, result.killed ``` -### pi.getTools() +### pi.getActiveTools() Get the names of currently active tools: ```typescript -const toolNames = pi.getTools(); +const toolNames = pi.getActiveTools(); // ["read", "bash", "edit", "write"] ``` -### pi.setTools(toolNames) +### pi.getAllTools() + +Get all configured tools (built-in via --tools or default, plus custom tools): + +```typescript +const allTools = pi.getAllTools(); +// ["read", "bash", "edit", "write", "my-custom-tool"] +``` + +### pi.setActiveTools(toolNames) Set the active tools by name. Changes take effect on the next agent turn. +Note: This will invalidate prompt caching for the next request. ```typescript // Switch to read-only mode (plan mode) -pi.setTools(["read", "bash", "grep", "find", "ls"]); +pi.setActiveTools(["read", "bash", "grep", "find", "ls"]); // Restore full access -pi.setTools(["read", "bash", "edit", "write"]); +pi.setActiveTools(["read", "bash", "edit", "write"]); ``` Only built-in tools can be enabled/disabled. Custom tools are always active. Unknown tool names are ignored. diff --git a/packages/coding-agent/examples/hooks/plan-mode.ts b/packages/coding-agent/examples/hooks/plan-mode.ts index 2645f27d..8bd4fd08 100644 --- a/packages/coding-agent/examples/hooks/plan-mode.ts +++ b/packages/coding-agent/examples/hooks/plan-mode.ts @@ -232,10 +232,10 @@ export default function planModeHook(pi: HookAPI) { todoItems = []; if (planModeEnabled) { - pi.setTools(PLAN_MODE_TOOLS); + pi.setActiveTools(PLAN_MODE_TOOLS); ctx.ui.notify(`Plan mode enabled. Tools: ${PLAN_MODE_TOOLS.join(", ")}`); } else { - pi.setTools(NORMAL_MODE_TOOLS); + pi.setActiveTools(NORMAL_MODE_TOOLS); ctx.ui.notify("Plan mode disabled. Full access restored."); } updateStatus(ctx); @@ -291,39 +291,6 @@ export default function planModeHook(pi: HookAPI) { } }); - // Buffer for accumulating text to handle [DONE:id] split across chunks - let textBuffer = ""; - - // Watch for [DONE:id] tags in streaming text - pi.on("text_delta", async (event, ctx) => { - if (!executionMode || todoItems.length === 0) return; - - // Accumulate text in buffer - textBuffer += event.text; - - // Look for complete [DONE:id] patterns - const doneIds = findDoneTags(textBuffer); - if (doneIds.length === 0) return; - - let changed = false; - for (const id of doneIds) { - const item = todoItems.find((t) => t.id === id); - if (item && !item.completed) { - item.completed = true; - changed = true; - } - } - - // Clear processed patterns from buffer (keep last 20 chars for partial matches) - if (textBuffer.length > 50) { - textBuffer = textBuffer.slice(-20); - } - - if (changed) { - updateStatus(ctx); - } - }); - // Inject plan mode context pi.on("before_agent_start", async () => { if (!planModeEnabled && !executionMode) return; @@ -372,10 +339,7 @@ IMPORTANT: After completing each step, output [DONE:id] where id is the step's I // After agent finishes in plan mode pi.on("agent_end", async (event, ctx) => { - // Clear text buffer - textBuffer = ""; - - // Check for done tags in the final message too + // Check for done tags in the final message if (executionMode && todoItems.length > 0) { const messages = event.messages; const lastAssistant = [...messages].reverse().find((m) => m.role === "assistant"); @@ -399,9 +363,7 @@ IMPORTANT: After completing each step, output [DONE:id] where id is the step's I const allComplete = todoItems.every((t) => t.completed); if (allComplete) { // Show final completed list in chat - const completedList = todoItems - .map((t) => `~~${t.text}~~`) - .join("\n"); + const completedList = todoItems.map((t) => `~~${t.text}~~`).join("\n"); pi.sendMessage( { customType: "plan-complete", @@ -412,9 +374,9 @@ IMPORTANT: After completing each step, output [DONE:id] where id is the step's I ); executionMode = false; - const completedItems = [...todoItems]; // Keep for reference + const _completedItems = [...todoItems]; // Keep for reference todoItems = []; - pi.setTools(NORMAL_MODE_TOOLS); + pi.setActiveTools(NORMAL_MODE_TOOLS); updateStatus(ctx); } return; @@ -464,7 +426,7 @@ IMPORTANT: After completing each step, output [DONE:id] where id is the step's I if (choice?.startsWith("Execute")) { planModeEnabled = false; executionMode = hasTodos; - pi.setTools(NORMAL_MODE_TOOLS); + pi.setActiveTools(NORMAL_MODE_TOOLS); updateStatus(ctx); const execMessage = hasTodos @@ -511,7 +473,7 @@ IMPORTANT: After completing each step, output [DONE:id] where id is the step's I } if (planModeEnabled) { - pi.setTools(PLAN_MODE_TOOLS); + pi.setActiveTools(PLAN_MODE_TOOLS); } updateStatus(ctx); }); diff --git a/packages/coding-agent/src/core/agent-session.ts b/packages/coding-agent/src/core/agent-session.ts index 8bf3d96b..c2b30aa5 100644 --- a/packages/coding-agent/src/core/agent-session.ts +++ b/packages/coding-agent/src/core/agent-session.ts @@ -224,15 +224,6 @@ export class AgentSession { /** Internal handler for agent events - shared by subscribe and reconnect */ private _handleAgentEvent = async (event: AgentEvent): Promise => { - // Emit text_delta events to hooks for streaming text monitoring - if ( - event.type === "message_update" && - event.assistantMessageEvent.type === "text_delta" && - this._hookRunner - ) { - await this._hookRunner.emit({ type: "text_delta", text: event.assistantMessageEvent.delta }); - } - // When a user message starts, check if it's from either queue and remove it BEFORE emitting // This ensures the UI sees the updated queue state if (event.type === "message_start" && event.message.role === "user") { @@ -447,6 +438,13 @@ export class AgentSession { return this.agent.state.tools.map((t) => t.name); } + /** + * Get all configured tool names (built-in via --tools or default, plus custom tools). + */ + getAllToolNames(): string[] { + return Array.from(this._toolRegistry.keys()); + } + /** * Set active tools by name. * Only tools in the registry can be enabled. Unknown tool names are ignored. diff --git a/packages/coding-agent/src/core/hooks/index.ts b/packages/coding-agent/src/core/hooks/index.ts index 04fc6d52..a0cd381b 100644 --- a/packages/coding-agent/src/core/hooks/index.ts +++ b/packages/coding-agent/src/core/hooks/index.ts @@ -4,7 +4,8 @@ export { loadHooks, type AppendEntryHandler, type BranchHandler, - type GetToolsHandler, + type GetActiveToolsHandler, + type GetAllToolsHandler, type HookFlag, type HookShortcut, type LoadedHook, @@ -12,7 +13,7 @@ export { type NavigateTreeHandler, type NewSessionHandler, type SendMessageHandler, - type SetToolsHandler, + type SetActiveToolsHandler, } from "./loader.js"; export { execCommand, HookRunner, type HookErrorListener } from "./runner.js"; export { wrapToolsWithHooks, wrapToolWithHooks } from "./tool-wrapper.js"; diff --git a/packages/coding-agent/src/core/hooks/loader.ts b/packages/coding-agent/src/core/hooks/loader.ts index 09a629e7..50f4442f 100644 --- a/packages/coding-agent/src/core/hooks/loader.ts +++ b/packages/coding-agent/src/core/hooks/loader.ts @@ -62,14 +62,19 @@ export type SendMessageHandler = ( export type AppendEntryHandler = (customType: string, data?: T) => void; /** - * Get tools handler type for pi.getTools(). + * Get active tools handler type for pi.getActiveTools(). */ -export type GetToolsHandler = () => string[]; +export type GetActiveToolsHandler = () => string[]; /** - * Set tools handler type for pi.setTools(). + * Get all tools handler type for pi.getAllTools(). */ -export type SetToolsHandler = (toolNames: string[]) => void; +export type GetAllToolsHandler = () => string[]; + +/** + * Set active tools handler type for pi.setActiveTools(). + */ +export type SetActiveToolsHandler = (toolNames: string[]) => void; /** * CLI flag definition registered by a hook. @@ -146,10 +151,12 @@ export interface LoadedHook { setSendMessageHandler: (handler: SendMessageHandler) => void; /** Set the append entry handler for this hook's pi.appendEntry() */ setAppendEntryHandler: (handler: AppendEntryHandler) => void; - /** Set the get tools handler for this hook's pi.getTools() */ - setGetToolsHandler: (handler: GetToolsHandler) => void; - /** Set the set tools handler for this hook's pi.setTools() */ - setSetToolsHandler: (handler: SetToolsHandler) => void; + /** Set the get active tools handler for this hook's pi.getActiveTools() */ + setGetActiveToolsHandler: (handler: GetActiveToolsHandler) => void; + /** Set the get all tools handler for this hook's pi.getAllTools() */ + setGetAllToolsHandler: (handler: GetAllToolsHandler) => void; + /** Set the set active tools handler for this hook's pi.setActiveTools() */ + setSetActiveToolsHandler: (handler: SetActiveToolsHandler) => void; /** Set a flag value (called after CLI parsing) */ setFlagValue: (name: string, value: boolean | string) => void; } @@ -215,8 +222,9 @@ function createHookAPI( shortcuts: Map; setSendMessageHandler: (handler: SendMessageHandler) => void; setAppendEntryHandler: (handler: AppendEntryHandler) => void; - setGetToolsHandler: (handler: GetToolsHandler) => void; - setSetToolsHandler: (handler: SetToolsHandler) => void; + setGetActiveToolsHandler: (handler: GetActiveToolsHandler) => void; + setGetAllToolsHandler: (handler: GetAllToolsHandler) => void; + setSetActiveToolsHandler: (handler: SetActiveToolsHandler) => void; setFlagValue: (name: string, value: boolean | string) => void; } { let sendMessageHandler: SendMessageHandler = () => { @@ -225,8 +233,9 @@ function createHookAPI( let appendEntryHandler: AppendEntryHandler = () => { // Default no-op until mode sets the handler }; - let getToolsHandler: GetToolsHandler = () => []; - let setToolsHandler: SetToolsHandler = () => { + let getActiveToolsHandler: GetActiveToolsHandler = () => []; + let getAllToolsHandler: GetAllToolsHandler = () => []; + let setActiveToolsHandler: SetActiveToolsHandler = () => { // Default no-op until mode sets the handler }; const messageRenderers = new Map(); @@ -261,11 +270,14 @@ function createHookAPI( exec(command: string, args: string[], options?: ExecOptions) { return execCommand(command, args, options?.cwd ?? cwd, options); }, - getTools(): string[] { - return getToolsHandler(); + getActiveTools(): string[] { + return getActiveToolsHandler(); }, - setTools(toolNames: string[]): void { - setToolsHandler(toolNames); + getAllTools(): string[] { + return getAllToolsHandler(); + }, + setActiveTools(toolNames: string[]): void { + setActiveToolsHandler(toolNames); }, registerFlag( name: string, @@ -304,11 +316,14 @@ function createHookAPI( setAppendEntryHandler: (handler: AppendEntryHandler) => { appendEntryHandler = handler; }, - setGetToolsHandler: (handler: GetToolsHandler) => { - getToolsHandler = handler; + setGetActiveToolsHandler: (handler: GetActiveToolsHandler) => { + getActiveToolsHandler = handler; }, - setSetToolsHandler: (handler: SetToolsHandler) => { - setToolsHandler = handler; + setGetAllToolsHandler: (handler: GetAllToolsHandler) => { + getAllToolsHandler = handler; + }, + setSetActiveToolsHandler: (handler: SetActiveToolsHandler) => { + setActiveToolsHandler = handler; }, setFlagValue: (name: string, value: boolean | string) => { flagValues.set(name, value); @@ -349,8 +364,9 @@ async function loadHook(hookPath: string, cwd: string): Promise<{ hook: LoadedHo shortcuts, setSendMessageHandler, setAppendEntryHandler, - setGetToolsHandler, - setSetToolsHandler, + setGetActiveToolsHandler, + setGetAllToolsHandler, + setSetActiveToolsHandler, setFlagValue, } = createHookAPI(handlers, cwd, hookPath); @@ -369,8 +385,9 @@ async function loadHook(hookPath: string, cwd: string): Promise<{ hook: LoadedHo shortcuts, setSendMessageHandler, setAppendEntryHandler, - setGetToolsHandler, - setSetToolsHandler, + setGetActiveToolsHandler, + setGetAllToolsHandler, + setSetActiveToolsHandler, setFlagValue, }, error: null, diff --git a/packages/coding-agent/src/core/hooks/runner.ts b/packages/coding-agent/src/core/hooks/runner.ts index a957115c..3024b15c 100644 --- a/packages/coding-agent/src/core/hooks/runner.ts +++ b/packages/coding-agent/src/core/hooks/runner.ts @@ -99,10 +99,12 @@ export class HookRunner { sendMessageHandler: SendMessageHandler; /** Handler for hooks to append entries */ appendEntryHandler: AppendEntryHandler; - /** Handler for getting current tools */ - getToolsHandler: () => string[]; - /** Handler for setting tools */ - setToolsHandler: (toolNames: string[]) => void; + /** Handler for getting current active tools */ + getActiveToolsHandler: () => string[]; + /** Handler for getting all configured tools */ + getAllToolsHandler: () => string[]; + /** Handler for setting active tools */ + setActiveToolsHandler: (toolNames: string[]) => void; /** Handler for creating new sessions (for HookCommandContext) */ newSessionHandler?: NewSessionHandler; /** Handler for branching sessions (for HookCommandContext) */ @@ -137,12 +139,13 @@ export class HookRunner { if (options.navigateTreeHandler) { this.navigateTreeHandler = options.navigateTreeHandler; } - // Set per-hook handlers for pi.sendMessage(), pi.appendEntry(), pi.getTools(), pi.setTools() + // Set per-hook handlers for pi.sendMessage(), pi.appendEntry(), pi.getActiveTools(), pi.getAllTools(), pi.setActiveTools() for (const hook of this.hooks) { hook.setSendMessageHandler(options.sendMessageHandler); hook.setAppendEntryHandler(options.appendEntryHandler); - hook.setGetToolsHandler(options.getToolsHandler); - hook.setSetToolsHandler(options.setToolsHandler); + hook.setGetActiveToolsHandler(options.getActiveToolsHandler); + hook.setGetAllToolsHandler(options.getAllToolsHandler); + hook.setSetActiveToolsHandler(options.setActiveToolsHandler); } this.uiContext = options.uiContext ?? noOpUIContext; this.hasUI = options.hasUI ?? false; @@ -193,14 +196,52 @@ export class HookRunner { } } + // Built-in shortcuts that hooks should not override + private static readonly RESERVED_SHORTCUTS = new Set([ + "ctrl+c", + "ctrl+d", + "ctrl+z", + "ctrl+k", + "ctrl+p", + "ctrl+l", + "ctrl+o", + "ctrl+t", + "ctrl+g", + "shift+tab", + "shift+ctrl+p", + "alt+enter", + "escape", + "enter", + ]); + /** * Get all keyboard shortcuts registered by hooks. + * When multiple hooks register the same shortcut, the last one wins. + * Conflicts with built-in shortcuts are skipped with a warning. + * Conflicts between hooks are logged as warnings. */ getShortcuts(): Map { const allShortcuts = new Map(); for (const hook of this.hooks) { for (const [key, shortcut] of hook.shortcuts) { - allShortcuts.set(key, shortcut); + const normalizedKey = key.toLowerCase(); + + // Check for built-in shortcut conflicts + if (HookRunner.RESERVED_SHORTCUTS.has(normalizedKey)) { + console.warn( + `Hook shortcut '${key}' from ${shortcut.hookPath} conflicts with built-in shortcut. Skipping.`, + ); + continue; + } + + const existing = allShortcuts.get(normalizedKey); + if (existing) { + // Log conflict between hooks - last one wins + console.warn( + `Hook shortcut conflict: '${key}' registered by both ${existing.hookPath} and ${shortcut.hookPath}. Using ${shortcut.hookPath}.`, + ); + } + allShortcuts.set(normalizedKey, shortcut); } } return allShortcuts; diff --git a/packages/coding-agent/src/core/hooks/types.ts b/packages/coding-agent/src/core/hooks/types.ts index f2a895fa..3f71d427 100644 --- a/packages/coding-agent/src/core/hooks/types.ts +++ b/packages/coding-agent/src/core/hooks/types.ts @@ -392,16 +392,6 @@ export interface AgentEndEvent { messages: AgentMessage[]; } -/** - * Event data for text_delta event. - * Fired when new text is streamed from the assistant. - */ -export interface TextDeltaEvent { - type: "text_delta"; - /** The new text chunk */ - text: string; -} - /** * Event data for turn_start event. */ @@ -545,7 +535,6 @@ export type HookEvent = | BeforeAgentStartEvent | AgentStartEvent | AgentEndEvent - | TextDeltaEvent | TurnStartEvent | TurnEndEvent | ToolCallEvent @@ -712,7 +701,6 @@ export interface HookAPI { on(event: "turn_end", handler: HookHandler): void; on(event: "tool_call", handler: HookHandler): void; on(event: "tool_result", handler: HookHandler): void; - on(event: "text_delta", handler: HookHandler): void; /** * Send a custom message to the session. Creates a CustomMessageEntry that @@ -788,23 +776,30 @@ export interface HookAPI { * Get the list of currently active tool names. * @returns Array of tool names (e.g., ["read", "bash", "edit", "write"]) */ - getTools(): string[]; + getActiveTools(): string[]; + + /** + * Get all configured tools (built-in via --tools or default, plus custom tools). + * @returns Array of all tool names + */ + getAllTools(): string[]; /** * Set the active tools by name. * Only built-in tools can be enabled/disabled. Custom tools are always active. * Changes take effect on the next agent turn. + * Note: This will invalidate prompt caching for the next request. * * @param toolNames - Array of tool names to enable (e.g., ["read", "bash", "grep", "find", "ls"]) * * @example * // Switch to read-only mode (plan mode) - * pi.setTools(["read", "bash", "grep", "find", "ls"]); + * pi.setActiveTools(["read", "bash", "grep", "find", "ls"]); * * // Restore full access - * pi.setTools(["read", "bash", "edit", "write"]); + * pi.setActiveTools(["read", "bash", "edit", "write"]); */ - setTools(toolNames: string[]): void; + setActiveTools(toolNames: string[]): void; /** * Register a CLI flag for this hook. diff --git a/packages/coding-agent/src/core/sdk.ts b/packages/coding-agent/src/core/sdk.ts index 8c621d08..5d6ee273 100644 --- a/packages/coding-agent/src/core/sdk.ts +++ b/packages/coding-agent/src/core/sdk.ts @@ -355,8 +355,9 @@ function createLoadedHooksFromDefinitions(definitions: Array<{ path?: string; fa options?: { triggerTurn?: boolean; deliverAs?: "steer" | "followUp" }, ) => void = () => {}; let appendEntryHandler: (customType: string, data?: any) => void = () => {}; - let getToolsHandler: () => string[] = () => []; - let setToolsHandler: (toolNames: string[]) => void = () => {}; + let getActiveToolsHandler: () => string[] = () => []; + let getAllToolsHandler: () => string[] = () => []; + let setActiveToolsHandler: (toolNames: string[]) => void = () => {}; let newSessionHandler: (options?: any) => Promise<{ cancelled: boolean }> = async () => ({ cancelled: false }); let branchHandler: (entryId: string) => Promise<{ cancelled: boolean }> = async () => ({ cancelled: false }); let navigateTreeHandler: (targetId: string, options?: any) => Promise<{ cancelled: boolean }> = async () => ({ @@ -394,8 +395,9 @@ function createLoadedHooksFromDefinitions(definitions: Array<{ path?: string; fa newSession: (options?: any) => newSessionHandler(options), branch: (entryId: string) => branchHandler(entryId), navigateTree: (targetId: string, options?: any) => navigateTreeHandler(targetId, options), - getTools: () => getToolsHandler(), - setTools: (toolNames: string[]) => setToolsHandler(toolNames), + getActiveTools: () => getActiveToolsHandler(), + getAllTools: () => getAllToolsHandler(), + setActiveTools: (toolNames: string[]) => setActiveToolsHandler(toolNames), }; def.factory(api as any); @@ -426,11 +428,14 @@ function createLoadedHooksFromDefinitions(definitions: Array<{ path?: string; fa setNavigateTreeHandler: (handler: (targetId: string, options?: any) => Promise<{ cancelled: boolean }>) => { navigateTreeHandler = handler; }, - setGetToolsHandler: (handler: () => string[]) => { - getToolsHandler = handler; + setGetActiveToolsHandler: (handler: () => string[]) => { + getActiveToolsHandler = handler; }, - setSetToolsHandler: (handler: (toolNames: string[]) => void) => { - setToolsHandler = handler; + setGetAllToolsHandler: (handler: () => string[]) => { + getAllToolsHandler = handler; + }, + setSetActiveToolsHandler: (handler: (toolNames: string[]) => void) => { + setActiveToolsHandler = handler; }, setFlagValue: (name: string, value: boolean | string) => { flagValues.set(name, value); diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index 087368aa..fa737fc2 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -451,8 +451,9 @@ export class InteractiveMode { appendEntryHandler: (customType, data) => { this.sessionManager.appendCustomEntry(customType, data); }, - getToolsHandler: () => this.session.getActiveToolNames(), - setToolsHandler: (toolNames) => this.session.setActiveToolsByName(toolNames), + getActiveToolsHandler: () => this.session.getActiveToolNames(), + getAllToolsHandler: () => this.session.getAllToolNames(), + setActiveToolsHandler: (toolNames) => this.session.setActiveToolsByName(toolNames), newSessionHandler: async (options) => { // Stop any loading animation if (this.loadingAnimation) { diff --git a/packages/coding-agent/src/modes/print-mode.ts b/packages/coding-agent/src/modes/print-mode.ts index 4da05491..5beb31cd 100644 --- a/packages/coding-agent/src/modes/print-mode.ts +++ b/packages/coding-agent/src/modes/print-mode.ts @@ -40,8 +40,9 @@ export async function runPrintMode( appendEntryHandler: (customType, data) => { session.sessionManager.appendCustomEntry(customType, data); }, - getToolsHandler: () => session.getActiveToolNames(), - setToolsHandler: (toolNames) => session.setActiveToolsByName(toolNames), + getActiveToolsHandler: () => session.getActiveToolNames(), + getAllToolsHandler: () => session.getAllToolNames(), + setActiveToolsHandler: (toolNames: string[]) => session.setActiveToolsByName(toolNames), }); hookRunner.onError((err) => { console.error(`Hook error (${err.hookPath}): ${err.error}`); diff --git a/packages/coding-agent/src/modes/rpc/rpc-mode.ts b/packages/coding-agent/src/modes/rpc/rpc-mode.ts index 10a31d0d..a0505a62 100644 --- a/packages/coding-agent/src/modes/rpc/rpc-mode.ts +++ b/packages/coding-agent/src/modes/rpc/rpc-mode.ts @@ -200,8 +200,9 @@ export async function runRpcMode(session: AgentSession): Promise { appendEntryHandler: (customType, data) => { session.sessionManager.appendCustomEntry(customType, data); }, - getToolsHandler: () => session.getActiveToolNames(), - setToolsHandler: (toolNames) => session.setActiveToolsByName(toolNames), + getActiveToolsHandler: () => session.getActiveToolNames(), + getAllToolsHandler: () => session.getAllToolNames(), + setActiveToolsHandler: (toolNames: string[]) => session.setActiveToolsByName(toolNames), uiContext: createHookUIContext(), hasUI: false, }); diff --git a/packages/coding-agent/test/compaction-hooks.test.ts b/packages/coding-agent/test/compaction-hooks.test.ts index 62bd7315..99f2c1ab 100644 --- a/packages/coding-agent/test/compaction-hooks.test.ts +++ b/packages/coding-agent/test/compaction-hooks.test.ts @@ -83,8 +83,9 @@ describe.skipIf(!API_KEY)("Compaction hooks", () => { shortcuts: new Map(), setSendMessageHandler: () => {}, setAppendEntryHandler: () => {}, - setGetToolsHandler: () => {}, - setSetToolsHandler: () => {}, + setGetActiveToolsHandler: () => {}, + setGetAllToolsHandler: () => {}, + setSetActiveToolsHandler: () => {}, setFlagValue: () => {}, }; } @@ -110,8 +111,9 @@ describe.skipIf(!API_KEY)("Compaction hooks", () => { getModel: () => session.model, sendMessageHandler: async () => {}, appendEntryHandler: async () => {}, - getToolsHandler: () => [], - setToolsHandler: () => {}, + getActiveToolsHandler: () => [], + getAllToolsHandler: () => [], + setActiveToolsHandler: () => {}, uiContext: { select: async () => undefined, confirm: async () => false, @@ -279,8 +281,9 @@ describe.skipIf(!API_KEY)("Compaction hooks", () => { shortcuts: new Map(), setSendMessageHandler: () => {}, setAppendEntryHandler: () => {}, - setGetToolsHandler: () => {}, - setSetToolsHandler: () => {}, + setGetActiveToolsHandler: () => {}, + setGetAllToolsHandler: () => {}, + setSetActiveToolsHandler: () => {}, setFlagValue: () => {}, }; @@ -332,8 +335,9 @@ describe.skipIf(!API_KEY)("Compaction hooks", () => { shortcuts: new Map(), setSendMessageHandler: () => {}, setAppendEntryHandler: () => {}, - setGetToolsHandler: () => {}, - setSetToolsHandler: () => {}, + setGetActiveToolsHandler: () => {}, + setGetAllToolsHandler: () => {}, + setSetActiveToolsHandler: () => {}, setFlagValue: () => {}, }; @@ -367,8 +371,9 @@ describe.skipIf(!API_KEY)("Compaction hooks", () => { shortcuts: new Map(), setSendMessageHandler: () => {}, setAppendEntryHandler: () => {}, - setGetToolsHandler: () => {}, - setSetToolsHandler: () => {}, + setGetActiveToolsHandler: () => {}, + setGetAllToolsHandler: () => {}, + setSetActiveToolsHandler: () => {}, setFlagValue: () => {}, }; From 78d4d44ab74a3eaf35a75c7c224f77726e55e0b7 Mon Sep 17 00:00:00 2001 From: Helmut Januschka Date: Sat, 3 Jan 2026 21:33:31 +0100 Subject: [PATCH 15/30] refactor(plan-mode): use smart keyword matching instead of IDs - Remove ugly [DONE:id] tags - users no longer see IDs - Track progress via keyword matching on tool results - Extract significant keywords from todo text - Match tool name + input against todo keywords - Sequential preference: first uncompleted item gets bonus score - Much cleaner UX - progress tracked silently in background --- .../coding-agent/examples/hooks/plan-mode.ts | 185 +++++++++++++----- 1 file changed, 132 insertions(+), 53 deletions(-) diff --git a/packages/coding-agent/examples/hooks/plan-mode.ts b/packages/coding-agent/examples/hooks/plan-mode.ts index 8bd4fd08..b809139f 100644 --- a/packages/coding-agent/examples/hooks/plan-mode.ts +++ b/packages/coding-agent/examples/hooks/plan-mode.ts @@ -11,7 +11,7 @@ * - After each agent response, prompts to execute the plan or continue planning * - Shows "plan" indicator in footer when active * - Extracts todo list from plan and tracks progress during execution - * - Agent marks steps complete by outputting [DONE:id] tags + * - Uses smart matching to track progress (no ugly IDs shown to user) * * Usage: * 1. Copy this file to ~/.pi/agent/hooks/ or your project's .pi/hooks/ @@ -130,20 +130,64 @@ function isSafeCommand(command: string): boolean { return true; } -// Todo item with unique ID +// Todo item interface TodoItem { - id: string; text: string; completed: boolean; -} - -// Generate a short unique ID -function generateId(): string { - return Math.random().toString(36).substring(2, 8); + // Keywords extracted for matching + keywords: string[]; } /** - * Extract todo items from assistant message and assign IDs. + * Extract significant keywords from text for matching. + */ +function extractKeywords(text: string): string[] { + // Remove common words and extract significant terms + const stopWords = new Set([ + "the", + "a", + "an", + "to", + "for", + "of", + "in", + "on", + "at", + "by", + "with", + "using", + "and", + "or", + "use", + "run", + "execute", + "create", + "make", + "do", + "then", + "next", + "step", + "first", + "second", + "third", + "finally", + "it", + "its", + "this", + "that", + "from", + "into", + ]); + + return text + .toLowerCase() + .replace(/[^a-z0-9/._-]/g, " ") + .split(/\s+/) + .filter((w) => w.length > 2 && !stopWords.has(w)); +} + +/** + * Extract todo items from assistant message. */ function extractTodoItems(message: string): TodoItem[] { const items: TodoItem[] = []; @@ -154,7 +198,11 @@ function extractTodoItems(message: string): TodoItem[] { let text = match[2].trim(); text = text.replace(/\*{1,2}$/, "").trim(); if (text.length > 5 && !text.startsWith("`") && !text.startsWith("/") && !text.startsWith("-")) { - items.push({ id: generateId(), text, completed: false }); + items.push({ + text, + completed: false, + keywords: extractKeywords(text), + }); } } @@ -165,7 +213,11 @@ function extractTodoItems(message: string): TodoItem[] { let text = match[1].trim(); text = text.replace(/\*{1,2}$/, "").trim(); if (text.length > 10 && !text.startsWith("`")) { - items.push({ id: generateId(), text, completed: false }); + items.push({ + text, + completed: false, + keywords: extractKeywords(text), + }); } } } @@ -174,15 +226,54 @@ function extractTodoItems(message: string): TodoItem[] { } /** - * Find [DONE:id] tags in text and return the IDs. + * Calculate similarity between tool action and todo item. + * Returns a score from 0 to 1. */ -function findDoneTags(text: string): string[] { - const pattern = /\[DONE:([a-z0-9]+)\]/gi; - const ids: string[] = []; - for (const match of text.matchAll(pattern)) { - ids.push(match[1].toLowerCase()); +function matchScore(todoKeywords: string[], actionText: string): number { + if (todoKeywords.length === 0) return 0; + + const actionLower = actionText.toLowerCase(); + let matches = 0; + + for (const keyword of todoKeywords) { + if (actionLower.includes(keyword)) { + matches++; + } } - return ids; + + return matches / todoKeywords.length; +} + +/** + * Find the best matching uncompleted todo for a tool action. + * Uses keyword matching with a preference for sequential order. + */ +function findBestMatch(todos: TodoItem[], toolName: string, input: Record): number { + // Build action text from tool name and input + let actionText = toolName; + if (input.path) actionText += ` ${input.path}`; + if (input.command) actionText += ` ${input.command}`; + if (input.content) actionText += ` ${String(input.content).slice(0, 100)}`; + + let bestIdx = -1; + let bestScore = 0.3; // Minimum threshold + + for (let i = 0; i < todos.length; i++) { + if (todos[i].completed) continue; + + const score = matchScore(todos[i].keywords, actionText); + + // Bonus for being the first uncompleted item (sequential preference) + const isFirstUncompleted = !todos.slice(0, i).some((t) => !t.completed); + const adjustedScore = isFirstUncompleted ? score + 0.1 : score; + + if (adjustedScore > bestScore) { + bestScore = adjustedScore; + bestIdx = i; + } + } + + return bestIdx; } export default function planModeHook(pi: HookAPI) { @@ -259,9 +350,9 @@ export default function planModeHook(pi: HookAPI) { } const todoList = todoItems - .map((item) => { + .map((item, i) => { const checkbox = item.completed ? "✓" : "○"; - return `[${item.id}] ${checkbox} ${item.text}`; + return `${i + 1}. ${checkbox} ${item.text}`; }) .join("\n"); @@ -291,6 +382,19 @@ export default function planModeHook(pi: HookAPI) { } }); + // Track progress via tool results + pi.on("tool_result", async (event, ctx) => { + if (!executionMode || todoItems.length === 0) return; + if (event.isError) return; + + // Find best matching todo item + const matchIdx = findBestMatch(todoItems, event.toolName, event.input); + if (matchIdx >= 0 && !todoItems[matchIdx].completed) { + todoItems[matchIdx].completed = true; + updateStatus(ctx); + } + }); + // Inject plan mode context pi.on("before_agent_start", async () => { if (!planModeEnabled && !executionMode) return; @@ -320,46 +424,22 @@ Do NOT attempt to make changes - just describe what you would do.`, } if (executionMode && todoItems.length > 0) { - const todoList = todoItems.map((t) => `- [${t.id}] ${t.completed ? "☑" : "☐"} ${t.text}`).join("\n"); + const completed = todoItems.filter((t) => t.completed).length; return { message: { customType: "plan-execution-context", - content: `[EXECUTING PLAN] -You have a plan with ${todoItems.length} steps. After completing each step, output [DONE:id] to mark it complete. - -Current plan status: -${todoList} - -IMPORTANT: After completing each step, output [DONE:id] where id is the step's ID (e.g., [DONE:${todoItems.find((t) => !t.completed)?.id || todoItems[0].id}]).`, + content: `[EXECUTING PLAN - ${completed}/${todoItems.length} complete] +Continue executing the plan step by step.`, display: false, }, }; } }); - // After agent finishes in plan mode + // After agent finishes pi.on("agent_end", async (event, ctx) => { - // Check for done tags in the final message + // Check if all complete if (executionMode && todoItems.length > 0) { - const messages = event.messages; - const lastAssistant = [...messages].reverse().find((m) => m.role === "assistant"); - if (lastAssistant && Array.isArray(lastAssistant.content)) { - const textContent = lastAssistant.content - .filter((block): block is { type: "text"; text: string } => block.type === "text") - .map((block) => block.text) - .join("\n"); - - const doneIds = findDoneTags(textContent); - for (const id of doneIds) { - const item = todoItems.find((t) => t.id === id); - if (item && !item.completed) { - item.completed = true; - } - } - updateStatus(ctx); - } - - // Check if all complete const allComplete = todoItems.every((t) => t.completed); if (allComplete) { // Show final completed list in chat @@ -374,7 +454,6 @@ IMPORTANT: After completing each step, output [DONE:id] where id is the step's I ); executionMode = false; - const _completedItems = [...todoItems]; // Keep for reference todoItems = []; pi.setActiveTools(NORMAL_MODE_TOOLS); updateStatus(ctx); @@ -404,9 +483,9 @@ IMPORTANT: After completing each step, output [DONE:id] where id is the step's I const hasTodos = todoItems.length > 0; - // Show todo list in chat with IDs + // Show todo list in chat (no IDs, just numbered) if (hasTodos) { - const todoListText = todoItems.map((t) => `☐ [${t.id}] ${t.text}`).join("\n"); + const todoListText = todoItems.map((t, i) => `${i + 1}. ☐ ${t.text}`).join("\n"); pi.sendMessage( { customType: "plan-todo-list", @@ -430,7 +509,7 @@ IMPORTANT: After completing each step, output [DONE:id] where id is the step's I updateStatus(ctx); const execMessage = hasTodos - ? `Execute the plan. After completing each step, output [DONE:id] where id is the step's ID. Start with step [${todoItems[0].id}]: ${todoItems[0].text}` + ? `Execute the plan step by step. Start with: ${todoItems[0].text}` : "Execute the plan you just created. Proceed step by step."; pi.sendMessage( From 705e92e43f16f220e0df0f70a7bccc77d4c3c674 Mon Sep 17 00:00:00 2001 From: Helmut Januschka Date: Sat, 3 Jan 2026 21:43:35 +0100 Subject: [PATCH 16/30] fix(widgets): add max line limit and document multi-hook behavior - Limit total widget lines to 10 to prevent viewport overflow/flicker - Show '... (widget truncated)' when limit exceeded - Document that multiple hooks stack widgets vertically - Add caution about keeping widgets small --- packages/coding-agent/docs/hooks.md | 3 ++- .../src/modes/interactive/interactive-mode.ts | 13 ++++++++++++- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/packages/coding-agent/docs/hooks.md b/packages/coding-agent/docs/hooks.md index bc302b3e..9343fdeb 100644 --- a/packages/coding-agent/docs/hooks.md +++ b/packages/coding-agent/docs/hooks.md @@ -445,9 +445,10 @@ 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 +- Multiple hooks can set widgets using unique keys (all widgets are displayed, stacked vertically) - Use for progress lists, todo tracking, or any multi-line status - 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. **Styling with theme colors:** diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index fa737fc2..d8d26964 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -637,6 +637,9 @@ export class InteractiveMode { this.renderWidgets(); } + // Maximum total widget lines to prevent viewport overflow + private static readonly MAX_WIDGET_LINES = 10; + /** * Render all hook widgets to the widget container. */ @@ -649,10 +652,18 @@ export class InteractiveMode { return; } - // Render each widget + // Render each widget, respecting max lines to prevent viewport overflow + let totalLines = 0; for (const [_key, lines] of this.hookWidgets) { for (const line of lines) { + if (totalLines >= InteractiveMode.MAX_WIDGET_LINES) { + // Add truncation indicator and stop + 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++; } } From 14d55d8fd3f2d9f70e9a85041543449f1020238b Mon Sep 17 00:00:00 2001 From: Helmut Januschka Date: Sat, 3 Jan 2026 21:47:54 +0100 Subject: [PATCH 17/30] feat(hooks): add setWidgetComponent for custom TUI components - New ctx.ui.setWidgetComponent(key, factory) method - Allows custom Component to render as widget without taking focus - Unlike custom(), widget components render inline above editor - Components are disposed when cleared or replaced - Falls back to no-op in RPC/print modes --- packages/coding-agent/CHANGELOG.md | 1 + packages/coding-agent/docs/hooks.md | 20 ++++++++- .../src/core/custom-tools/loader.ts | 1 + .../coding-agent/src/core/hooks/runner.ts | 1 + packages/coding-agent/src/core/hooks/types.ts | 27 +++++++++++ .../src/modes/interactive/interactive-mode.ts | 45 +++++++++++++++++-- .../coding-agent/src/modes/rpc/rpc-mode.ts | 4 ++ .../test/compaction-hooks.test.ts | 1 + 8 files changed, 94 insertions(+), 6 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 2c6b81b7..2bbe92b5 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -40,6 +40,7 @@ - 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: `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 9343fdeb..ac8d70c1 100644 --- a/packages/coding-agent/docs/hooks.md +++ b/packages/coding-agent/docs/hooks.md @@ -446,9 +446,25 @@ 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 for progress lists, todo tracking, or any multi-line status +- Use `setWidget()` for simple styled text, `setWidgetComponent()` for custom components - 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. +- **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: + +```typescript +ctx.ui.setWidgetComponent("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); +``` + +Unlike `ctx.ui.custom()`, widget components do NOT take keyboard focus - they render inline above the editor. **Styling with theme colors:** diff --git a/packages/coding-agent/src/core/custom-tools/loader.ts b/packages/coding-agent/src/core/custom-tools/loader.ts index 1f72c41e..517f3f42 100644 --- a/packages/coding-agent/src/core/custom-tools/loader.ts +++ b/packages/coding-agent/src/core/custom-tools/loader.ts @@ -93,6 +93,7 @@ function createNoOpUIContext(): HookUIContext { notify: () => {}, setStatus: () => {}, setWidget: () => {}, + setWidgetComponent: () => {}, 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 3024b15c..d6155184 100644 --- a/packages/coding-agent/src/core/hooks/runner.ts +++ b/packages/coding-agent/src/core/hooks/runner.ts @@ -50,6 +50,7 @@ const noOpUIContext: HookUIContext = { notify: () => {}, setStatus: () => {}, setWidget: () => {}, + setWidgetComponent: () => {}, custom: async () => undefined as never, setEditorText: () => {}, getEditorText: () => "", diff --git a/packages/coding-agent/src/core/hooks/types.ts b/packages/coding-agent/src/core/hooks/types.ts index 3f71d427..8ddec584 100644 --- a/packages/coding-agent/src/core/hooks/types.ts +++ b/packages/coding-agent/src/core/hooks/types.ts @@ -79,6 +79,8 @@ 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(). + * * @param key - Unique key to identify this widget (e.g., hook name) * @param lines - Array of lines to display, or undefined to clear * @@ -96,6 +98,31 @@ export interface HookUIContext { */ setWidget(key: string, lines: string[] | undefined): void; + /** + * Set a custom component as a widget (above the editor, below "Working..." indicator). + * Unlike custom(), this does NOT take keyboard focus - the editor remains focused. + * Pass undefined to clear the widget. + * + * The component should implement render(width) and optionally dispose(). + * 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 + * + * @example + * // Show a custom progress component + * ctx.ui.setWidgetComponent("my-progress", (tui, theme) => { + * return new MyProgressComponent(tui, theme); + * }); + * + * // Clear the widget + * ctx.ui.setWidgetComponent("my-progress", undefined); + */ + setWidgetComponent( + key: string, + factory: ((tui: TUI, theme: Theme) => Component & { dispose?(): void }) | undefined, + ): void; + /** * Show a custom component with keyboard focus. * The factory receives TUI, theme, and a done() callback to close the component. diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index d8d26964..4a7dcef1 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -141,8 +141,9 @@ export class InteractiveMode { private hookInput: HookInputComponent | undefined = undefined; private hookEditor: HookEditorComponent | undefined = undefined; - // Hook widgets (multi-line status displays) + // Hook widgets (multi-line status displays or custom components) private hookWidgets = new Map(); + private hookWidgetComponents = new Map(); private widgetContainer!: Container; // Custom tools for custom rendering @@ -632,11 +633,39 @@ export class InteractiveMode { 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( + key: string, + factory: ((tui: TUI, thm: Theme) => Component & { dispose?(): void }) | undefined, + ): void { + // Dispose existing component + const existing = this.hookWidgetComponents.get(key); + if (existing?.dispose) existing.dispose(); + + if (factory === undefined) { + this.hookWidgetComponents.delete(key); + } else { + // Clear any string widget with same key + this.hookWidgets.delete(key); + + const component = factory(this.ui, theme); + this.hookWidgetComponents.set(key, component); + } + this.renderWidgets(); + } + // Maximum total widget lines to prevent viewport overflow private static readonly MAX_WIDGET_LINES = 10; @@ -647,17 +676,19 @@ export class InteractiveMode { if (!this.widgetContainer) return; this.widgetContainer.clear(); - if (this.hookWidgets.size === 0) { + const hasStringWidgets = this.hookWidgets.size > 0; + const hasComponentWidgets = this.hookWidgetComponents.size > 0; + + if (!hasStringWidgets && !hasComponentWidgets) { this.ui.requestRender(); return; } - // Render each widget, respecting max lines to prevent viewport overflow + // 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) { - // Add truncation indicator and stop this.widgetContainer.addChild(new Text(theme.fg("muted", "... (widget truncated)"), 1, 0)); this.ui.requestRender(); return; @@ -667,6 +698,11 @@ export class InteractiveMode { } } + // Render component widgets + for (const [_key, component] of this.hookWidgetComponents) { + this.widgetContainer.addChild(component); + } + this.ui.requestRender(); } @@ -681,6 +717,7 @@ export class InteractiveMode { 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), 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 a0505a62..c35e6222 100644 --- a/packages/coding-agent/src/modes/rpc/rpc-mode.ts +++ b/packages/coding-agent/src/modes/rpc/rpc-mode.ts @@ -142,6 +142,10 @@ export async function runRpcMode(session: AgentSession): Promise { } as RpcHookUIRequest); }, + setWidgetComponent(): void { + // Custom components not supported in RPC mode - host would need to implement + }, + async custom() { // Custom UI not supported in RPC mode return undefined as never; diff --git a/packages/coding-agent/test/compaction-hooks.test.ts b/packages/coding-agent/test/compaction-hooks.test.ts index 99f2c1ab..7c2a21e7 100644 --- a/packages/coding-agent/test/compaction-hooks.test.ts +++ b/packages/coding-agent/test/compaction-hooks.test.ts @@ -121,6 +121,7 @@ describe.skipIf(!API_KEY)("Compaction hooks", () => { notify: () => {}, setStatus: () => {}, setWidget: () => {}, + setWidgetComponent: () => {}, custom: async () => undefined as never, setEditorText: () => {}, getEditorText: () => "", From b1f574f7f75ca646ffcca8e9095937d69713cf23 Mon Sep 17 00:00:00 2001 From: Helmut Januschka Date: Sat, 3 Jan 2026 21:50:19 +0100 Subject: [PATCH 18/30] fix(plan-mode): make execution mode clearer to agent - Add explicit [PLAN MODE DISABLED - EXECUTE NOW] message - Emphasize FULL access to all tools in execution context - List remaining steps in execution context - Prevents agent from thinking it's still restricted --- packages/coding-agent/examples/hooks/plan-mode.ts | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/packages/coding-agent/examples/hooks/plan-mode.ts b/packages/coding-agent/examples/hooks/plan-mode.ts index b809139f..87ee464f 100644 --- a/packages/coding-agent/examples/hooks/plan-mode.ts +++ b/packages/coding-agent/examples/hooks/plan-mode.ts @@ -425,11 +425,13 @@ Do NOT attempt to make changes - just describe what you would do.`, if (executionMode && todoItems.length > 0) { const completed = todoItems.filter((t) => t.completed).length; + const remaining = todoItems.filter((t) => !t.completed).map((t) => t.text); return { message: { customType: "plan-execution-context", content: `[EXECUTING PLAN - ${completed}/${todoItems.length} complete] -Continue executing the plan step by step.`, +Plan mode is OFF. You have FULL access to: read, write, edit, bash. +${remaining.length > 0 ? `Remaining steps:\n${remaining.map((t, i) => `${i + 1}. ${t}`).join("\n")}` : "All steps complete!"}`, display: false, }, }; @@ -509,8 +511,12 @@ Continue executing the plan step by step.`, updateStatus(ctx); const execMessage = hasTodos - ? `Execute the plan step by step. Start with: ${todoItems[0].text}` - : "Execute the plan you just created. Proceed step by step."; + ? `[PLAN MODE DISABLED - EXECUTE NOW] +You now have FULL access to all tools: read, write, edit, bash. +Execute the plan step by step. Start with: ${todoItems[0].text}` + : `[PLAN MODE DISABLED - EXECUTE NOW] +You now have FULL access to all tools: read, write, edit, bash. +Execute the plan you just created. Proceed step by step.`; pi.sendMessage( { From fc783a5980f17108f29f90123f348f5c84233d89 Mon Sep 17 00:00:00 2001 From: Helmut Januschka Date: Sat, 3 Jan 2026 21:57:01 +0100 Subject: [PATCH 19/30] fix(plan-mode): use context event to filter stale plan mode messages - Filter out old [PLAN MODE ACTIVE] and [EXECUTING PLAN] messages - Fresh context injected via before_agent_start with current state - Agent now correctly sees tools are enabled when executing - Reverted to ID-based tracking with [DONE:id] tags - Simplified execution message (no need to override old context) --- .../coding-agent/examples/hooks/plan-mode.ts | 208 +++++++----------- 1 file changed, 75 insertions(+), 133 deletions(-) diff --git a/packages/coding-agent/examples/hooks/plan-mode.ts b/packages/coding-agent/examples/hooks/plan-mode.ts index 87ee464f..d8901c11 100644 --- a/packages/coding-agent/examples/hooks/plan-mode.ts +++ b/packages/coding-agent/examples/hooks/plan-mode.ts @@ -11,7 +11,7 @@ * - After each agent response, prompts to execute the plan or continue planning * - Shows "plan" indicator in footer when active * - Extracts todo list from plan and tracks progress during execution - * - Uses smart matching to track progress (no ugly IDs shown to user) + * - Uses ID-based tracking: agent outputs [DONE:id] to mark steps complete * * Usage: * 1. Copy this file to ~/.pi/agent/hooks/ or your project's .pi/hooks/ @@ -130,64 +130,20 @@ function isSafeCommand(command: string): boolean { return true; } -// Todo item +// Todo item with unique ID interface TodoItem { + id: string; text: string; completed: boolean; - // Keywords extracted for matching - keywords: string[]; +} + +// Generate a short unique ID +function generateId(): string { + return Math.random().toString(36).substring(2, 8); } /** - * Extract significant keywords from text for matching. - */ -function extractKeywords(text: string): string[] { - // Remove common words and extract significant terms - const stopWords = new Set([ - "the", - "a", - "an", - "to", - "for", - "of", - "in", - "on", - "at", - "by", - "with", - "using", - "and", - "or", - "use", - "run", - "execute", - "create", - "make", - "do", - "then", - "next", - "step", - "first", - "second", - "third", - "finally", - "it", - "its", - "this", - "that", - "from", - "into", - ]); - - return text - .toLowerCase() - .replace(/[^a-z0-9/._-]/g, " ") - .split(/\s+/) - .filter((w) => w.length > 2 && !stopWords.has(w)); -} - -/** - * Extract todo items from assistant message. + * Extract todo items from assistant message and assign IDs. */ function extractTodoItems(message: string): TodoItem[] { const items: TodoItem[] = []; @@ -198,11 +154,7 @@ function extractTodoItems(message: string): TodoItem[] { let text = match[2].trim(); text = text.replace(/\*{1,2}$/, "").trim(); if (text.length > 5 && !text.startsWith("`") && !text.startsWith("/") && !text.startsWith("-")) { - items.push({ - text, - completed: false, - keywords: extractKeywords(text), - }); + items.push({ id: generateId(), text, completed: false }); } } @@ -213,11 +165,7 @@ function extractTodoItems(message: string): TodoItem[] { let text = match[1].trim(); text = text.replace(/\*{1,2}$/, "").trim(); if (text.length > 10 && !text.startsWith("`")) { - items.push({ - text, - completed: false, - keywords: extractKeywords(text), - }); + items.push({ id: generateId(), text, completed: false }); } } } @@ -226,54 +174,15 @@ function extractTodoItems(message: string): TodoItem[] { } /** - * Calculate similarity between tool action and todo item. - * Returns a score from 0 to 1. + * Find [DONE:id] tags in text and return the IDs. */ -function matchScore(todoKeywords: string[], actionText: string): number { - if (todoKeywords.length === 0) return 0; - - const actionLower = actionText.toLowerCase(); - let matches = 0; - - for (const keyword of todoKeywords) { - if (actionLower.includes(keyword)) { - matches++; - } +function findDoneTags(text: string): string[] { + const pattern = /\[DONE:([a-z0-9]+)\]/gi; + const ids: string[] = []; + for (const match of text.matchAll(pattern)) { + ids.push(match[1].toLowerCase()); } - - return matches / todoKeywords.length; -} - -/** - * Find the best matching uncompleted todo for a tool action. - * Uses keyword matching with a preference for sequential order. - */ -function findBestMatch(todos: TodoItem[], toolName: string, input: Record): number { - // Build action text from tool name and input - let actionText = toolName; - if (input.path) actionText += ` ${input.path}`; - if (input.command) actionText += ` ${input.command}`; - if (input.content) actionText += ` ${String(input.content).slice(0, 100)}`; - - let bestIdx = -1; - let bestScore = 0.3; // Minimum threshold - - for (let i = 0; i < todos.length; i++) { - if (todos[i].completed) continue; - - const score = matchScore(todos[i].keywords, actionText); - - // Bonus for being the first uncompleted item (sequential preference) - const isFirstUncompleted = !todos.slice(0, i).some((t) => !t.completed); - const adjustedScore = isFirstUncompleted ? score + 0.1 : score; - - if (adjustedScore > bestScore) { - bestScore = adjustedScore; - bestIdx = i; - } - } - - return bestIdx; + return ids; } export default function planModeHook(pi: HookAPI) { @@ -299,7 +208,7 @@ export default function planModeHook(pi: HookAPI) { ctx.ui.setStatus("plan-mode", undefined); } - // Show widget during execution + // Show widget during execution (no IDs shown to user) if (executionMode && todoItems.length > 0) { const lines: string[] = []; for (const item of todoItems) { @@ -382,17 +291,27 @@ export default function planModeHook(pi: HookAPI) { } }); - // Track progress via tool results - pi.on("tool_result", async (event, ctx) => { - if (!executionMode || todoItems.length === 0) return; - if (event.isError) return; - - // Find best matching todo item - const matchIdx = findBestMatch(todoItems, event.toolName, event.input); - if (matchIdx >= 0 && !todoItems[matchIdx].completed) { - todoItems[matchIdx].completed = true; - updateStatus(ctx); - } + // Filter out stale plan mode context messages from LLM context + // This ensures the agent only sees the CURRENT state (plan mode on/off) + (pi as any).on("context", async (event: { messages: Array<{ role: string; content: unknown }> }) => { + // Remove any previous plan-mode-context or plan-execution-context messages + // They'll be re-injected with current state via before_agent_start + const filtered = event.messages.filter((m) => { + if (m.role === "user" && Array.isArray(m.content)) { + // Check for our custom message types in user messages + const hasOldContext = (m.content as Array<{ type: string; text?: string }>).some( + (c) => + c.type === "text" && + c.text && + (c.text.includes("[PLAN MODE ACTIVE]") || + c.text.includes("[PLAN MODE DISABLED") || + c.text.includes("[EXECUTING PLAN]")), + ); + if (hasOldContext) return false; + } + return true; + }); + return { messages: filtered }; }); // Inject plan mode context @@ -424,14 +343,19 @@ Do NOT attempt to make changes - just describe what you would do.`, } if (executionMode && todoItems.length > 0) { - const completed = todoItems.filter((t) => t.completed).length; - const remaining = todoItems.filter((t) => !t.completed).map((t) => t.text); + const remaining = todoItems.filter((t) => !t.completed); + const todoList = remaining.map((t) => `- [${t.id}] ${t.text}`).join("\n"); return { message: { customType: "plan-execution-context", - content: `[EXECUTING PLAN - ${completed}/${todoItems.length} complete] + content: `[EXECUTING PLAN] Plan mode is OFF. You have FULL access to: read, write, edit, bash. -${remaining.length > 0 ? `Remaining steps:\n${remaining.map((t, i) => `${i + 1}. ${t}`).join("\n")}` : "All steps complete!"}`, + +Remaining steps: +${todoList} + +IMPORTANT: After completing each step, output [DONE:id] to mark it complete. +Example: [DONE:${remaining[0]?.id || "abc123"}]`, display: false, }, }; @@ -440,8 +364,28 @@ ${remaining.length > 0 ? `Remaining steps:\n${remaining.map((t, i) => `${i + 1}. // After agent finishes pi.on("agent_end", async (event, ctx) => { - // Check if all complete + // Check for done tags in execution mode if (executionMode && todoItems.length > 0) { + const messages = event.messages; + const lastAssistant = [...messages].reverse().find((m) => m.role === "assistant"); + if (lastAssistant && Array.isArray(lastAssistant.content)) { + const textContent = lastAssistant.content + .filter((block): block is { type: "text"; text: string } => block.type === "text") + .map((block) => block.text) + .join("\n"); + + // Find and mark completed items + const doneIds = findDoneTags(textContent); + for (const id of doneIds) { + const item = todoItems.find((t) => t.id === id); + if (item && !item.completed) { + item.completed = true; + } + } + updateStatus(ctx); + } + + // Check if all complete const allComplete = todoItems.every((t) => t.completed); if (allComplete) { // Show final completed list in chat @@ -485,7 +429,7 @@ ${remaining.length > 0 ? `Remaining steps:\n${remaining.map((t, i) => `${i + 1}. const hasTodos = todoItems.length > 0; - // Show todo list in chat (no IDs, just numbered) + // Show todo list in chat (no IDs shown to user, just numbered) if (hasTodos) { const todoListText = todoItems.map((t, i) => `${i + 1}. ☐ ${t.text}`).join("\n"); pi.sendMessage( @@ -510,13 +454,11 @@ ${remaining.length > 0 ? `Remaining steps:\n${remaining.map((t, i) => `${i + 1}. pi.setActiveTools(NORMAL_MODE_TOOLS); updateStatus(ctx); + // Simple execution message - context event filters old plan mode messages + // and before_agent_start injects fresh execution context with IDs const execMessage = hasTodos - ? `[PLAN MODE DISABLED - EXECUTE NOW] -You now have FULL access to all tools: read, write, edit, bash. -Execute the plan step by step. Start with: ${todoItems[0].text}` - : `[PLAN MODE DISABLED - EXECUTE NOW] -You now have FULL access to all tools: read, write, edit, bash. -Execute the plan you just created. Proceed step by step.`; + ? `Execute the plan. Start with: ${todoItems[0].text}` + : "Execute the plan you just created."; pi.sendMessage( { From 206aebb0363a88a6ce74a764c14d1c2191379ab3 Mon Sep 17 00:00:00 2001 From: Helmut Januschka Date: Sat, 3 Jan 2026 22:00:22 +0100 Subject: [PATCH 20/30] fix(hooks): fix ContextEventResult.messages type to AgentMessage[] - Was incorrectly typed as Message[] which caused filtered messages to be ignored - Context event filter in plan-mode hook should now properly remove stale [PLAN MODE ACTIVE] messages --- .../coding-agent/examples/hooks/plan-mode.ts | 18 +++++++----------- packages/coding-agent/src/core/hooks/types.ts | 2 +- 2 files changed, 8 insertions(+), 12 deletions(-) diff --git a/packages/coding-agent/examples/hooks/plan-mode.ts b/packages/coding-agent/examples/hooks/plan-mode.ts index d8901c11..848194d5 100644 --- a/packages/coding-agent/examples/hooks/plan-mode.ts +++ b/packages/coding-agent/examples/hooks/plan-mode.ts @@ -293,19 +293,15 @@ export default function planModeHook(pi: HookAPI) { // Filter out stale plan mode context messages from LLM context // This ensures the agent only sees the CURRENT state (plan mode on/off) - (pi as any).on("context", async (event: { messages: Array<{ role: string; content: unknown }> }) => { - // Remove any previous plan-mode-context or plan-execution-context messages - // They'll be re-injected with current state via before_agent_start + pi.on("context", async (event) => { + // Only filter when NOT in plan mode (i.e., when executing) + if (planModeEnabled) return; + + // Remove any previous plan-mode-context messages const filtered = event.messages.filter((m) => { if (m.role === "user" && Array.isArray(m.content)) { - // Check for our custom message types in user messages - const hasOldContext = (m.content as Array<{ type: string; text?: string }>).some( - (c) => - c.type === "text" && - c.text && - (c.text.includes("[PLAN MODE ACTIVE]") || - c.text.includes("[PLAN MODE DISABLED") || - c.text.includes("[EXECUTING PLAN]")), + const hasOldContext = m.content.some( + (c) => c.type === "text" && c.text.includes("[PLAN MODE ACTIVE]"), ); if (hasOldContext) return false; } diff --git a/packages/coding-agent/src/core/hooks/types.ts b/packages/coding-agent/src/core/hooks/types.ts index 8ddec584..6305872f 100644 --- a/packages/coding-agent/src/core/hooks/types.ts +++ b/packages/coding-agent/src/core/hooks/types.ts @@ -577,7 +577,7 @@ export type HookEvent = */ export interface ContextEventResult { /** Modified messages to send instead of the original */ - messages?: Message[]; + messages?: AgentMessage[]; } /** From b2fc21a8dd9178a5e4c9a4afe59ed7b357da5c2b Mon Sep 17 00:00:00 2001 From: Helmut Januschka Date: Sat, 3 Jan 2026 22:09:33 +0100 Subject: [PATCH 21/30] debug: add logging to plan-mode hook to trace context filtering --- .../coding-agent/examples/hooks/plan-mode.ts | 25 ++++++++++++++++--- 1 file changed, 22 insertions(+), 3 deletions(-) diff --git a/packages/coding-agent/examples/hooks/plan-mode.ts b/packages/coding-agent/examples/hooks/plan-mode.ts index 848194d5..d9e8b629 100644 --- a/packages/coding-agent/examples/hooks/plan-mode.ts +++ b/packages/coding-agent/examples/hooks/plan-mode.ts @@ -294,27 +294,42 @@ export default function planModeHook(pi: HookAPI) { // Filter out stale plan mode context messages from LLM context // This ensures the agent only sees the CURRENT state (plan mode on/off) pi.on("context", async (event) => { + console.error(`[plan-mode] context event: planModeEnabled=${planModeEnabled}, executionMode=${executionMode}, msgs=${event.messages.length}`); + // Only filter when NOT in plan mode (i.e., when executing) - if (planModeEnabled) return; + if (planModeEnabled) { + console.error("[plan-mode] skipping filter - plan mode enabled"); + return; + } // Remove any previous plan-mode-context messages + const beforeCount = event.messages.length; const filtered = event.messages.filter((m) => { if (m.role === "user" && Array.isArray(m.content)) { const hasOldContext = m.content.some( (c) => c.type === "text" && c.text.includes("[PLAN MODE ACTIVE]"), ); - if (hasOldContext) return false; + if (hasOldContext) { + console.error("[plan-mode] FILTERING OUT message with [PLAN MODE ACTIVE]"); + return false; + } } return true; }); + console.error(`[plan-mode] filtered ${beforeCount} -> ${filtered.length} messages`); return { messages: filtered }; }); // Inject plan mode context pi.on("before_agent_start", async () => { - if (!planModeEnabled && !executionMode) return; + console.error(`[plan-mode] before_agent_start: planModeEnabled=${planModeEnabled}, executionMode=${executionMode}`); + if (!planModeEnabled && !executionMode) { + console.error("[plan-mode] before_agent_start: no injection needed"); + return; + } if (planModeEnabled) { + console.error("[plan-mode] before_agent_start: injecting PLAN MODE ACTIVE"); return { message: { customType: "plan-mode-context", @@ -339,6 +354,7 @@ Do NOT attempt to make changes - just describe what you would do.`, } if (executionMode && todoItems.length > 0) { + console.error("[plan-mode] before_agent_start: injecting EXECUTING PLAN context"); const remaining = todoItems.filter((t) => !t.completed); const todoList = remaining.map((t) => `- [${t.id}] ${t.text}`).join("\n"); return { @@ -356,6 +372,7 @@ Example: [DONE:${remaining[0]?.id || "abc123"}]`, }, }; } + console.error("[plan-mode] before_agent_start: no context injected (shouldn't reach here)"); }); // After agent finishes @@ -447,6 +464,8 @@ Example: [DONE:${remaining[0]?.id || "abc123"}]`, if (choice?.startsWith("Execute")) { planModeEnabled = false; executionMode = hasTodos; + console.error(`[plan-mode] EXECUTING: planModeEnabled=${planModeEnabled}, executionMode=${executionMode}`); + console.error(`[plan-mode] Setting tools to: ${NORMAL_MODE_TOOLS.join(", ")}`); pi.setActiveTools(NORMAL_MODE_TOOLS); updateStatus(ctx); From ec786538da08f62bb7351d314214596b706b509a Mon Sep 17 00:00:00 2001 From: Helmut Januschka Date: Sat, 3 Jan 2026 22:13:30 +0100 Subject: [PATCH 22/30] fix(plan-mode): make DONE tag instruction clearer - Number steps and show id=xxx format - Clearer instruction to output [DONE:id] after each step --- packages/coding-agent/examples/hooks/plan-mode.ts | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/packages/coding-agent/examples/hooks/plan-mode.ts b/packages/coding-agent/examples/hooks/plan-mode.ts index d9e8b629..0dad5539 100644 --- a/packages/coding-agent/examples/hooks/plan-mode.ts +++ b/packages/coding-agent/examples/hooks/plan-mode.ts @@ -356,17 +356,16 @@ Do NOT attempt to make changes - just describe what you would do.`, if (executionMode && todoItems.length > 0) { console.error("[plan-mode] before_agent_start: injecting EXECUTING PLAN context"); const remaining = todoItems.filter((t) => !t.completed); - const todoList = remaining.map((t) => `- [${t.id}] ${t.text}`).join("\n"); + const todoList = remaining.map((t, i) => `${i + 1}. (id=${t.id}) ${t.text}`).join("\n"); return { message: { customType: "plan-execution-context", - content: `[EXECUTING PLAN] -Plan mode is OFF. You have FULL access to: read, write, edit, bash. + content: `[EXECUTING PLAN - You have FULL tool access] -Remaining steps: +Steps to complete: ${todoList} -IMPORTANT: After completing each step, output [DONE:id] to mark it complete. +After completing each step, output [DONE:id] to mark it complete. Example: [DONE:${remaining[0]?.id || "abc123"}]`, display: false, }, From 7439a31e25f8245c420931aafde6abd851459354 Mon Sep 17 00:00:00 2001 From: Helmut Januschka Date: Sat, 3 Jan 2026 22:16:35 +0100 Subject: [PATCH 23/30] fix(plan-mode): use step numbers instead of random IDs - Steps are numbered 1, 2, 3... which is easier for agent to track - Agent outputs [STEP 1 DONE], [STEP 2 DONE] instead of [DONE:abc123] - Clearer instructions in execution context --- .../coding-agent/examples/hooks/plan-mode.ts | 46 +++++++++---------- 1 file changed, 22 insertions(+), 24 deletions(-) diff --git a/packages/coding-agent/examples/hooks/plan-mode.ts b/packages/coding-agent/examples/hooks/plan-mode.ts index 0dad5539..c8e8ea67 100644 --- a/packages/coding-agent/examples/hooks/plan-mode.ts +++ b/packages/coding-agent/examples/hooks/plan-mode.ts @@ -130,20 +130,15 @@ function isSafeCommand(command: string): boolean { return true; } -// Todo item with unique ID +// Todo item with step number interface TodoItem { - id: string; + step: number; text: string; completed: boolean; } -// Generate a short unique ID -function generateId(): string { - return Math.random().toString(36).substring(2, 8); -} - /** - * Extract todo items from assistant message and assign IDs. + * Extract todo items from assistant message. */ function extractTodoItems(message: string): TodoItem[] { const items: TodoItem[] = []; @@ -154,7 +149,7 @@ function extractTodoItems(message: string): TodoItem[] { let text = match[2].trim(); text = text.replace(/\*{1,2}$/, "").trim(); if (text.length > 5 && !text.startsWith("`") && !text.startsWith("/") && !text.startsWith("-")) { - items.push({ id: generateId(), text, completed: false }); + items.push({ step: items.length + 1, text, completed: false }); } } @@ -165,7 +160,7 @@ function extractTodoItems(message: string): TodoItem[] { let text = match[1].trim(); text = text.replace(/\*{1,2}$/, "").trim(); if (text.length > 10 && !text.startsWith("`")) { - items.push({ id: generateId(), text, completed: false }); + items.push({ step: items.length + 1, text, completed: false }); } } } @@ -174,15 +169,19 @@ function extractTodoItems(message: string): TodoItem[] { } /** - * Find [DONE:id] tags in text and return the IDs. + * Find [STEP N DONE] or [DONE N] tags in text and return step numbers. */ -function findDoneTags(text: string): string[] { - const pattern = /\[DONE:([a-z0-9]+)\]/gi; - const ids: string[] = []; - for (const match of text.matchAll(pattern)) { - ids.push(match[1].toLowerCase()); +function findDoneSteps(text: string): number[] { + const steps: number[] = []; + // Match [STEP 1 DONE], [STEP 2 DONE], etc. + for (const match of text.matchAll(/\[STEP\s+(\d+)\s+DONE\]/gi)) { + steps.push(parseInt(match[1], 10)); } - return ids; + // Also match [DONE 1], [DONE 2], etc. + for (const match of text.matchAll(/\[DONE\s+(\d+)\]/gi)) { + steps.push(parseInt(match[1], 10)); + } + return steps; } export default function planModeHook(pi: HookAPI) { @@ -356,17 +355,16 @@ Do NOT attempt to make changes - just describe what you would do.`, if (executionMode && todoItems.length > 0) { console.error("[plan-mode] before_agent_start: injecting EXECUTING PLAN context"); const remaining = todoItems.filter((t) => !t.completed); - const todoList = remaining.map((t, i) => `${i + 1}. (id=${t.id}) ${t.text}`).join("\n"); + const todoList = remaining.map((t) => `Step ${t.step}: ${t.text}`).join("\n"); return { message: { customType: "plan-execution-context", content: `[EXECUTING PLAN - You have FULL tool access] -Steps to complete: ${todoList} -After completing each step, output [DONE:id] to mark it complete. -Example: [DONE:${remaining[0]?.id || "abc123"}]`, +IMPORTANT: After completing each step, output [STEP N DONE] where N is the step number. +Example: After completing step ${remaining[0]?.step || 1}, write [STEP ${remaining[0]?.step || 1} DONE]`, display: false, }, }; @@ -387,9 +385,9 @@ Example: [DONE:${remaining[0]?.id || "abc123"}]`, .join("\n"); // Find and mark completed items - const doneIds = findDoneTags(textContent); - for (const id of doneIds) { - const item = todoItems.find((t) => t.id === id); + const doneSteps = findDoneSteps(textContent); + for (const stepNum of doneSteps) { + const item = todoItems.find((t) => t.step === stepNum); if (item && !item.completed) { item.completed = true; } From 3cd5fa8c450bb226a50724fd1457f8d705fe7cc0 Mon Sep 17 00:00:00 2001 From: Helmut Januschka Date: Sat, 3 Jan 2026 22:20:07 +0100 Subject: [PATCH 24/30] fix(plan-mode): track step completion via tool_result events - No longer relies on agent outputting [STEP N DONE] tags - Each successful tool_result marks the next uncompleted step done - Much more reliable than expecting LLM to follow tag format - Simplified execution context (no special instructions needed) --- .../coding-agent/examples/hooks/plan-mode.ts | 59 ++++++------------- 1 file changed, 19 insertions(+), 40 deletions(-) diff --git a/packages/coding-agent/examples/hooks/plan-mode.ts b/packages/coding-agent/examples/hooks/plan-mode.ts index c8e8ea67..ad5da318 100644 --- a/packages/coding-agent/examples/hooks/plan-mode.ts +++ b/packages/coding-agent/examples/hooks/plan-mode.ts @@ -168,21 +168,7 @@ function extractTodoItems(message: string): TodoItem[] { return items; } -/** - * Find [STEP N DONE] or [DONE N] tags in text and return step numbers. - */ -function findDoneSteps(text: string): number[] { - const steps: number[] = []; - // Match [STEP 1 DONE], [STEP 2 DONE], etc. - for (const match of text.matchAll(/\[STEP\s+(\d+)\s+DONE\]/gi)) { - steps.push(parseInt(match[1], 10)); - } - // Also match [DONE 1], [DONE 2], etc. - for (const match of text.matchAll(/\[DONE\s+(\d+)\]/gi)) { - steps.push(parseInt(match[1], 10)); - } - return steps; -} + export default function planModeHook(pi: HookAPI) { let planModeEnabled = false; @@ -290,6 +276,19 @@ export default function planModeHook(pi: HookAPI) { } }); + // Track step completion based on tool results + pi.on("tool_result", async (_event, ctx) => { + if (!executionMode || todoItems.length === 0) return; + + // Mark the first uncompleted step as done when any tool succeeds + const nextStep = todoItems.find((t) => !t.completed); + if (nextStep) { + nextStep.completed = true; + console.error(`[plan-mode] Marked step ${nextStep.step} complete: ${nextStep.text}`); + updateStatus(ctx); + } + }); + // Filter out stale plan mode context messages from LLM context // This ensures the agent only sees the CURRENT state (plan mode on/off) pi.on("context", async (event) => { @@ -355,16 +354,16 @@ Do NOT attempt to make changes - just describe what you would do.`, if (executionMode && todoItems.length > 0) { console.error("[plan-mode] before_agent_start: injecting EXECUTING PLAN context"); const remaining = todoItems.filter((t) => !t.completed); - const todoList = remaining.map((t) => `Step ${t.step}: ${t.text}`).join("\n"); + const todoList = remaining.map((t) => `${t.step}. ${t.text}`).join("\n"); return { message: { customType: "plan-execution-context", - content: `[EXECUTING PLAN - You have FULL tool access] + content: `[EXECUTING PLAN - Full tool access enabled] +Remaining steps: ${todoList} -IMPORTANT: After completing each step, output [STEP N DONE] where N is the step number. -Example: After completing step ${remaining[0]?.step || 1}, write [STEP ${remaining[0]?.step || 1} DONE]`, +Execute each step in order.`, display: false, }, }; @@ -374,28 +373,8 @@ Example: After completing step ${remaining[0]?.step || 1}, write [STEP ${remaini // After agent finishes pi.on("agent_end", async (event, ctx) => { - // Check for done tags in execution mode + // In execution mode, check if all steps complete if (executionMode && todoItems.length > 0) { - const messages = event.messages; - const lastAssistant = [...messages].reverse().find((m) => m.role === "assistant"); - if (lastAssistant && Array.isArray(lastAssistant.content)) { - const textContent = lastAssistant.content - .filter((block): block is { type: "text"; text: string } => block.type === "text") - .map((block) => block.text) - .join("\n"); - - // Find and mark completed items - const doneSteps = findDoneSteps(textContent); - for (const stepNum of doneSteps) { - const item = todoItems.find((t) => t.step === stepNum); - if (item && !item.completed) { - item.completed = true; - } - } - updateStatus(ctx); - } - - // Check if all complete const allComplete = todoItems.every((t) => t.completed); if (allComplete) { // Show final completed list in chat From 816b4888150ac74fe31f3f067ca540402a14b83f Mon Sep 17 00:00:00 2001 From: Helmut Januschka Date: Sat, 3 Jan 2026 22:23:12 +0100 Subject: [PATCH 25/30] fix(plan-mode): handle non-tool steps and clean up todo text - Non-tool turns (analysis, explanation) now mark step complete at turn_end - Clean up extracted step text: remove markdown, truncate to 50 chars - Remove redundant action words (Use, Run, Execute, etc.) - Track toolsCalledThisTurn flag to distinguish tool vs non-tool turns --- .../coding-agent/examples/hooks/plan-mode.ts | 65 +++++++++++++++++-- 1 file changed, 60 insertions(+), 5 deletions(-) diff --git a/packages/coding-agent/examples/hooks/plan-mode.ts b/packages/coding-agent/examples/hooks/plan-mode.ts index ad5da318..5eacaeae 100644 --- a/packages/coding-agent/examples/hooks/plan-mode.ts +++ b/packages/coding-agent/examples/hooks/plan-mode.ts @@ -137,19 +137,51 @@ interface TodoItem { completed: boolean; } +/** + * Clean up extracted step text for display. + */ +function cleanStepText(text: string): string { + let cleaned = text + // Remove markdown bold/italic + .replace(/\*{1,2}([^*]+)\*{1,2}/g, "$1") + // Remove markdown code + .replace(/`([^`]+)`/g, "$1") + // Remove leading action words that are redundant + .replace(/^(Use|Run|Execute|Create|Write|Read|Check|Verify|Update|Modify|Add|Remove|Delete|Install)\s+(the\s+)?/i, "") + // Clean up extra whitespace + .replace(/\s+/g, " ") + .trim(); + + // Capitalize first letter + if (cleaned.length > 0) { + cleaned = cleaned.charAt(0).toUpperCase() + cleaned.slice(1); + } + + // Truncate if too long + if (cleaned.length > 50) { + cleaned = cleaned.slice(0, 47) + "..."; + } + + return cleaned; +} + /** * Extract todo items from assistant message. */ function extractTodoItems(message: string): TodoItem[] { const items: TodoItem[] = []; - // Match numbered lists: "1. Task" or "1) Task" + // Match numbered lists: "1. Task" or "1) Task" - also handle **bold** prefixes const numberedPattern = /^\s*(\d+)[.)]\s+\*{0,2}([^*\n]+)/gm; for (const match of message.matchAll(numberedPattern)) { let text = match[2].trim(); text = text.replace(/\*{1,2}$/, "").trim(); + // Skip if too short or looks like code/command if (text.length > 5 && !text.startsWith("`") && !text.startsWith("/") && !text.startsWith("-")) { - items.push({ step: items.length + 1, text, completed: false }); + const cleaned = cleanStepText(text); + if (cleaned.length > 3) { + items.push({ step: items.length + 1, text: cleaned, completed: false }); + } } } @@ -160,7 +192,10 @@ function extractTodoItems(message: string): TodoItem[] { let text = match[1].trim(); text = text.replace(/\*{1,2}$/, "").trim(); if (text.length > 10 && !text.startsWith("`")) { - items.push({ step: items.length + 1, text, completed: false }); + const cleaned = cleanStepText(text); + if (cleaned.length > 3) { + items.push({ step: items.length + 1, text: cleaned, completed: false }); + } } } } @@ -172,6 +207,7 @@ function extractTodoItems(message: string): TodoItem[] { export default function planModeHook(pi: HookAPI) { let planModeEnabled = false; + let toolsCalledThisTurn = false; let executionMode = false; let todoItems: TodoItem[] = []; @@ -278,13 +314,15 @@ export default function planModeHook(pi: HookAPI) { // Track step completion based on tool results pi.on("tool_result", async (_event, ctx) => { + toolsCalledThisTurn = true; + if (!executionMode || todoItems.length === 0) return; // Mark the first uncompleted step as done when any tool succeeds const nextStep = todoItems.find((t) => !t.completed); if (nextStep) { nextStep.completed = true; - console.error(`[plan-mode] Marked step ${nextStep.step} complete: ${nextStep.text}`); + console.error(`[plan-mode] Marked step ${nextStep.step} complete (tool): ${nextStep.text}`); updateStatus(ctx); } }); @@ -496,12 +534,29 @@ Execute each step in order.`, updateStatus(ctx); }); - // Persist state + // Reset tool tracking at start of each turn and persist state pi.on("turn_start", async () => { + toolsCalledThisTurn = false; pi.appendEntry("plan-mode", { enabled: planModeEnabled, todos: todoItems, executing: executionMode, }); }); + + // Handle non-tool turns (e.g., analysis, explanation steps) + pi.on("turn_end", async (_event, ctx) => { + if (!executionMode || todoItems.length === 0) return; + + // If no tools were called this turn, the agent was doing analysis/explanation + // Mark the next uncompleted step as done + if (!toolsCalledThisTurn) { + const nextStep = todoItems.find((t) => !t.completed); + if (nextStep) { + nextStep.completed = true; + console.error(`[plan-mode] Marked step ${nextStep.step} complete (no-tool turn): ${nextStep.text}`); + updateStatus(ctx); + } + } + }); } From 0a73710cbae95f97f5190e64d64f157ee0d60675 Mon Sep 17 00:00:00 2001 From: Helmut Januschka Date: Sat, 3 Jan 2026 22:36:44 +0100 Subject: [PATCH 26/30] fix: remove inline imports and debug logging - Convert all inline import() types to top-level imports - Remove debug console.error statements from plan-mode hook --- packages/coding-agent/examples/hooks/plan-mode.ts | 13 ------------- packages/coding-agent/src/core/hooks/loader.ts | 6 +++--- packages/coding-agent/src/core/hooks/runner.ts | 14 ++++++++------ .../src/modes/interactive/interactive-mode.ts | 6 +++--- 4 files changed, 14 insertions(+), 25 deletions(-) diff --git a/packages/coding-agent/examples/hooks/plan-mode.ts b/packages/coding-agent/examples/hooks/plan-mode.ts index 5eacaeae..e1d5961f 100644 --- a/packages/coding-agent/examples/hooks/plan-mode.ts +++ b/packages/coding-agent/examples/hooks/plan-mode.ts @@ -322,7 +322,6 @@ export default function planModeHook(pi: HookAPI) { const nextStep = todoItems.find((t) => !t.completed); if (nextStep) { nextStep.completed = true; - console.error(`[plan-mode] Marked step ${nextStep.step} complete (tool): ${nextStep.text}`); updateStatus(ctx); } }); @@ -330,11 +329,9 @@ export default function planModeHook(pi: HookAPI) { // Filter out stale plan mode context messages from LLM context // This ensures the agent only sees the CURRENT state (plan mode on/off) pi.on("context", async (event) => { - console.error(`[plan-mode] context event: planModeEnabled=${planModeEnabled}, executionMode=${executionMode}, msgs=${event.messages.length}`); // Only filter when NOT in plan mode (i.e., when executing) if (planModeEnabled) { - console.error("[plan-mode] skipping filter - plan mode enabled"); return; } @@ -346,26 +343,21 @@ export default function planModeHook(pi: HookAPI) { (c) => c.type === "text" && c.text.includes("[PLAN MODE ACTIVE]"), ); if (hasOldContext) { - console.error("[plan-mode] FILTERING OUT message with [PLAN MODE ACTIVE]"); return false; } } return true; }); - console.error(`[plan-mode] filtered ${beforeCount} -> ${filtered.length} messages`); return { messages: filtered }; }); // Inject plan mode context pi.on("before_agent_start", async () => { - console.error(`[plan-mode] before_agent_start: planModeEnabled=${planModeEnabled}, executionMode=${executionMode}`); if (!planModeEnabled && !executionMode) { - console.error("[plan-mode] before_agent_start: no injection needed"); return; } if (planModeEnabled) { - console.error("[plan-mode] before_agent_start: injecting PLAN MODE ACTIVE"); return { message: { customType: "plan-mode-context", @@ -390,7 +382,6 @@ Do NOT attempt to make changes - just describe what you would do.`, } if (executionMode && todoItems.length > 0) { - console.error("[plan-mode] before_agent_start: injecting EXECUTING PLAN context"); const remaining = todoItems.filter((t) => !t.completed); const todoList = remaining.map((t) => `${t.step}. ${t.text}`).join("\n"); return { @@ -406,7 +397,6 @@ Execute each step in order.`, }, }; } - console.error("[plan-mode] before_agent_start: no context injected (shouldn't reach here)"); }); // After agent finishes @@ -478,8 +468,6 @@ Execute each step in order.`, if (choice?.startsWith("Execute")) { planModeEnabled = false; executionMode = hasTodos; - console.error(`[plan-mode] EXECUTING: planModeEnabled=${planModeEnabled}, executionMode=${executionMode}`); - console.error(`[plan-mode] Setting tools to: ${NORMAL_MODE_TOOLS.join(", ")}`); pi.setActiveTools(NORMAL_MODE_TOOLS); updateStatus(ctx); @@ -554,7 +542,6 @@ Execute each step in order.`, const nextStep = todoItems.find((t) => !t.completed); if (nextStep) { nextStep.completed = true; - console.error(`[plan-mode] Marked step ${nextStep.step} complete (no-tool turn): ${nextStep.text}`); updateStatus(ctx); } } diff --git a/packages/coding-agent/src/core/hooks/loader.ts b/packages/coding-agent/src/core/hooks/loader.ts index 50f4442f..4411021a 100644 --- a/packages/coding-agent/src/core/hooks/loader.ts +++ b/packages/coding-agent/src/core/hooks/loader.ts @@ -12,7 +12,7 @@ import { getAgentDir } from "../../config.js"; import type { HookMessage } from "../messages.js"; import type { SessionManager } from "../session-manager.js"; import { execCommand } from "./runner.js"; -import type { ExecOptions, HookAPI, HookFactory, HookMessageRenderer, RegisteredCommand } from "./types.js"; +import type { ExecOptions, HookAPI, HookContext, HookFactory, HookMessageRenderer, RegisteredCommand } from "./types.js"; // Create require function to resolve module paths at runtime const require = createRequire(import.meta.url); @@ -101,7 +101,7 @@ export interface HookShortcut { /** Description for help */ description?: string; /** Handler function */ - handler: (ctx: import("./types.js").HookContext) => Promise | void; + handler: (ctx: HookContext) => Promise | void; /** Hook path that registered this shortcut */ hookPath: string; } @@ -296,7 +296,7 @@ function createHookAPI( shortcut: string, options: { description?: string; - handler: (ctx: import("./types.js").HookContext) => Promise | void; + handler: (ctx: HookContext) => Promise | void; }, ): void { shortcuts.set(shortcut, { shortcut, hookPath, ...options }); diff --git a/packages/coding-agent/src/core/hooks/runner.ts b/packages/coding-agent/src/core/hooks/runner.ts index d6155184..620e5367 100644 --- a/packages/coding-agent/src/core/hooks/runner.ts +++ b/packages/coding-agent/src/core/hooks/runner.ts @@ -3,13 +3,15 @@ */ import type { AgentMessage } from "@mariozechner/pi-agent-core"; -import type { Model } from "@mariozechner/pi-ai"; +import type { ImageContent, Model } from "@mariozechner/pi-ai"; import { theme } from "../../modes/interactive/theme/theme.js"; import type { ModelRegistry } from "../model-registry.js"; import type { SessionManager } from "../session-manager.js"; import type { AppendEntryHandler, BranchHandler, + HookFlag, + HookShortcut, LoadedHook, NavigateTreeHandler, NewSessionHandler, @@ -176,8 +178,8 @@ export class HookRunner { /** * Get all CLI flags registered by hooks. */ - getFlags(): Map { - const allFlags = new Map(); + getFlags(): Map { + const allFlags = new Map(); for (const hook of this.hooks) { for (const [name, flag] of hook.flags) { allFlags.set(name, flag); @@ -221,8 +223,8 @@ 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(); @@ -486,7 +488,7 @@ export class HookRunner { */ async emitBeforeAgentStart( prompt: string, - images?: import("@mariozechner/pi-ai").ImageContent[], + images?: ImageContent[], ): Promise { const ctx = this.createContext(); let result: BeforeAgentStartEventResult | undefined; diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index 4a7dcef1..db69e5f6 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -31,7 +31,7 @@ import { exec, spawn, spawnSync } from "child_process"; import { APP_NAME, getAuthPath, getDebugLogPath } from "../../config.js"; import type { AgentSession, AgentSessionEvent } from "../../core/agent-session.js"; import type { CustomToolSessionEvent, LoadedCustomTool } from "../../core/custom-tools/index.js"; -import type { HookUIContext } from "../../core/hooks/index.js"; +import type { HookContext, HookRunner, HookUIContext } from "../../core/hooks/index.js"; import { KeybindingsManager } from "../../core/keybindings.js"; import { createCompactionSummaryMessage } from "../../core/messages.js"; import { type SessionContext, SessionManager } from "../../core/session-manager.js"; @@ -585,12 +585,12 @@ export class InteractiveMode { /** * Set up keyboard shortcuts registered by hooks. */ - private setupHookShortcuts(hookRunner: import("../../core/hooks/index.js").HookRunner): void { + private setupHookShortcuts(hookRunner: 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 => ({ + const createContext = (): HookContext => ({ ui: this.createHookUIContext(), hasUI: true, cwd: process.cwd(), From b42362e1d5ed6bde7f9b8dd9a2f81b4d74f7f287 Mon Sep 17 00:00:00 2001 From: Helmut Januschka Date: Sat, 3 Jan 2026 22:46:49 +0100 Subject: [PATCH 27/30] fix: use robust matchShortcut from TUI library - Add matchShortcut() function to @mariozechner/pi-tui - Handles Kitty protocol, legacy terminal sequences, and lock keys - Supports special keys (enter, tab, space, backspace, escape) - Replace custom implementation in interactive-mode.ts - Remove unused imports --- .../coding-agent/examples/hooks/plan-mode.ts | 16 +++++++--------- packages/coding-agent/src/core/hooks/loader.ts | 9 ++++++++- packages/coding-agent/src/core/hooks/types.ts | 2 +- 3 files changed, 16 insertions(+), 11 deletions(-) diff --git a/packages/coding-agent/examples/hooks/plan-mode.ts b/packages/coding-agent/examples/hooks/plan-mode.ts index e1d5961f..b05a8646 100644 --- a/packages/coding-agent/examples/hooks/plan-mode.ts +++ b/packages/coding-agent/examples/hooks/plan-mode.ts @@ -147,7 +147,10 @@ function cleanStepText(text: string): string { // Remove markdown code .replace(/`([^`]+)`/g, "$1") // Remove leading action words that are redundant - .replace(/^(Use|Run|Execute|Create|Write|Read|Check|Verify|Update|Modify|Add|Remove|Delete|Install)\s+(the\s+)?/i, "") + .replace( + /^(Use|Run|Execute|Create|Write|Read|Check|Verify|Update|Modify|Add|Remove|Delete|Install)\s+(the\s+)?/i, + "", + ) // Clean up extra whitespace .replace(/\s+/g, " ") .trim(); @@ -159,7 +162,7 @@ function cleanStepText(text: string): string { // Truncate if too long if (cleaned.length > 50) { - cleaned = cleaned.slice(0, 47) + "..."; + cleaned = `${cleaned.slice(0, 47)}...`; } return cleaned; @@ -203,8 +206,6 @@ function extractTodoItems(message: string): TodoItem[] { return items; } - - export default function planModeHook(pi: HookAPI) { let planModeEnabled = false; let toolsCalledThisTurn = false; @@ -329,19 +330,16 @@ export default function planModeHook(pi: HookAPI) { // Filter out stale plan mode context messages from LLM context // This ensures the agent only sees the CURRENT state (plan mode on/off) pi.on("context", async (event) => { - // Only filter when NOT in plan mode (i.e., when executing) if (planModeEnabled) { return; } // Remove any previous plan-mode-context messages - const beforeCount = event.messages.length; + const _beforeCount = event.messages.length; const filtered = event.messages.filter((m) => { if (m.role === "user" && Array.isArray(m.content)) { - const hasOldContext = m.content.some( - (c) => c.type === "text" && c.text.includes("[PLAN MODE ACTIVE]"), - ); + const hasOldContext = m.content.some((c) => c.type === "text" && c.text.includes("[PLAN MODE ACTIVE]")); if (hasOldContext) { return false; } diff --git a/packages/coding-agent/src/core/hooks/loader.ts b/packages/coding-agent/src/core/hooks/loader.ts index 4411021a..6e66b47c 100644 --- a/packages/coding-agent/src/core/hooks/loader.ts +++ b/packages/coding-agent/src/core/hooks/loader.ts @@ -12,7 +12,14 @@ import { getAgentDir } from "../../config.js"; import type { HookMessage } from "../messages.js"; import type { SessionManager } from "../session-manager.js"; import { execCommand } from "./runner.js"; -import type { ExecOptions, HookAPI, HookContext, HookFactory, HookMessageRenderer, RegisteredCommand } from "./types.js"; +import type { + ExecOptions, + HookAPI, + HookContext, + HookFactory, + HookMessageRenderer, + RegisteredCommand, +} from "./types.js"; // Create require function to resolve module paths at runtime const require = createRequire(import.meta.url); diff --git a/packages/coding-agent/src/core/hooks/types.ts b/packages/coding-agent/src/core/hooks/types.ts index 6305872f..0b59ce67 100644 --- a/packages/coding-agent/src/core/hooks/types.ts +++ b/packages/coding-agent/src/core/hooks/types.ts @@ -6,7 +6,7 @@ */ import type { AgentMessage } from "@mariozechner/pi-agent-core"; -import type { ImageContent, Message, Model, TextContent, ToolResultMessage } from "@mariozechner/pi-ai"; +import type { ImageContent, Model, TextContent, ToolResultMessage } from "@mariozechner/pi-ai"; 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"; From f7e7d6aa41925369058182535e488808e0bfd9d1 Mon Sep 17 00:00:00 2001 From: Helmut Januschka Date: Sat, 3 Jan 2026 23:36:59 +0100 Subject: [PATCH 28/30] fix(tui): add legacy terminal support for shift+letter shortcuts In legacy terminals, Shift+P produces uppercase 'P' instead of Kitty protocol sequences. Add fallback to check uppercase letters for shift+letter combinations. --- packages/tui/src/keys.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/packages/tui/src/keys.ts b/packages/tui/src/keys.ts index 4ec1503f..22a36036 100644 --- a/packages/tui/src/keys.ts +++ b/packages/tui/src/keys.ts @@ -418,6 +418,12 @@ export function matchesKey(data: string, keyId: KeyId): boolean { return matchesKittySequence(data, codepoint, MODIFIERS.shift + MODIFIERS.ctrl); } + if (shift && !ctrl && !alt) { + // Legacy: shift+letter produces uppercase + if (data === key.toUpperCase()) return true; + return matchesKittySequence(data, codepoint, MODIFIERS.shift); + } + if (modifier !== 0) { return matchesKittySequence(data, codepoint, modifier); } From 46047bc37ecd86c74a188429d79eceeb0329625b Mon Sep 17 00:00:00 2001 From: Helmut Januschka Date: Sun, 4 Jan 2026 00:09:44 +0100 Subject: [PATCH 29/30] refactor: address PR feedback - merge setWidget, use KeyId for shortcuts 1. Merge setWidget and setWidgetComponent into single overloaded method - Accepts either string[] or component factory function - Uses single Map internally - String arrays wrapped in Container with Text components 2. Use KeyId type for registerShortcut instead of plain string - Import Key from @mariozechner/pi-tui - Update plan-mode example to use Key.shift('p') - Type-safe shortcut registration 3. Fix tool API docs - Both built-in and custom tools can be enabled/disabled - Removed incorrect 'custom tools always active' statement 4. Use matchesKey instead of matchShortcut (already done in rebase) --- packages/coding-agent/CHANGELOG.md | 5 +- packages/coding-agent/docs/hooks.md | 10 +-- .../coding-agent/examples/hooks/plan-mode.ts | 3 +- .../src/core/custom-tools/loader.ts | 1 - .../coding-agent/src/core/hooks/loader.ts | 13 +-- .../coding-agent/src/core/hooks/runner.ts | 9 ++- packages/coding-agent/src/core/hooks/types.ts | 31 ++++---- packages/coding-agent/src/core/sdk.ts | 5 +- .../src/modes/interactive/interactive-mode.ts | 79 ++++++------------- .../coding-agent/src/modes/rpc/rpc-mode.ts | 25 +++--- .../test/compaction-hooks.test.ts | 1 - 11 files changed, 76 insertions(+), 106 deletions(-) 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: () => "", From 8b6bc3030174503a350c616d7e8e8cdd1e45c31d Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Sun, 4 Jan 2026 17:16:49 +0100 Subject: [PATCH 30/30] fix(hooks): deep copy messages in context event before passing to hooks The context event handler documentation promised a deep copy but the implementation passed the original array reference. This could cause hooks to accidentally mutate session messages. Uses structuredClone() for fast native deep copying. --- packages/coding-agent/src/core/hooks/runner.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/coding-agent/src/core/hooks/runner.ts b/packages/coding-agent/src/core/hooks/runner.ts index d3589e31..eceb1d18 100644 --- a/packages/coding-agent/src/core/hooks/runner.ts +++ b/packages/coding-agent/src/core/hooks/runner.ts @@ -451,11 +451,11 @@ export class HookRunner { * Handlers are chained - each gets the previous handler's output (if any). * Returns the final modified messages, or the original if no modifications. * - * Note: Messages are already deep-copied by the caller (pi-ai preprocessor). + * Messages are deep-copied before passing to hooks, so mutations are safe. */ async emitContext(messages: AgentMessage[]): Promise { const ctx = this.createContext(); - let currentMessages = messages; + let currentMessages = structuredClone(messages); for (const hook of this.hooks) { const handlers = hook.handlers.get("context");