Refactor: shared exec utility, rename CustomMessageRenderer to HookMessageRenderer

- Extract execCommand to src/core/exec.ts, shared by hooks and custom-tools
- Rename CustomMessageRenderer -> HookMessageRenderer
- Rename registerCustomMessageRenderer -> registerMessageRenderer
- Renderer now receives HookMessage instead of CustomMessageEntry
- CustomMessageComponent now has setExpanded() and responds to Ctrl+E toggle
- Re-export ExecOptions/ExecResult from exec.ts for backward compatibility
This commit is contained in:
Mario Zechner 2025-12-27 03:10:08 +01:00
parent 5fee9005b7
commit a8866d7a83
12 changed files with 199 additions and 261 deletions

View file

@ -34,9 +34,9 @@
- `pi.send(text, attachments?)` replaced with `pi.sendMessage(message, triggerTurn?)` which creates `CustomMessageEntry` instead of user messages - `pi.send(text, attachments?)` replaced with `pi.sendMessage(message, triggerTurn?)` which creates `CustomMessageEntry` instead of user messages
- New `pi.appendEntry(customType, data?)` to persist hook state (does NOT participate in LLM context) - New `pi.appendEntry(customType, data?)` to persist hook state (does NOT participate in LLM context)
- New `pi.registerCommand(name, options)` to register custom slash commands - New `pi.registerCommand(name, options)` to register custom slash commands
- New `pi.registerCustomMessageRenderer(customType, renderer)` to register custom renderers for `CustomMessageEntry` - New `pi.registerMessageRenderer(customType, renderer)` to register custom renderers for hook messages
- New `pi.exec(command, args, options?)` to execute shell commands (moved from `HookEventContext`/`HookCommandContext`) - New `pi.exec(command, args, options?)` to execute shell commands (moved from `HookEventContext`/`HookCommandContext`)
- `CustomMessageRenderer` type: `(entry, options, theme) => Component | null` - `HookMessageRenderer` type: `(message: HookMessage, options, theme) => Component | null`
- Renderers return inner content; the TUI wraps it in a styled Box - Renderers return inner content; the TUI wraps it in a styled Box
- New types: `HookMessage<T>`, `RegisteredCommand`, `HookCommandContext` - New types: `HookMessage<T>`, `RegisteredCommand`, `HookCommandContext`
- Handler types renamed: `SendHandler``SendMessageHandler`, new `AppendEntryHandler` - Handler types renamed: `SendHandler``SendMessageHandler`, new `AppendEntryHandler`

View file

