mirror of
https://github.com/getcompanion-ai/co-mono.git
synced 2026-04-19 22:01:38 +00:00
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:
parent
5fee9005b7
commit
a8866d7a83
12 changed files with 199 additions and 261 deletions
|
|
@ -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`
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
104
packages/coding-agent/src/core/exec.ts
Normal file
104
packages/coding-agent/src/core/exec.ts
Normal 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 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -13,8 +13,8 @@ export type {
|
||||||
AgentEndEvent,
|
AgentEndEvent,
|
||||||
AgentStartEvent,
|
AgentStartEvent,
|
||||||
BashToolResultEvent,
|
BashToolResultEvent,
|
||||||
CustomMessageRenderer,
|
HookMessageRenderer,
|
||||||
CustomMessageRenderOptions,
|
HookMessageRenderOptions,
|
||||||
CustomToolResultEvent,
|
CustomToolResultEvent,
|
||||||
EditToolResultEvent,
|
EditToolResultEvent,
|
||||||
ExecOptions,
|
ExecOptions,
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -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: () => {},
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue