Custom tools with session lifecycle, examples for hooks and tools

- Custom tools: TypeScript modules that extend pi with new tools
  - Custom TUI rendering via renderCall/renderResult
  - User interaction via pi.ui (select, confirm, input, notify)
  - Session lifecycle via onSession callback for state reconstruction
  - Examples: todo.ts, question.ts, hello.ts

- Hook examples: permission-gate, git-checkpoint, protected-paths

- Session lifecycle centralized in AgentSession
  - Works across all modes (interactive, print, RPC)
  - Unified session event for hooks (replaces session_start/session_switch)

- Box component added to pi-tui

- Examples bundled in npm and binary releases

Fixes #190
This commit is contained in:
Mario Zechner 2025-12-17 16:03:23 +01:00
parent 295f51b53f
commit e7097d911a
33 changed files with 1926 additions and 117 deletions

View file

@ -1,5 +1,6 @@
import * as os from "node:os";
import {
Box,
Container,
getCapabilities,
getImageDimensions,
@ -9,6 +10,7 @@ import {
Text,
} from "@mariozechner/pi-tui";
import stripAnsi from "strip-ansi";
import type { CustomAgentTool } from "../../../core/custom-tools/types.js";
import { DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, formatSize } from "../../../core/tools/truncate.js";
import { theme } from "../theme/theme.js";
@ -38,27 +40,37 @@ export interface ToolExecutionOptions {
* Component that renders a tool call with its result (updateable)
*/
export class ToolExecutionComponent extends Container {
private contentText: Text;
private contentBox: Box;
private contentText: Text; // For built-in tools
private imageComponents: Image[] = [];
private toolName: string;
private args: any;
private expanded = false;
private showImages: boolean;
private isPartial = true;
private customTool?: CustomAgentTool;
private result?: {
content: Array<{ type: string; text?: string; data?: string; mimeType?: string }>;
isError: boolean;
details?: any;
};
constructor(toolName: string, args: any, options: ToolExecutionOptions = {}) {
constructor(toolName: string, args: any, options: ToolExecutionOptions = {}, customTool?: CustomAgentTool) {
super();
this.toolName = toolName;
this.args = args;
this.showImages = options.showImages ?? true;
this.customTool = customTool;
this.addChild(new Spacer(1));
this.contentText = new Text("", 1, 1, (text: string) => theme.bg("toolPendingBg", text));
this.addChild(this.contentText);
// Box wraps content with padding and background
this.contentBox = new Box(1, 1, (text: string) => theme.bg("toolPendingBg", text));
this.addChild(this.contentBox);
// Text component for built-in tool rendering
this.contentText = new Text("", 0, 0);
this.updateDisplay();
}
@ -91,15 +103,66 @@ export class ToolExecutionComponent extends Container {
}
private updateDisplay(): void {
// Set background based on state
const bgFn = this.isPartial
? (text: string) => theme.bg("toolPendingBg", text)
: this.result?.isError
? (text: string) => theme.bg("toolErrorBg", text)
: (text: string) => theme.bg("toolSuccessBg", text);
this.contentText.setCustomBgFn(bgFn);
this.contentText.setText(this.formatToolExecution());
this.contentBox.setBgFn(bgFn);
this.contentBox.clear();
// Check for custom tool rendering
if (this.customTool) {
// Render call component
if (this.customTool.renderCall) {
try {
const callComponent = this.customTool.renderCall(this.args, theme);
if (callComponent) {
this.contentBox.addChild(callComponent);
}
} catch {
// Fall back to default on error
this.contentBox.addChild(new Text(theme.fg("toolTitle", theme.bold(this.toolName)), 0, 0));
}
} else {
// No custom renderCall, show tool name
this.contentBox.addChild(new Text(theme.fg("toolTitle", theme.bold(this.toolName)), 0, 0));
}
// Render result component if we have a result
if (this.result && this.customTool.renderResult) {
try {
const resultComponent = this.customTool.renderResult(
{ content: this.result.content as any, details: this.result.details },
{ expanded: this.expanded, isPartial: this.isPartial },
theme,
);
if (resultComponent) {
this.contentBox.addChild(resultComponent);
}
} catch {
// Fall back to showing raw output on error
const output = this.getTextOutput();
if (output) {
this.contentBox.addChild(new Text(theme.fg("toolOutput", output), 0, 0));
}
}
} else if (this.result) {
// Has result but no custom renderResult
const output = this.getTextOutput();
if (output) {
this.contentBox.addChild(new Text(theme.fg("toolOutput", output), 0, 0));
}
}
} else {
// Built-in tool: use existing formatToolExecution
this.contentText.setText(this.formatToolExecution());
this.contentBox.addChild(this.contentText);
}
// Handle images (same for both custom and built-in)
for (const img of this.imageComponents) {
this.removeChild(img);
}
@ -110,7 +173,6 @@ export class ToolExecutionComponent extends Container {
const caps = getCapabilities();
for (const img of imageBlocks) {
// Show inline image only if terminal supports it AND user setting allows it
if (caps.images && this.showImages && img.data && img.mimeType) {
this.addChild(new Spacer(1));
const imageComponent = new Image(
@ -142,7 +204,6 @@ export class ToolExecutionComponent extends Container {
.join("\n");
const caps = getCapabilities();
// Show text fallback if terminal doesn't support images OR if user disabled inline images
if (imageBlocks.length > 0 && (!caps.images || !this.showImages)) {
const imageIndicators = imageBlocks
.map((img: any) => {
@ -159,7 +220,6 @@ export class ToolExecutionComponent extends Container {
private formatToolExecution(): string {
let text = "";
// Format based on tool type
if (this.toolName === "bash") {
const command = this.args?.command || "";
text = theme.fg("toolTitle", theme.bold(`$ ${command || theme.fg("toolOutput", "...")}`));
@ -180,7 +240,6 @@ export class ToolExecutionComponent extends Container {
displayLines.map((line: string) => theme.fg("toolOutput", line)).join("\n");
}
// Show truncation warning at the bottom (outside collapsed area)
const truncation = this.result.details?.truncation;
const fullOutputPath = this.result.details?.fullOutputPath;
if (truncation?.truncated || fullOutputPath) {
@ -205,7 +264,6 @@ export class ToolExecutionComponent extends Container {
const offset = this.args?.offset;
const limit = this.args?.limit;
// Build path display with offset/limit suffix (in warning color if offset/limit used)
let pathDisplay = path ? theme.fg("accent", path) : theme.fg("toolOutput", "...");
if (offset !== undefined || limit !== undefined) {
const startLine = offset ?? 1;
@ -228,7 +286,6 @@ export class ToolExecutionComponent extends Container {
text += theme.fg("toolOutput", `\n... (${remaining} more lines)`);
}
// Show truncation warning at the bottom (outside collapsed area)
const truncation = this.result.details?.truncation;
if (truncation?.truncated) {
if (truncation.firstLineExceedsLimit) {
@ -269,7 +326,6 @@ export class ToolExecutionComponent extends Container {
text += ` (${totalLines} lines)`;
}
// Show first 10 lines of content if available
if (fileContent) {
const maxLines = this.expanded ? lines.length : 10;
const displayLines = lines.slice(0, maxLines);
@ -288,14 +344,12 @@ export class ToolExecutionComponent extends Container {
(path ? theme.fg("accent", path) : theme.fg("toolOutput", "..."));
if (this.result) {
// Show error message if it's an error
if (this.result.isError) {
const errorText = this.getTextOutput();
if (errorText) {
text += "\n\n" + theme.fg("error", errorText);
}
} else if (this.result.details?.diff) {
// Show diff if available
const diffLines = this.result.details.diff.split("\n");
const coloredLines = diffLines.map((line: string) => {
if (line.startsWith("+")) {
@ -332,7 +386,6 @@ export class ToolExecutionComponent extends Container {
}
}
// Show truncation warning at the bottom (outside collapsed area)
const entryLimit = this.result.details?.entryLimitReached;
const truncation = this.result.details?.truncation;
if (entryLimit || truncation?.truncated) {
@ -374,7 +427,6 @@ export class ToolExecutionComponent extends Container {
}
}
// Show truncation warning at the bottom (outside collapsed area)
const resultLimit = this.result.details?.resultLimitReached;
const truncation = this.result.details?.truncation;
if (resultLimit || truncation?.truncated) {
@ -420,7 +472,6 @@ export class ToolExecutionComponent extends Container {
}
}
// Show truncation warning at the bottom (outside collapsed area)
const matchLimit = this.result.details?.matchLimitReached;
const truncation = this.result.details?.truncation;
const linesTruncated = this.result.details?.linesTruncated;
@ -439,7 +490,7 @@ export class ToolExecutionComponent extends Container {
}
}
} else {
// Generic tool
// Generic tool (shouldn't reach here for custom tools)
text = theme.fg("toolTitle", theme.bold(this.toolName));
const content = JSON.stringify(this.args, null, 2);

View file

@ -26,6 +26,7 @@ import {
import { exec } from "child_process";
import { APP_NAME, getDebugLogPath, getOAuthPath } 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 } from "../../core/messages.js";
import { invalidateOAuthCache } from "../../core/model-config.js";
@ -113,6 +114,9 @@ export class InteractiveMode {
private hookSelector: HookSelectorComponent | null = null;
private hookInput: HookInputComponent | null = null;
// Custom tools for custom rendering
private customTools: Map<string, LoadedCustomTool>;
// Convenience accessors
private get agent() {
return this.session.agent;
@ -128,11 +132,14 @@ export class InteractiveMode {
session: AgentSession,
version: string,
changelogMarkdown: string | null = null,
customTools: LoadedCustomTool[] = [],
private setToolUIContext: (uiContext: HookUIContext, hasUI: boolean) => void = () => {},
fdPath: string | null = null,
) {
this.session = session;
this.version = version;
this.changelogMarkdown = changelogMarkdown;
this.customTools = new Map(customTools.map((ct) => [ct.tool.name, ct]));
this.ui = new TUI(new ProcessTerminal());
this.chatContainer = new Container();
this.pendingMessagesContainer = new Container();
@ -263,7 +270,7 @@ export class InteractiveMode {
this.isInitialized = true;
// Initialize hooks with TUI-based UI context
await this.initHooks();
await this.initHooksAndCustomTools();
// Subscribe to agent events
this.subscribeToAgent();
@ -288,7 +295,7 @@ export class InteractiveMode {
/**
* Initialize the hook system with TUI-based UI context.
*/
private async initHooks(): Promise<void> {
private async initHooksAndCustomTools(): Promise<void> {
// Show loaded project context files
const contextFiles = loadProjectContextFiles();
if (contextFiles.length > 0) {
@ -305,13 +312,37 @@ export class InteractiveMode {
this.chatContainer.addChild(new Spacer(1));
}
// Show loaded custom tools
if (this.customTools.size > 0) {
const toolList = Array.from(this.customTools.values())
.map((ct) => theme.fg("dim", ` ${ct.tool.name} (${ct.path})`))
.join("\n");
this.chatContainer.addChild(new Text(theme.fg("muted", "Loaded custom tools:\n") + toolList, 0, 0));
this.chatContainer.addChild(new Spacer(1));
}
// Load session entries if any
const entries = this.session.sessionManager.loadEntries();
// Set TUI-based UI context for custom tools
const uiContext = this.createHookUIContext();
this.setToolUIContext(uiContext, true);
// Notify custom tools of session start
await this.emitToolSessionEvent({
entries,
sessionFile: this.session.sessionFile,
previousSessionFile: null,
reason: "start",
});
const hookRunner = this.session.hookRunner;
if (!hookRunner) {
return; // No hooks loaded
}
// Set TUI-based UI context on the hook runner
hookRunner.setUIContext(this.createHookUIContext(), true);
// Set UI context on hook runner
hookRunner.setUIContext(uiContext, true);
hookRunner.setSessionFile(this.session.sessionFile);
// Subscribe to hook errors
@ -332,8 +363,38 @@ export class InteractiveMode {
this.chatContainer.addChild(new Spacer(1));
}
// Emit session_start event
await hookRunner.emit({ type: "session_start" });
// Emit session event
await hookRunner.emit({
type: "session",
entries,
sessionFile: this.session.sessionFile,
previousSessionFile: null,
reason: "start",
});
}
/**
* Emit session event to all custom tools.
*/
private async emitToolSessionEvent(event: ToolSessionEvent): Promise<void> {
for (const { tool } of this.customTools.values()) {
if (tool.onSession) {
try {
await tool.onSession(event);
} catch (err) {
this.showToolError(tool.name, err instanceof Error ? err.message : String(err));
}
}
}
}
/**
* Show a tool error in the chat.
*/
private showToolError(toolName: string, error: string): void {
const errorText = new Text(theme.fg("error", `Tool "${toolName}" error: ${error}`), 1, 0);
this.chatContainer.addChild(errorText);
this.ui.requestRender();
}
/**
@ -708,9 +769,14 @@ export class InteractiveMode {
if (content.type === "toolCall") {
if (!this.pendingTools.has(content.id)) {
this.chatContainer.addChild(new Text("", 0, 0));
const component = new ToolExecutionComponent(content.name, content.arguments, {
showImages: this.settingsManager.getShowImages(),
});
const component = new ToolExecutionComponent(
content.name,
content.arguments,
{
showImages: this.settingsManager.getShowImages(),
},
this.customTools.get(content.name)?.tool,
);
this.chatContainer.addChild(component);
this.pendingTools.set(content.id, component);
} else {
@ -750,9 +816,14 @@ export class InteractiveMode {
case "tool_execution_start": {
if (!this.pendingTools.has(event.toolCallId)) {
const component = new ToolExecutionComponent(event.toolName, event.args, {
showImages: this.settingsManager.getShowImages(),
});
const component = new ToolExecutionComponent(
event.toolName,
event.args,
{
showImages: this.settingsManager.getShowImages(),
},
this.customTools.get(event.toolName)?.tool,
);
this.chatContainer.addChild(component);
this.pendingTools.set(event.toolCallId, component);
this.ui.requestRender();
@ -984,9 +1055,14 @@ export class InteractiveMode {
for (const content of assistantMsg.content) {
if (content.type === "toolCall") {
const component = new ToolExecutionComponent(content.name, content.arguments, {
showImages: this.settingsManager.getShowImages(),
});
const component = new ToolExecutionComponent(
content.name,
content.arguments,
{
showImages: this.settingsManager.getShowImages(),
},
this.customTools.get(content.name)?.tool,
);
this.chatContainer.addChild(component);
if (assistantMsg.stopReason === "aborted" || assistantMsg.stopReason === "error") {
@ -1307,6 +1383,7 @@ export class InteractiveMode {
this.ui.requestRender();
return;
}
this.chatContainer.clear();
this.isFirstUserMessage = true;
this.renderInitialMessages(this.session.state);
@ -1353,7 +1430,7 @@ export class InteractiveMode {
this.streamingComponent = null;
this.pendingTools.clear();
// Switch session via AgentSession
// Switch session via AgentSession (emits hook and tool session events)
await this.session.switchSession(sessionPath);
// Clear and re-render the chat
@ -1560,7 +1637,7 @@ export class InteractiveMode {
}
this.statusContainer.clear();
// Reset via session
// Reset via session (emits hook and tool session events)
await this.session.reset();
// Clear UI state

View file

@ -27,6 +27,9 @@ export async function runPrintMode(
initialMessage?: string,
initialAttachments?: Attachment[],
): Promise<void> {
// Load entries once for session start events
const entries = session.sessionManager.loadEntries();
// Hook runner already has no-op UI context by default (set in main.ts)
// Set up hooks for print mode (no UI)
const hookRunner = session.hookRunner;
@ -40,8 +43,30 @@ export async function runPrintMode(
hookRunner.setSendHandler(() => {
console.error("Warning: pi.send() is not supported in print mode");
});
// Emit session_start event
await hookRunner.emit({ type: "session_start" });
// Emit session event
await hookRunner.emit({
type: "session",
entries,
sessionFile: session.sessionFile,
previousSessionFile: null,
reason: "start",
});
}
// Emit session start event to custom tools (no UI in print mode)
for (const { tool } of session.customTools) {
if (tool.onSession) {
try {
await tool.onSession({
entries,
sessionFile: session.sessionFile,
previousSessionFile: null,
reason: "start",
});
} catch (_err) {
// Silently ignore tool errors
}
}
}
// Always subscribe to enable session persistence via _handleAgentEvent

View file

@ -120,6 +120,9 @@ export async function runRpcMode(session: AgentSession): Promise<never> {
},
});
// Load entries once for session start events
const entries = session.sessionManager.loadEntries();
// Set up hooks with RPC-based UI context
const hookRunner = session.hookRunner;
if (hookRunner) {
@ -139,8 +142,31 @@ export async function runRpcMode(session: AgentSession): Promise<never> {
});
}
});
// Emit session_start event
await hookRunner.emit({ type: "session_start" });
// Emit session event
await hookRunner.emit({
type: "session",
entries,
sessionFile: session.sessionFile,
previousSessionFile: null,
reason: "start",
});
}
// Emit session start event to custom tools
// Note: Tools get no-op UI context in RPC mode (host handles UI via protocol)
for (const { tool } of session.customTools) {
if (tool.onSession) {
try {
await tool.onSession({
entries,
sessionFile: session.sessionFile,
previousSessionFile: null,
reason: "start",
});
} catch (_err) {
// Silently ignore tool errors
}
}
}
// Output all agent events as JSON