@ -7,7 +7,6 @@
* for custom tools that depend on pi packages. * for custom tools that depend on pi packages.
*/ */
import { spawn } from "node:child_process";
import * as fs from "node:fs"; import * as fs from "node:fs";
import { createRequire } from "node:module"; import { createRequire } from "node:module";
import * as os from "node:os"; import * as os from "node:os";
@ -15,15 +14,10 @@ import * as path from "node:path";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
import { createJiti } from "jiti"; import { createJiti } from "jiti";
import { getAgentDir, isBunBinary } from "../../config.js"; import { getAgentDir, isBunBinary } from "../../config.js";
import type { ExecOptions } from "../exec.js";
import { execCommand } from "../exec.js";
import type { HookUIContext } from "../hooks/types.js"; import type { HookUIContext } from "../hooks/types.js";
import type { import type { CustomToolFactory, CustomToolsLoadResult, LoadedCustomTool, ToolAPI } from "./types.js";
CustomToolFactory,
CustomToolsLoadResult,
ExecOptions,
ExecResult,
LoadedCustomTool,
ToolAPI,
} from "./types.js";
// Create require function to resolve module paths at runtime // Create require function to resolve module paths at runtime
const require = createRequire(import.meta.url); const require = createRequire(import.meta.url);
@ -87,88 +81,6 @@ function resolveToolPath(toolPath: string, cwd: string): string {
return path.resolve(cwd, expanded); return path.resolve(cwd, expanded);
} }
/**
* Execute a command and return stdout/stderr/code.
* Supports cancellation via AbortSignal and timeout.
*/
async function execCommand(command: string, args: string[], cwd: string, options?: ExecOptions): Promise<ExecResult> {
return new Promise((resolve) => {
const proc = spawn(command, args, {
cwd,
shell: false,
stdio: ["ignore", "pipe", "pipe"],
});
let stdout = "";
let stderr = "";
let killed = false;
let timeoutId: NodeJS.Timeout | undefined;
const killProcess = () => {
if (!killed) {
killed = true;
proc.kill("SIGTERM");
// Force kill after 5 seconds if SIGTERM doesn't work
setTimeout(() => {
if (!proc.killed) {
proc.kill("SIGKILL");
}
}, 5000);
}
};
// Handle abort signal
if (options?.signal) {
if (options.signal.aborted) {
killProcess();
} else {
options.signal.addEventListener("abort", killProcess, { once: true });
}
}
// Handle timeout
if (options?.timeout && options.timeout > 0) {
timeoutId = setTimeout(() => {
killProcess();
}, options.timeout);
}
proc.stdout.on("data", (data) => {
stdout += data.toString();
});
proc.stderr.on("data", (data) => {
stderr += data.toString();
});
proc.on("close", (code) => {
if (timeoutId) clearTimeout(timeoutId);
if (options?.signal) {
options.signal.removeEventListener("abort", killProcess);
}
resolve({
stdout,
stderr,
code: code ?? 0,
killed,
});
});
proc.on("error", (err) => {
if (timeoutId) clearTimeout(timeoutId);
if (options?.signal) {
options.signal.removeEventListener("abort", killProcess);
}
resolve({
stdout,
stderr: stderr || err.message,
code: 1,
killed,
});
});
});
}
/** /**
* Create a no-op UI context for headless modes. * Create a no-op UI context for headless modes.
*/ */

View file

