diff --git a/.pi/hooks/test-command.ts b/.pi/hooks/test-command.ts index 07354c87..065b5c9b 100644 --- a/.pi/hooks/test-command.ts +++ b/.pi/hooks/test-command.ts @@ -11,7 +11,7 @@ export default function (pi: HookAPI) { const name = ctx.args.trim() || "world"; // Insert a custom message and trigger LLM response - ctx.sendMessage( + pi.sendMessage( { customType: "greeting", content: `Hello, ${name}! Please say something nice about them.`, diff --git a/packages/agent/src/agent.ts b/packages/agent/src/agent.ts index 39b4c943..ecf785b3 100644 --- a/packages/agent/src/agent.ts +++ b/packages/agent/src/agent.ts @@ -325,15 +325,15 @@ export class Agent { break; } case "message_update": { - partial = ev.message as AppMessage; + partial = ev.message; this._state.streamMessage = ev.message as Message; break; } case "message_end": { partial = null; this._state.streamMessage = null; - this.appendMessage(ev.message as AppMessage); - generatedMessages.push(ev.message as AppMessage); + this.appendMessage(ev.message); + generatedMessages.push(ev.message); break; } case "tool_execution_start": { diff --git a/packages/agent/src/types.ts b/packages/agent/src/types.ts index 46da1492..6fccb591 100644 --- a/packages/agent/src/types.ts +++ b/packages/agent/src/types.ts @@ -73,7 +73,7 @@ export interface AgentState { tools: AgentTool[]; messages: AppMessage[]; // Can include attachments + custom message types isStreaming: boolean; - streamMessage: Message | null; + streamMessage: AppMessage | null; pendingToolCalls: Set; error?: string; } diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 5efd1ee1..b2c064c8 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -56,8 +56,8 @@ - **Entry labels**: New `getLabel(id)` and `appendLabelChange(targetId, label)` methods for labeling entries. Labels are included in `SessionTreeNode` for UI/export. - **TUI**: `CustomMessageEntry` renders with purple styling (customMessageBg, customMessageText, customMessageLabel theme colors). Entries with `display: false` are hidden. - **AgentSession**: New `sendHookMessage(message, triggerTurn?)` method for hooks to inject messages. Handles queuing during streaming, direct append when idle, and optional turn triggering. -- **HookAppMessage**: New message type with `role: "hookMessage"` for hook-injected messages in agent events. Use `isHookAppMessage(msg)` type guard to identify them. These are converted to user messages for LLM context via `messageTransformer`. -- **Agent.prompt()**: Now accepts `AppMessage` directly (in addition to `string, attachments?`) for custom message types like `HookAppMessage`. +- **HookMessage**: New message type with `role: "hookMessage"` for hook-injected messages in agent events. Use `isHookMessage(msg)` type guard to identify them. These are converted to user messages for LLM context via `messageTransformer`. +- **Agent.prompt()**: Now accepts `AppMessage` directly (in addition to `string, attachments?`) for custom message types like `HookMessage`. ### Fixed diff --git a/packages/coding-agent/docs/session-tree-plan.md b/packages/coding-agent/docs/session-tree-plan.md index 319cf632..969748e6 100644 --- a/packages/coding-agent/docs/session-tree-plan.md +++ b/packages/coding-agent/docs/session-tree-plan.md @@ -219,7 +219,7 @@ For arbitrary hook UI with keyboard focus: ```typescript interface HookUIContext { // ... existing: select, confirm, input, notify - + /** Show custom component with keyboard focus. Call done() when finished. */ custom(component: Component, done: () => void): void; } @@ -325,7 +325,7 @@ Review and update all docs: - Updated event signatures (`SessionEventBase`, `before_compact`, etc.) - [ ] `docs/hooks-v2.md` - Review/merge or remove if obsolete - [ ] `docs/sdk.md` - Update for: - - `HookAppMessage` and `isHookAppMessage()` + - `HookMessage` and `isHookMessage()` - `Agent.prompt(AppMessage)` overload - Session v2 tree structure - SessionManager API changes diff --git a/packages/coding-agent/src/core/agent-session.ts b/packages/coding-agent/src/core/agent-session.ts index b00caad8..ecd509d0 100644 --- a/packages/coding-agent/src/core/agent-session.ts +++ b/packages/coding-agent/src/core/agent-session.ts @@ -14,7 +14,7 @@ */ import type { Agent, AgentEvent, AgentState, AppMessage, Attachment, ThinkingLevel } from "@mariozechner/pi-agent-core"; -import type { AssistantMessage, ImageContent, Message, Model, TextContent } from "@mariozechner/pi-ai"; +import type { AssistantMessage, Message, Model, TextContent } from "@mariozechner/pi-ai"; import { isContextOverflow, modelsAreEqual, supportsXhigh } from "@mariozechner/pi-ai"; import { getAuthPath } from "../config.js"; import { type BashResult, executeBash as executeBashCommand } from "./bash-executor.js"; @@ -29,13 +29,12 @@ import type { LoadedCustomTool, SessionEvent as ToolSessionEvent } from "./custo import { exportSessionToHtml } from "./export-html.js"; import type { HookCommandContext, - HookMessage, HookRunner, SessionEventResult, TurnEndEvent, TurnStartEvent, } from "./hooks/index.js"; -import { type BashExecutionMessage, type HookAppMessage, isHookAppMessage } from "./messages.js"; +import { type BashExecutionMessage, type HookMessage, isHookMessage } from "./messages.js"; import type { ModelRegistry } from "./model-registry.js"; import type { CompactionEntry, SessionManager } from "./session-manager.js"; import type { SettingsManager, SkillsSettings } from "./settings-manager.js"; @@ -220,7 +219,7 @@ export class AgentSession { // Handle session persistence if (event.type === "message_end") { // Check if this is a hook message (has _hookData marker) - if (isHookAppMessage(event.message)) { + if (isHookMessage(event.message)) { // Persist as CustomMessageEntry this.sessionManager.appendCustomMessageEntry( event.message.customType, @@ -557,21 +556,18 @@ export class AgentSession { * @param message Hook message with customType, content, display, details * @param triggerTurn If true and not streaming, triggers a new LLM turn */ - async sendHookMessage(message: HookMessage, triggerTurn?: boolean): Promise { - // Normalize content to array format for the AppMessage - const content: (TextContent | ImageContent)[] = - typeof message.content === "string" ? [{ type: "text", text: message.content }] : message.content; - - // Create HookAppMessage with proper role for type-safe handling - const appMessage: HookAppMessage = { - role: "hookMessage", + async sendHookMessage( + message: Pick, "customType" | "content" | "display" | "details">, + triggerTurn?: boolean, + ): Promise { + const appMessage = { + role: "hookMessage" as const, customType: message.customType, - content, + content: message.content, display: message.display, details: message.details, timestamp: Date.now(), - }; - + } satisfies HookMessage; if (this.isStreaming) { // Queue for processing by agent loop await this.agent.queueMessage(appMessage); diff --git a/packages/coding-agent/src/core/hooks/index.ts b/packages/coding-agent/src/core/hooks/index.ts index 4f98b704..43441e3a 100644 --- a/packages/coding-agent/src/core/hooks/index.ts +++ b/packages/coding-agent/src/core/hooks/index.ts @@ -27,7 +27,6 @@ export type { HookEvent, HookEventContext, HookFactory, - HookMessage, HookMessageRenderer, HookMessageRenderOptions, HookUIContext, diff --git a/packages/coding-agent/src/core/hooks/loader.ts b/packages/coding-agent/src/core/hooks/loader.ts index 6cfb88f8..3ac44b27 100644 --- a/packages/coding-agent/src/core/hooks/loader.ts +++ b/packages/coding-agent/src/core/hooks/loader.ts @@ -9,15 +9,9 @@ import * as path from "node:path"; import { fileURLToPath } from "node:url"; import { createJiti } from "jiti"; import { getAgentDir } from "../../config.js"; +import type { HookMessage } from "../messages.js"; import { execCommand } from "./runner.js"; -import type { - ExecOptions, - HookAPI, - HookFactory, - HookMessage, - HookMessageRenderer, - RegisteredCommand, -} from "./types.js"; +import type { ExecOptions, HookAPI, HookFactory, HookMessageRenderer, RegisteredCommand } from "./types.js"; // Create require function to resolve module paths at runtime const require = createRequire(import.meta.url); @@ -56,7 +50,10 @@ type HandlerFn = (...args: unknown[]) => Promise; /** * Send message handler type for pi.sendMessage(). */ -export type SendMessageHandler = (message: HookMessage, triggerTurn?: boolean) => void; +export type SendMessageHandler = ( + message: Pick, "customType" | "content" | "display" | "details">, + triggerTurn?: boolean, +) => void; /** * Append entry handler type for pi.appendEntry(). diff --git a/packages/coding-agent/src/core/hooks/types.ts b/packages/coding-agent/src/core/hooks/types.ts index 480696b7..74e011f1 100644 --- a/packages/coding-agent/src/core/hooks/types.ts +++ b/packages/coding-agent/src/core/hooks/types.ts @@ -11,8 +11,9 @@ import type { Component } from "@mariozechner/pi-tui"; import type { Theme } from "../../modes/interactive/theme/theme.js"; import type { CompactionPreparation, CompactionResult } from "../compaction.js"; import type { ExecOptions, ExecResult } from "../exec.js"; +import type { HookMessage } from "../messages.js"; import type { ModelRegistry } from "../model-registry.js"; -import type { CompactionEntry, CustomMessageEntry, SessionManager } from "../session-manager.js"; +import type { CompactionEntry, SessionManager } from "../session-manager.js"; import type { EditToolDetails } from "../tools/edit.js"; import type { BashToolDetails, @@ -380,14 +381,6 @@ export interface SessionEventResult { */ export type HookHandler = (event: E, ctx: HookEventContext) => Promise; -/** - * Options passed to custom message renderers. - */ -/** - * Message type for hooks to send. Creates CustomMessageEntry in the session. - */ -export type HookMessage = Pick, "customType" | "content" | "display" | "details">; - export interface HookMessageRenderOptions { /** Whether the view is expanded */ expanded: boolean; @@ -463,7 +456,10 @@ export interface HookAPI { * @param triggerTurn - If true and agent is idle, triggers a new LLM turn. Default: false. * If agent is streaming, message is queued and triggerTurn is ignored. */ - sendMessage(message: HookMessage, triggerTurn?: boolean): void; + sendMessage( + message: Pick, "customType" | "content" | "display" | "details">, + triggerTurn?: boolean, + ): void; /** * Append a custom entry to the session for hook state persistence. diff --git a/packages/coding-agent/src/core/messages.ts b/packages/coding-agent/src/core/messages.ts index 80121d6e..b202ca7d 100644 --- a/packages/coding-agent/src/core/messages.ts +++ b/packages/coding-agent/src/core/messages.ts @@ -32,10 +32,10 @@ import type { ImageContent, TextContent } from "@mariozechner/pi-ai"; * Message type for hook-injected messages via sendMessage(). * These are custom messages that hooks can inject into the conversation. */ -export interface HookAppMessage { +export interface HookMessage { role: "hookMessage"; customType: string; - content: (TextContent | ImageContent)[]; + content: string | (TextContent | ImageContent)[]; display: boolean; details?: T; timestamp: number; @@ -45,7 +45,7 @@ export interface HookAppMessage { declare module "@mariozechner/pi-agent-core" { interface CustomMessages { bashExecution: BashExecutionMessage; - hookMessage: HookAppMessage; + hookMessage: HookMessage; } } @@ -63,8 +63,8 @@ export function isBashExecutionMessage(msg: AppMessage | Message): msg is BashEx /** * Type guard for HookAppMessage. */ -export function isHookAppMessage(msg: AppMessage | Message): msg is HookAppMessage { - return (msg as HookAppMessage).role === "hookMessage"; +export function isHookMessage(msg: AppMessage | Message): msg is HookMessage { + return (msg as HookMessage).role === "hookMessage"; } // ============================================================================ @@ -114,7 +114,7 @@ export function messageTransformer(messages: AppMessage[]): Message[] { timestamp: m.timestamp, }; } - if (isHookAppMessage(m)) { + if (isHookMessage(m)) { // Convert hook message to user message for LLM return { role: "user", diff --git a/packages/coding-agent/src/core/session-manager.ts b/packages/coding-agent/src/core/session-manager.ts index 6bd8a46f..fb1ce775 100644 --- a/packages/coding-agent/src/core/session-manager.ts +++ b/packages/coding-agent/src/core/session-manager.ts @@ -131,8 +131,6 @@ export interface SessionTreeNode { export interface SessionContext { messages: AppMessage[]; - /** Entries in the current path (root to leaf). Use to identify custom_message entries for rendering. */ - entries: SessionEntry[]; thinkingLevel: string; model: { provider: string; modelId: string } | null; } @@ -292,7 +290,7 @@ export function buildSessionContext( } if (!leaf) { - return { messages: [], entries: [], thinkingLevel: "off", model: null }; + return { messages: [], thinkingLevel: "off", model: null }; } // Walk from leaf to root, collecting path @@ -326,12 +324,10 @@ export function buildSessionContext( // 2. Emit kept messages (from firstKeptEntryId up to compaction) // 3. Emit messages after compaction const messages: AppMessage[] = []; - const contextEntries: SessionEntry[] = []; if (compaction) { // Emit summary first messages.push(createSummaryMessage(compaction.summary, compaction.timestamp)); - contextEntries.push(compaction); // Find compaction index in path const compactionIdx = path.findIndex((e) => e.type === "compaction" && e.id === compaction.id); @@ -346,13 +342,10 @@ export function buildSessionContext( if (foundFirstKept) { if (entry.type === "message") { messages.push(entry.message); - contextEntries.push(entry); } else if (entry.type === "custom_message") { messages.push(createCustomMessage(entry)); - contextEntries.push(entry); } else if (entry.type === "branch_summary") { messages.push(createSummaryMessage(entry.summary, entry.timestamp)); - contextEntries.push(entry); } } } @@ -362,13 +355,10 @@ export function buildSessionContext( const entry = path[i]; if (entry.type === "message") { messages.push(entry.message); - contextEntries.push(entry); } else if (entry.type === "custom_message") { messages.push(createCustomMessage(entry)); - contextEntries.push(entry); } else if (entry.type === "branch_summary") { messages.push(createSummaryMessage(entry.summary, entry.timestamp)); - contextEntries.push(entry); } } } else { @@ -376,18 +366,15 @@ export function buildSessionContext( for (const entry of path) { if (entry.type === "message") { messages.push(entry.message); - contextEntries.push(entry); } else if (entry.type === "custom_message") { messages.push(createCustomMessage(entry)); - contextEntries.push(entry); } else if (entry.type === "branch_summary") { messages.push(createSummaryMessage(entry.summary, entry.timestamp)); - contextEntries.push(entry); } } } - return { messages, entries: contextEntries, thinkingLevel, model }; + return { messages, thinkingLevel, model }; } /** diff --git a/packages/coding-agent/src/modes/interactive/components/custom-message.ts b/packages/coding-agent/src/modes/interactive/components/hook-message.ts similarity index 65% rename from packages/coding-agent/src/modes/interactive/components/custom-message.ts rename to packages/coding-agent/src/modes/interactive/components/hook-message.ts index cb1ccf34..9ef94d6a 100644 --- a/packages/coding-agent/src/modes/interactive/components/custom-message.ts +++ b/packages/coding-agent/src/modes/interactive/components/hook-message.ts @@ -1,22 +1,22 @@ import type { TextContent } from "@mariozechner/pi-ai"; import { Box, Container, Markdown, Spacer, Text } from "@mariozechner/pi-tui"; -import type { HookMessage, HookMessageRenderer } from "../../../core/hooks/types.js"; -import type { CustomMessageEntry } from "../../../core/session-manager.js"; +import type { HookMessage } from "packages/coding-agent/src/core/messages.js"; +import type { HookMessageRenderer } from "../../../core/hooks/types.js"; import { getMarkdownTheme, theme } from "../theme/theme.js"; /** * Component that renders a custom message entry from hooks. * Uses distinct styling to differentiate from user messages. */ -export class CustomMessageComponent extends Container { - private entry: CustomMessageEntry; +export class HookMessageComponent extends Container { + private message: HookMessage; private customRenderer?: HookMessageRenderer; private box: Box; private _expanded = false; - constructor(entry: CustomMessageEntry, customRenderer?: HookMessageRenderer) { + constructor(message: HookMessage, customRenderer?: HookMessageRenderer) { super(); - this.entry = entry; + this.message = message; this.customRenderer = customRenderer; this.addChild(new Spacer(1)); @@ -38,18 +38,10 @@ export class CustomMessageComponent extends Container { private rebuild(): void { this.box.clear(); - // Convert entry to HookMessage for renderer - const message: HookMessage = { - customType: this.entry.customType, - content: this.entry.content, - display: this.entry.display, - details: this.entry.details, - }; - // Try custom renderer first if (this.customRenderer) { try { - const component = this.customRenderer(message, { expanded: this._expanded }, theme); + const component = this.customRenderer(this.message, { expanded: this._expanded }, theme); if (component) { this.box.addChild(component); return; @@ -60,16 +52,16 @@ export class CustomMessageComponent extends Container { } // Default rendering: label + content - const label = theme.fg("customMessageLabel", `\x1b[1m[${this.entry.customType}]\x1b[22m`); + const label = theme.fg("customMessageLabel", `\x1b[1m[${this.message.customType}]\x1b[22m`); this.box.addChild(new Text(label, 0, 0)); this.box.addChild(new Spacer(1)); // Extract text content let text: string; - if (typeof this.entry.content === "string") { - text = this.entry.content; + if (typeof this.message.content === "string") { + text = this.message.content; } else { - text = this.entry.content + text = this.message.content .filter((c): c is TextContent => c.type === "text") .map((c) => c.text) .join("\n"); diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index babb141f..97da3727 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -28,7 +28,7 @@ import { APP_NAME, getAuthPath, getDebugLogPath } from "../../config.js"; import type { AgentSession, AgentSessionEvent } from "../../core/agent-session.js"; import type { LoadedCustomTool, SessionEvent as ToolSessionEvent } from "../../core/custom-tools/index.js"; import type { HookUIContext } from "../../core/hooks/index.js"; -import { isBashExecutionMessage, isHookAppMessage } from "../../core/messages.js"; +import { isBashExecutionMessage, isHookMessage } from "../../core/messages.js"; import { getLatestCompactionEntry, type SessionContext, @@ -46,10 +46,10 @@ import { AssistantMessageComponent } from "./components/assistant-message.js"; import { BashExecutionComponent } from "./components/bash-execution.js"; import { CompactionComponent } from "./components/compaction.js"; import { CustomEditor } from "./components/custom-editor.js"; -import { CustomMessageComponent } from "./components/custom-message.js"; import { DynamicBorder } from "./components/dynamic-border.js"; import { FooterComponent } from "./components/footer.js"; import { HookInputComponent } from "./components/hook-input.js"; +import { HookMessageComponent } from "./components/hook-message.js"; import { HookSelectorComponent } from "./components/hook-selector.js"; import { ModelSelectorComponent } from "./components/model-selector.js"; import { OAuthSelectorComponent } from "./components/oauth-selector.js"; @@ -817,7 +817,7 @@ export class InteractiveMode { break; case "message_start": - if (isHookAppMessage(event.message)) { + if (isHookMessage(event.message)) { this.addMessageToChat(event.message); this.ui.requestRender(); } else if (event.message.role === "user") { @@ -1051,7 +1051,7 @@ export class InteractiveMode { this.ui.requestRender(); } - private addMessageToChat(message: Message | AppMessage): void { + private addMessageToChat(message: AppMessage): void { if (isBashExecutionMessage(message)) { const component = new BashExecutionComponent(message.command, this.ui); if (message.output) { @@ -1067,20 +1067,11 @@ export class InteractiveMode { return; } - if (isHookAppMessage(message)) { + if (isHookMessage(message)) { // Render as custom message if display is true if (message.display) { - const entry = { - type: "custom_message" as const, - customType: message.customType, - content: message.content, - display: true, - id: "", - parentId: null, - timestamp: new Date().toISOString(), - }; const renderer = this.session.hookRunner?.getMessageRenderer(message.customType); - this.chatContainer.addChild(new CustomMessageComponent(entry, renderer)); + this.chatContainer.addChild(new HookMessageComponent(message, renderer)); } } else if (message.role === "user") { const textContent = this.getUserMessageText(message); @@ -1114,11 +1105,9 @@ export class InteractiveMode { } const compactionEntry = getLatestCompactionEntry(this.sessionManager.getEntries()); - const entries = sessionContext.entries; for (let i = 0; i < sessionContext.messages.length; i++) { const message = sessionContext.messages[i]; - const entry = entries?.[i]; if (isBashExecutionMessage(message)) { this.addMessageToChat(message); @@ -1126,10 +1115,10 @@ export class InteractiveMode { } // Check if this is a custom_message entry - if (entry?.type === "custom_message") { - if (entry.display) { - const renderer = this.session.hookRunner?.getMessageRenderer(entry.customType); - this.chatContainer.addChild(new CustomMessageComponent(entry, renderer)); + if (isHookMessage(message)) { + if (message.display) { + const renderer = this.session.hookRunner?.getMessageRenderer(message.customType); + this.chatContainer.addChild(new HookMessageComponent(message, renderer)); } continue; } @@ -1322,7 +1311,7 @@ export class InteractiveMode { child.setExpanded(this.toolOutputExpanded); } else if (child instanceof BashExecutionComponent) { child.setExpanded(this.toolOutputExpanded); - } else if (child instanceof CustomMessageComponent) { + } else if (child instanceof HookMessageComponent) { child.setExpanded(this.toolOutputExpanded); } }