diff --git a/.pi/hooks/test-command.ts b/.pi/hooks/test-command.ts index 065b5c9b..9e909de2 100644 --- a/.pi/hooks/test-command.ts +++ b/.pi/hooks/test-command.ts @@ -1,24 +1,81 @@ /** - * Test hook that registers a /greet command. - * Usage: /greet [name] + * Test hook demonstrating custom commands, message rendering, and before_agent_start. */ -import type { HookAPI } from "@mariozechner/pi-coding-agent/hooks"; +import type { HookAPI } from "@mariozechner/pi-coding-agent"; +import { Box, Text } from "@mariozechner/pi-tui"; export default function (pi: HookAPI) { - pi.registerCommand("greet", { - description: "Send a greeting message to the LLM", - handler: async (ctx) => { - const name = ctx.args.trim() || "world"; + // Track whether injection is enabled + let injectEnabled = false; - // Insert a custom message and trigger LLM response - pi.sendMessage( - { - customType: "greeting", - content: `Hello, ${name}! Please say something nice about them.`, - display: true, - }, - true, // triggerTurn - get LLM to respond - ); + // Register a custom message renderer for our "test-info" type + pi.registerMessageRenderer("test-info", (message, options, theme) => { + const box = new Box(0, 0, (t) => theme.bg("success", t)); + + const label = theme.fg("successText", "[TEST INFO]"); + box.addChild(new Text(label, 0, 0)); + + const content = + typeof message.content === "string" + ? message.content + : message.content.map((c) => (c.type === "text" ? c.text : "[image]")).join(""); + + box.addChild(new Text(theme.fg("successText", content), 0, 1)); + + if (options.expanded && message.details) { + box.addChild(new Text(theme.fg("dim", `Details: ${JSON.stringify(message.details)}`), 0, 2)); + } + + return box; + }); + + // Register /test-msg command + pi.registerCommand("test-msg", { + description: "Send a test custom message", + handler: async (ctx) => { + pi.sendMessage({ + customType: "test-info", + content: "This is a test message with custom rendering!", + display: true, + details: { timestamp: Date.now(), source: "test-command hook" }, + }); }, }); + + // Register /test-hidden command + pi.registerCommand("test-hidden", { + description: "Send a hidden message (display: false)", + handler: async (ctx) => { + pi.sendMessage({ + customType: "test-info", + content: "This message is in context but not displayed", + display: false, + }); + ctx.ui.notify("Sent hidden message (check session file)"); + }, + }); + + // Register /test-inject command to toggle before_agent_start injection + pi.registerCommand("test-inject", { + description: "Toggle context injection before agent starts", + handler: async (ctx) => { + injectEnabled = !injectEnabled; + ctx.ui.notify(`Context injection ${injectEnabled ? "enabled" : "disabled"}`); + }, + }); + + // Demonstrate before_agent_start: inject context when enabled + pi.on("before_agent_start", async (event, ctx) => { + if (!injectEnabled) return; + + // Return a message to inject before the user's prompt + return { + message: { + customType: "test-info", + content: `[Injected context for prompt: "${event.prompt.slice(0, 50)}..."]`, + display: true, + details: { injectedAt: Date.now() }, + }, + }; + }); } diff --git a/packages/coding-agent/src/core/agent-session.ts b/packages/coding-agent/src/core/agent-session.ts index 9502a2a3..306a7d17 100644 --- a/packages/coding-agent/src/core/agent-session.ts +++ b/packages/coding-agent/src/core/agent-session.ts @@ -490,6 +490,29 @@ export class AgentSession { // Expand file-based slash commands if requested const expandedText = expandCommands ? expandSlashCommand(text, [...this._fileCommands]) : text; + // Emit before_agent_start hook event + if (this._hookRunner) { + const result = await this._hookRunner.emitBeforeAgentStart(expandedText, options?.images); + if (result?.message) { + // Append hook message to agent state and session + const hookMessage: HookMessage = { + role: "hookMessage", + customType: result.message.customType, + content: result.message.content, + display: result.message.display, + details: result.message.details, + timestamp: Date.now(), + }; + this.agent.appendMessage(hookMessage); + this.sessionManager.appendCustomMessageEntry( + result.message.customType, + result.message.content, + result.message.display, + result.message.details, + ); + } + } + await this.agent.prompt(expandedText, options?.images); await this.waitForRetry(); } diff --git a/packages/coding-agent/src/core/hooks/index.ts b/packages/coding-agent/src/core/hooks/index.ts index 43441e3a..6f34fe00 100644 --- a/packages/coding-agent/src/core/hooks/index.ts +++ b/packages/coding-agent/src/core/hooks/index.ts @@ -12,6 +12,8 @@ export { wrapToolsWithHooks, wrapToolWithHooks } from "./tool-wrapper.js"; export type { AgentEndEvent, AgentStartEvent, + BeforeAgentStartEvent, + BeforeAgentStartEventResult, BashToolResultEvent, ContextEvent, ContextEventResult, diff --git a/packages/coding-agent/src/core/hooks/runner.ts b/packages/coding-agent/src/core/hooks/runner.ts index ced7e994..76c17f51 100644 --- a/packages/coding-agent/src/core/hooks/runner.ts +++ b/packages/coding-agent/src/core/hooks/runner.ts @@ -7,6 +7,8 @@ import type { ModelRegistry } from "../model-registry.js"; import type { SessionManager } from "../session-manager.js"; import type { AppendEntryHandler, LoadedHook, SendMessageHandler } from "./loader.js"; import type { + BeforeAgentStartEvent, + BeforeAgentStartEventResult, ContextEvent, ContextEventResult, HookError, @@ -346,4 +348,44 @@ export class HookRunner { return currentMessages; } + + /** + * Emit before_agent_start event to all hooks. + * Returns the first message to inject (if any handler returns one). + */ + async emitBeforeAgentStart( + prompt: string, + images?: import("@mariozechner/pi-ai").ImageContent[], + ): Promise { + const ctx = this.createContext(); + let result: BeforeAgentStartEventResult | undefined; + + for (const hook of this.hooks) { + const handlers = hook.handlers.get("before_agent_start"); + if (!handlers || handlers.length === 0) continue; + + for (const handler of handlers) { + try { + const event: BeforeAgentStartEvent = { type: "before_agent_start", prompt, images }; + const timeout = createTimeout(this.timeout); + const handlerResult = await Promise.race([handler(event, ctx), timeout.promise]); + timeout.clear(); + + // Take the first message returned + if (handlerResult && (handlerResult as BeforeAgentStartEventResult).message && !result) { + result = handlerResult as BeforeAgentStartEventResult; + } + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + this.emitError({ + hookPath: hook.path, + event: "before_agent_start", + error: message, + }); + } + } + } + + return result; + } } diff --git a/packages/coding-agent/src/core/hooks/types.ts b/packages/coding-agent/src/core/hooks/types.ts index 05eba8e8..32426f45 100644 --- a/packages/coding-agent/src/core/hooks/types.ts +++ b/packages/coding-agent/src/core/hooks/types.ts @@ -158,6 +158,19 @@ export interface ContextEvent { messages: AgentMessage[]; } +/** + * Event data for before_agent_start event. + * Fired after user submits a prompt but before the agent loop starts. + * Allows hooks to inject context that will be persisted and visible in TUI. + */ +export interface BeforeAgentStartEvent { + type: "before_agent_start"; + /** The user's prompt text */ + prompt: string; + /** Any images attached to the prompt */ + images?: ImageContent[]; +} + /** * Event data for agent_start event. * Fired when an agent loop starts (once per user prompt). @@ -314,6 +327,7 @@ export function isLsToolResult(e: ToolResultEvent): e is LsToolResultEvent { export type HookEvent = | SessionEvent | ContextEvent + | BeforeAgentStartEvent | AgentStartEvent | AgentEndEvent | TurnStartEvent @@ -358,6 +372,15 @@ export interface ToolResultEventResult { isError?: boolean; } +/** + * Return type for before_agent_start event handlers. + * Allows hooks to inject context before the agent runs. + */ +export interface BeforeAgentStartEventResult { + /** Message to inject into context (persisted to session, visible in TUI) */ + message?: Pick; +} + /** * Return type for session event handlers. * Allows hooks to cancel "before_*" actions. @@ -433,6 +456,11 @@ export interface HookAPI { on(event: "session", handler: HookHandler): void; // biome-ignore lint/suspicious/noConfusingVoidType: void allows handlers to not return anything on(event: "context", handler: HookHandler): void; + // biome-ignore lint/suspicious/noConfusingVoidType: void allows handlers to not return anything + on( + event: "before_agent_start", + handler: HookHandler, + ): void; on(event: "agent_start", handler: HookHandler): void; on(event: "agent_end", handler: HookHandler): void; on(event: "turn_start", handler: HookHandler): void; diff --git a/packages/coding-agent/src/index.ts b/packages/coding-agent/src/index.ts index 01c0d689..b363b1ce 100644 --- a/packages/coding-agent/src/index.ts +++ b/packages/coding-agent/src/index.ts @@ -42,6 +42,8 @@ export type { AgentEndEvent, AgentStartEvent, BashToolResultEvent, + BeforeAgentStartEvent, + BeforeAgentStartEventResult, CustomToolResultEvent, EditToolResultEvent, FindToolResultEvent,