@ -9,6 +9,7 @@ import type { AgentTool, AgentToolResult, AgentToolUpdateCallback } from "@mario
import type { Component } from "@mariozechner/pi-tui"; import type { Component } from "@mariozechner/pi-tui";
import type { Static, TSchema } from "@sinclair/typebox"; import type { Static, TSchema } from "@sinclair/typebox";
import type { Theme } from "../../modes/interactive/theme/theme.js"; import type { Theme } from "../../modes/interactive/theme/theme.js";
import type { ExecOptions, ExecResult } from "../exec.js";
import type { HookUIContext } from "../hooks/types.js"; import type { HookUIContext } from "../hooks/types.js";
import type { SessionEntry } from "../session-manager.js"; import type { SessionEntry } from "../session-manager.js";
@ -18,22 +19,8 @@ export type ToolUIContext = HookUIContext;
/** Re-export for custom tools to use in execute signature */ /** Re-export for custom tools to use in execute signature */
export type { AgentToolUpdateCallback }; export type { AgentToolUpdateCallback };
export interface ExecResult { // Re-export for backward compatibility
stdout: string; export type { ExecOptions, ExecResult } from "../exec.js";
stderr: string;
code: number;
/** True if the process was killed due to signal or timeout */
killed?: boolean;
}
export interface ExecOptions {
/** AbortSignal to cancel the process */
signal?: AbortSignal;
/** Timeout in milliseconds */
timeout?: number;
/** Working directory */
cwd?: string;
}
/** API passed to custom tool factory (stable across session changes) */ /** API passed to custom tool factory (stable across session changes) */
export interface ToolAPI { export interface ToolAPI {

View file

@ -0,0 +1,104 @@
/**
* Shared command execution utilities for hooks and custom tools.
*/
import { spawn } from "node:child_process";
/**
* Options for executing shell commands.
*/
export interface ExecOptions {
/** AbortSignal to cancel the command */
signal?: AbortSignal;
/** Timeout in milliseconds */
timeout?: number;
/** Working directory */
cwd?: string;
}
/**
* Result of executing a shell command.
*/
export interface ExecResult {
stdout: string;
stderr: string;
code: number;
killed: boolean;
}
/**
* Execute a shell command and return stdout/stderr/code.
* Supports timeout and abort signal.
*/
export async function execCommand(
command: string,
args: string[],
cwd: string,
options?: ExecOptions,
): Promise<ExecResult> {
return new Promise((resolve) => {
const proc = spawn(command, args, {
cwd,
shell: false,
stdio: ["ignore", "pipe", "pipe"],
});
let stdout = "";
let stderr = "";
let killed = false;
let timeoutId: NodeJS.Timeout | undefined;
const killProcess = () => {
if (!killed) {
killed = true;
proc.kill("SIGTERM");
// Force kill after 5 seconds if SIGTERM doesn't work
setTimeout(() => {
if (!proc.killed) {
proc.kill("SIGKILL");
}
}, 5000);
}
};
// Handle abort signal
if (options?.signal) {
if (options.signal.aborted) {
killProcess();
} else {
options.signal.addEventListener("abort", killProcess, { once: true });
}
}
// Handle timeout
if (options?.timeout && options.timeout > 0) {
timeoutId = setTimeout(() => {
killProcess();
}, options.timeout);
}
proc.stdout?.on("data", (data) => {
stdout += data.toString();
});
proc.stderr?.on("data", (data) => {
stderr += data.toString();
});
proc.on("close", (code) => {
if (timeoutId) clearTimeout(timeoutId);
if (options?.signal) {
options.signal.removeEventListener("abort", killProcess);
}
resolve({ stdout, stderr, code: code ?? 0, killed });
});
proc.on("error", (_err) => {
if (timeoutId) clearTimeout(timeoutId);
if (options?.signal) {
options.signal.removeEventListener("abort", killProcess);
}
resolve({ stdout, stderr, code: 1, killed });
});
});
}

View file

@ -13,8 +13,8 @@ export type {
AgentEndEvent, AgentEndEvent,
AgentStartEvent, AgentStartEvent,
BashToolResultEvent, BashToolResultEvent,
CustomMessageRenderer, HookMessageRenderer,
CustomMessageRenderOptions, HookMessageRenderOptions,
CustomToolResultEvent, CustomToolResultEvent,
EditToolResultEvent, EditToolResultEvent,
ExecOptions, ExecOptions,

View file

@ -11,11 +11,11 @@ import { createJiti } from "jiti";
import { getAgentDir } from "../../config.js"; import { getAgentDir } from "../../config.js";
import { execCommand } from "./runner.js"; import { execCommand } from "./runner.js";
import type { import type {
CustomMessageRenderer,
ExecOptions, ExecOptions,
HookAPI, HookAPI,
HookFactory, HookFactory,
HookMessage, HookMessage,
HookMessageRenderer,
RegisteredCommand, RegisteredCommand,
} from "./types.js"; } from "./types.js";
@ -73,8 +73,8 @@ export interface LoadedHook {
resolvedPath: string; resolvedPath: string;
/** Map of event type to handler functions */ /** Map of event type to handler functions */
handlers: Map<string, HandlerFn[]>; handlers: Map<string, HandlerFn[]>;
/** Map of customType to custom message renderer */ /** Map of customType to hook message renderer */
customMessageRenderers: Map<string, CustomMessageRenderer>; messageRenderers: Map<string, HookMessageRenderer>;
/** Map of command name to registered command */ /** Map of command name to registered command */
commands: Map<string, RegisteredCommand>; commands: Map<string, RegisteredCommand>;
/** Set the send message handler for this hook's pi.sendMessage() */ /** Set the send message handler for this hook's pi.sendMessage() */
@ -136,7 +136,7 @@ function createHookAPI(
cwd: string, cwd: string,
): { ): {
api: HookAPI; api: HookAPI;
customMessageRenderers: Map<string, CustomMessageRenderer>; messageRenderers: Map<string, HookMessageRenderer>;
commands: Map<string, RegisteredCommand>; commands: Map<string, RegisteredCommand>;
setSendMessageHandler: (handler: SendMessageHandler) => void; setSendMessageHandler: (handler: SendMessageHandler) => void;
setAppendEntryHandler: (handler: AppendEntryHandler) => void; setAppendEntryHandler: (handler: AppendEntryHandler) => void;
@ -147,7 +147,7 @@ function createHookAPI(
let appendEntryHandler: AppendEntryHandler = () => { let appendEntryHandler: AppendEntryHandler = () => {
// Default no-op until mode sets the handler // Default no-op until mode sets the handler
}; };
const customMessageRenderers = new Map<string, CustomMessageRenderer>(); const messageRenderers = new Map<string, HookMessageRenderer>();
const commands = new Map<string, RegisteredCommand>(); const commands = new Map<string, RegisteredCommand>();
// Cast to HookAPI - the implementation is more general (string event names) // Cast to HookAPI - the implementation is more general (string event names)
@ -164,8 +164,8 @@ function createHookAPI(
appendEntry<T = unknown>(customType: string, data?: T): void { appendEntry<T = unknown>(customType: string, data?: T): void {
appendEntryHandler(customType, data); appendEntryHandler(customType, data);
}, },
registerCustomMessageRenderer<T = unknown>(customType: string, renderer: CustomMessageRenderer<T>): void { registerMessageRenderer<T = unknown>(customType: string, renderer: HookMessageRenderer<T>): void {
customMessageRenderers.set(customType, renderer as CustomMessageRenderer); messageRenderers.set(customType, renderer as HookMessageRenderer);
}, },
registerCommand(name: string, options: { description?: string; handler: RegisteredCommand["handler"] }): void { registerCommand(name: string, options: { description?: string; handler: RegisteredCommand["handler"] }): void {
commands.set(name, { name, ...options }); commands.set(name, { name, ...options });
@ -177,7 +177,7 @@ function createHookAPI(
return { return {
api, api,
customMessageRenderers, messageRenderers,
commands, commands,
setSendMessageHandler: (handler: SendMessageHandler) => { setSendMessageHandler: (handler: SendMessageHandler) => {
sendMessageHandler = handler; sendMessageHandler = handler;
@ -212,7 +212,7 @@ async function loadHook(hookPath: string, cwd: string): Promise<{ hook: LoadedHo
// Create handlers map and API // Create handlers map and API
const handlers = new Map<string, HandlerFn[]>(); const handlers = new Map<string, HandlerFn[]>();
const { api, customMessageRenderers, commands, setSendMessageHandler, setAppendEntryHandler } = createHookAPI( const { api, messageRenderers, commands, setSendMessageHandler, setAppendEntryHandler } = createHookAPI(
handlers, handlers,
cwd, cwd,
); );
@ -225,7 +225,7 @@ async function loadHook(hookPath: string, cwd: string): Promise<{ hook: LoadedHo
path: hookPath, path: hookPath,
resolvedPath, resolvedPath,
handlers, handlers,
customMessageRenderers, messageRenderers,
commands, commands,
setSendMessageHandler, setSendMessageHandler,
setAppendEntryHandler, setAppendEntryHandler,

View file

@ -2,17 +2,14 @@
* Hook runner - executes hooks and manages their lifecycle. * Hook runner - executes hooks and manages their lifecycle.
*/ */
import { spawn } from "node:child_process";
import type { ModelRegistry } from "../model-registry.js"; import type { ModelRegistry } from "../model-registry.js";
import type { SessionManager } from "../session-manager.js"; import type { SessionManager } from "../session-manager.js";
import type { AppendEntryHandler, LoadedHook, SendMessageHandler } from "./loader.js"; import type { AppendEntryHandler, LoadedHook, SendMessageHandler } from "./loader.js";
import type { import type {
CustomMessageRenderer,
ExecOptions,
ExecResult,
HookError, HookError,
HookEvent, HookEvent,
HookEventContext, HookEventContext,
HookMessageRenderer,
HookUIContext, HookUIContext,
RegisteredCommand, RegisteredCommand,
SessionEvent, SessionEvent,
@ -32,78 +29,8 @@ const DEFAULT_TIMEOUT = 30000;
*/ */
export type HookErrorListener = (error: HookError) => void; export type HookErrorListener = (error: HookError) => void;
/** // Re-export execCommand for backward compatibility
* Execute a command and return stdout/stderr/code. export { execCommand } from "../exec.js";
* Supports cancellation via AbortSignal and timeout.
*/
export async function execCommand(
command: string,
args: string[],
cwd: string,
options?: ExecOptions,
): Promise<ExecResult> {
return new Promise((resolve) => {
const proc = spawn(command, args, { cwd, shell: false });
let stdout = "";
let stderr = "";
let killed = false;
let timeoutId: NodeJS.Timeout | undefined;
const killProcess = () => {
if (!killed) {
killed = true;
proc.kill("SIGTERM");
// Force kill after 5 seconds if SIGTERM doesn't work
setTimeout(() => {
if (!proc.killed) {
proc.kill("SIGKILL");
}
}, 5000);
}
};
// Handle abort signal
if (options?.signal) {
if (options.signal.aborted) {
killProcess();
} else {
options.signal.addEventListener("abort", killProcess, { once: true });
}
}
// Handle timeout
if (options?.timeout && options.timeout > 0) {
timeoutId = setTimeout(() => {
killProcess();
}, options.timeout);
}
proc.stdout?.on("data", (data) => {
stdout += data.toString();
});
proc.stderr?.on("data", (data) => {
stderr += data.toString();
});
proc.on("close", (code) => {
if (timeoutId) clearTimeout(timeoutId);
if (options?.signal) {
options.signal.removeEventListener("abort", killProcess);
}
resolve({ stdout, stderr, code: code ?? 0, killed });
});
proc.on("error", (_err) => {
if (timeoutId) clearTimeout(timeoutId);
if (options?.signal) {
options.signal.removeEventListener("abort", killProcess);
}
resolve({ stdout, stderr, code: 1, killed });
});
});
}
/** /**
* Create a promise that rejects after a timeout. * Create a promise that rejects after a timeout.
@ -241,12 +168,12 @@ export class HookRunner {
} }
/** /**
* Get a custom message renderer for the given customType. * Get a message renderer for the given customType.
* Returns the first renderer found across all hooks, or undefined if none. * Returns the first renderer found across all hooks, or undefined if none.
*/ */
getCustomMessageRenderer(customType: string): CustomMessageRenderer | undefined { getMessageRenderer(customType: string): HookMessageRenderer | undefined {
for (const hook of this.hooks) { for (const hook of this.hooks) {
const renderer = hook.customMessageRenderers.get(customType); const renderer = hook.messageRenderers.get(customType);
if (renderer) { if (renderer) {
return renderer; return renderer;
} }

View file

@ -10,6 +10,7 @@ import type { ImageContent, Model, TextContent, ToolResultMessage } from "@mario
import type { Component } from "@mariozechner/pi-tui"; import type { Component } from "@mariozechner/pi-tui";
import type { Theme } from "../../modes/interactive/theme/theme.js"; import type { Theme } from "../../modes/interactive/theme/theme.js";
import type { CompactionPreparation, CompactionResult } from "../compaction.js"; import type { CompactionPreparation, CompactionResult } from "../compaction.js";
import type { ExecOptions, ExecResult } from "../exec.js";
import type { ModelRegistry } from "../model-registry.js"; import type { ModelRegistry } from "../model-registry.js";
import type { CompactionEntry, CustomMessageEntry, SessionManager } from "../session-manager.js"; import type { CompactionEntry, CustomMessageEntry, SessionManager } from "../session-manager.js";
import type { EditToolDetails } from "../tools/edit.js"; import type { EditToolDetails } from "../tools/edit.js";
@ -21,29 +22,8 @@ import type {
ReadToolDetails, ReadToolDetails,
} from "../tools/index.js"; } from "../tools/index.js";
// ============================================================================ // Re-export for backward compatibility
// Execution Context export type { ExecOptions, ExecResult } from "../exec.js";
// ============================================================================
/**
* Result of executing a command via ctx.exec()
*/
export interface ExecResult {
stdout: string;
stderr: string;
code: number;
/** True if the process was killed due to signal or timeout */
killed?: boolean;
}
export interface ExecOptions {
/** AbortSignal to cancel the process */
signal?: AbortSignal;
/** Timeout in milliseconds */
timeout?: number;
/** Working directory */
cwd?: string;
}
/** /**
* UI context for hooks to request interactive UI from the harness. * UI context for hooks to request interactive UI from the harness.
@ -372,26 +352,26 @@ export type HookHandler<E, R = void> = (event: E, ctx: HookEventContext) => Prom
/** /**
* Options passed to custom message renderers. * Options passed to custom message renderers.
*/ */
export interface CustomMessageRenderOptions { /**
* Message type for hooks to send. Creates CustomMessageEntry in the session.
*/
export type HookMessage<T = unknown> = Pick<CustomMessageEntry<T>, "customType" | "content" | "display" | "details">;
export interface HookMessageRenderOptions {
/** Whether the view is expanded */ /** Whether the view is expanded */
expanded: boolean; expanded: boolean;
} }
/** /**
* Renderer for custom message entries. * Renderer for hook messages.
* Hooks register these to provide custom TUI rendering for their CustomMessageEntry types. * Hooks register these to provide custom TUI rendering for their message types.
*/ */
export type CustomMessageRenderer<T = unknown> = ( export type HookMessageRenderer<T = unknown> = (
entry: CustomMessageEntry<T>, message: HookMessage<T>,
options: CustomMessageRenderOptions, options: HookMessageRenderOptions,
theme: Theme, theme: Theme,
) => Component | null; ) => Component | null;
/**
* Message type for hooks to send. Creates CustomMessageEntry in the session.
*/
export type HookMessage<T = unknown> = Pick<CustomMessageEntry<T>, "customType" | "content" | "display" | "details">;
/** /**
* Context passed to hook command handlers. * Context passed to hook command handlers.
*/ */
@ -483,7 +463,7 @@ export interface HookAPI {
* The renderer is called when rendering the entry in the TUI. * The renderer is called when rendering the entry in the TUI.
* Return null to use the default renderer. * Return null to use the default renderer.
*/ */
registerCustomMessageRenderer<T = unknown>(customType: string, renderer: CustomMessageRenderer<T>): void; registerMessageRenderer<T = unknown>(customType: string, renderer: HookMessageRenderer<T>): void;
/** /**
* Register a custom slash command. * Register a custom slash command.

View file

@ -340,7 +340,7 @@ function createFactoryFromLoadedHook(loaded: LoadedHook): HookFactory {
function createLoadedHooksFromDefinitions(definitions: Array<{ path?: string; factory: HookFactory }>): LoadedHook[] { function createLoadedHooksFromDefinitions(definitions: Array<{ path?: string; factory: HookFactory }>): LoadedHook[] {
return definitions.map((def) => { return definitions.map((def) => {
const handlers = new Map<string, Array<(...args: unknown[]) => Promise<unknown>>>(); const handlers = new Map<string, Array<(...args: unknown[]) => Promise<unknown>>>();
const customMessageRenderers = new Map<string, any>(); const messageRenderers = new Map<string, any>();
const commands = new Map<string, any>(); const commands = new Map<string, any>();
let sendMessageHandler: (message: any, triggerTurn?: boolean) => void = () => {}; let sendMessageHandler: (message: any, triggerTurn?: boolean) => void = () => {};
let appendEntryHandler: (customType: string, data?: any) => void = () => {}; let appendEntryHandler: (customType: string, data?: any) => void = () => {};
@ -357,8 +357,8 @@ function createLoadedHooksFromDefinitions(definitions: Array<{ path?: string; fa
appendEntry: (customType: string, data?: any) => { appendEntry: (customType: string, data?: any) => {
appendEntryHandler(customType, data); appendEntryHandler(customType, data);
}, },
registerCustomMessageRenderer: (customType: string, renderer: any) => { registerMessageRenderer: (customType: string, renderer: any) => {
customMessageRenderers.set(customType, renderer); messageRenderers.set(customType, renderer);
}, },
registerCommand: (name: string, options: any) => { registerCommand: (name: string, options: any) => {
commands.set(name, { name, ...options }); commands.set(name, { name, ...options });
@ -371,7 +371,7 @@ function createLoadedHooksFromDefinitions(definitions: Array<{ path?: string; fa
path: def.path ?? "<inline>", path: def.path ?? "<inline>",
resolvedPath: def.path ?? "<inline>", resolvedPath: def.path ?? "<inline>",
handlers, handlers,
customMessageRenderers, messageRenderers,
commands, commands,
setSendMessageHandler: (handler: (message: any, triggerTurn?: boolean) => void) => { setSendMessageHandler: (handler: (message: any, triggerTurn?: boolean) => void) => {
sendMessageHandler = handler; sendMessageHandler = handler;

View file

@ -1,6 +1,6 @@
import type { TextContent } from "@mariozechner/pi-ai"; import type { TextContent } from "@mariozechner/pi-ai";
import { Box, Container, Markdown, Spacer, Text } from "@mariozechner/pi-tui"; import { Box, Container, Markdown, Spacer, Text } from "@mariozechner/pi-tui";
import type { CustomMessageRenderer } from "../../../core/hooks/types.js"; import type { HookMessage, HookMessageRenderer } from "../../../core/hooks/types.js";
import type { CustomMessageEntry } from "../../../core/session-manager.js"; import type { CustomMessageEntry } from "../../../core/session-manager.js";
import { getMarkdownTheme, theme } from "../theme/theme.js"; import { getMarkdownTheme, theme } from "../theme/theme.js";
@ -9,21 +9,49 @@ import { getMarkdownTheme, theme } from "../theme/theme.js";
* Uses distinct styling to differentiate from user messages. * Uses distinct styling to differentiate from user messages.
*/ */
export class CustomMessageComponent extends Container { export class CustomMessageComponent extends Container {
constructor(entry: CustomMessageEntry, customRenderer?: CustomMessageRenderer) { private entry: CustomMessageEntry;
private customRenderer?: HookMessageRenderer;
private box: Box;
private _expanded = false;
constructor(entry: CustomMessageEntry, customRenderer?: HookMessageRenderer) {
super(); super();
this.entry = entry;
this.customRenderer = customRenderer;
this.addChild(new Spacer(1)); this.addChild(new Spacer(1));
// Create box with purple background // Create box with purple background
const box = new Box(1, 1, (t) => theme.bg("customMessageBg", t)); this.box = new Box(1, 1, (t) => theme.bg("customMessageBg", t));
this.addChild(this.box);
this.rebuild();
}
setExpanded(expanded: boolean): void {
if (this._expanded !== expanded) {
this._expanded = expanded;
this.rebuild();
}
}
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 // Try custom renderer first
if (customRenderer) { if (this.customRenderer) {
try { try {
const component = customRenderer(entry, { expanded: false }, theme); const component = this.customRenderer(message, { expanded: this._expanded }, theme);
if (component) { if (component) {
box.addChild(component); this.box.addChild(component);
this.addChild(box);
return; return;
} }
} catch { } catch {
@ -32,27 +60,25 @@ export class CustomMessageComponent extends Container {
} }
// Default rendering: label + content // Default rendering: label + content
const label = theme.fg("customMessageLabel", `\x1b[1m[${entry.customType}]\x1b[22m`); const label = theme.fg("customMessageLabel", `\x1b[1m[${this.entry.customType}]\x1b[22m`);
box.addChild(new Text(label, 0, 0)); this.box.addChild(new Text(label, 0, 0));
box.addChild(new Spacer(1)); this.box.addChild(new Spacer(1));
// Extract text content // Extract text content
let text: string; let text: string;
if (typeof entry.content === "string") { if (typeof this.entry.content === "string") {
text = entry.content; text = this.entry.content;
} else { } else {
text = entry.content text = this.entry.content
.filter((c): c is TextContent => c.type === "text") .filter((c): c is TextContent => c.type === "text")
.map((c) => c.text) .map((c) => c.text)
.join("\n"); .join("\n");
} }
box.addChild( this.box.addChild(
new Markdown(text, 0, 0, getMarkdownTheme(), { new Markdown(text, 0, 0, getMarkdownTheme(), {
color: (text: string) => theme.fg("customMessageText", text), color: (text: string) => theme.fg("customMessageText", text),
}), }),
); );
this.addChild(box);
} }
} }

View file

@ -1028,7 +1028,7 @@ export class InteractiveMode {
parentId: null, parentId: null,
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
}; };
const renderer = this.session.hookRunner?.getCustomMessageRenderer(message.customType); const renderer = this.session.hookRunner?.getMessageRenderer(message.customType);
this.chatContainer.addChild(new CustomMessageComponent(entry, renderer)); this.chatContainer.addChild(new CustomMessageComponent(entry, renderer));
} }
} else if (message.role === "user") { } else if (message.role === "user") {
@ -1077,7 +1077,7 @@ export class InteractiveMode {
// Check if this is a custom_message entry // Check if this is a custom_message entry
if (entry?.type === "custom_message") { if (entry?.type === "custom_message") {
if (entry.display) { if (entry.display) {
const renderer = this.session.hookRunner?.getCustomMessageRenderer(entry.customType); const renderer = this.session.hookRunner?.getMessageRenderer(entry.customType);
this.chatContainer.addChild(new CustomMessageComponent(entry, renderer)); this.chatContainer.addChild(new CustomMessageComponent(entry, renderer));
} }
continue; continue;
@ -1271,6 +1271,8 @@ export class InteractiveMode {
child.setExpanded(this.toolOutputExpanded); child.setExpanded(this.toolOutputExpanded);
} else if (child instanceof BashExecutionComponent) { } else if (child instanceof BashExecutionComponent) {
child.setExpanded(this.toolOutputExpanded); child.setExpanded(this.toolOutputExpanded);
} else if (child instanceof CustomMessageComponent) {
child.setExpanded(this.toolOutputExpanded);
} }
} }
this.ui.requestRender(); this.ui.requestRender();

View file

@ -63,7 +63,7 @@ describe.skipIf(!API_KEY)("Compaction hooks", () => {
path: "test-hook", path: "test-hook",
resolvedPath: "/test/test-hook.ts", resolvedPath: "/test/test-hook.ts",
handlers, handlers,
customMessageRenderers: new Map(), messageRenderers: new Map(),
commands: new Map(), commands: new Map(),
setSendMessageHandler: () => {}, setSendMessageHandler: () => {},
setAppendEntryHandler: () => {}, setAppendEntryHandler: () => {},
@ -240,7 +240,7 @@ describe.skipIf(!API_KEY)("Compaction hooks", () => {
], ],
], ],
]), ]),
customMessageRenderers: new Map(), messageRenderers: new Map(),
commands: new Map(), commands: new Map(),
setSendMessageHandler: () => {}, setSendMessageHandler: () => {},
setAppendEntryHandler: () => {}, setAppendEntryHandler: () => {},
@ -286,7 +286,7 @@ describe.skipIf(!API_KEY)("Compaction hooks", () => {
], ],
], ],
]), ]),
customMessageRenderers: new Map(), messageRenderers: new Map(),
commands: new Map(), commands: new Map(),
setSendMessageHandler: () => {}, setSendMessageHandler: () => {},
setAppendEntryHandler: () => {}, setAppendEntryHandler: () => {},
@ -311,7 +311,7 @@ describe.skipIf(!API_KEY)("Compaction hooks", () => {
], ],
], ],
]), ]),
customMessageRenderers: new Map(), messageRenderers: new Map(),
commands: new Map(), commands: new Map(),
setSendMessageHandler: () => {}, setSendMessageHandler: () => {},
setAppendEntryHandler: () => {}, setAppendEntryHandler: () => {},