Merge main, resolve CHANGELOG conflict

This commit is contained in:
Mario Zechner 2026-01-01 00:10:46 +01:00
commit c15efdbcd9
41 changed files with 515 additions and 184 deletions

View file

@ -90,7 +90,9 @@ function createNoOpUIContext(): HookUIContext {
confirm: async () => false,
input: async () => undefined,
notify: () => {},
custom: () => ({ close: () => {}, requestRender: () => {} }),
custom: async () => undefined as never,
setEditorText: () => {},
getEditorText: () => "",
};
}

View file

@ -39,7 +39,9 @@ const noOpUIContext: HookUIContext = {
confirm: async () => false,
input: async () => undefined,
notify: () => {},
custom: () => ({ close: () => {}, requestRender: () => {} }),
custom: async () => undefined as never,
setEditorText: () => {},
getEditorText: () => "",
};
/**

View file

@ -46,30 +46,46 @@ export function wrapToolWithHooks<T>(tool: AgentTool<any, T>, hookRunner: HookRu
}
// Execute the actual tool, forwarding onUpdate for progress streaming
const result = await tool.execute(toolCallId, params, signal, onUpdate);
try {
const result = await tool.execute(toolCallId, params, signal, onUpdate);
// Emit tool_result event - hooks can modify the result
if (hookRunner.hasHandlers("tool_result")) {
const resultResult = (await hookRunner.emit({
type: "tool_result",
toolName: tool.name,
toolCallId,
input: params,
content: result.content,
details: result.details,
isError: false,
})) as ToolResultEventResult | undefined;
// Emit tool_result event - hooks can modify the result
if (hookRunner.hasHandlers("tool_result")) {
const resultResult = (await hookRunner.emit({
type: "tool_result",
toolName: tool.name,
toolCallId,
input: params,
content: result.content,
details: result.details,
isError: false,
})) as ToolResultEventResult | undefined;
// Apply modifications if any
if (resultResult) {
return {
content: resultResult.content ?? result.content,
details: (resultResult.details ?? result.details) as T,
};
// Apply modifications if any
if (resultResult) {
return {
content: resultResult.content ?? result.content,
details: (resultResult.details ?? result.details) as T,
};
}
}
}
return result;
return result;
} catch (err) {
// Emit tool_result event for errors so hooks can observe failures
if (hookRunner.hasHandlers("tool_result")) {
await hookRunner.emit({
type: "tool_result",
toolName: tool.name,
toolCallId,
input: params,
content: [{ type: "text", text: err instanceof Error ? err.message : String(err) }],
details: undefined,
isError: true,
});
}
throw err; // Re-throw original error for agent-loop
}
},
};
}

View file

@ -7,7 +7,7 @@
import type { AgentMessage } from "@mariozechner/pi-agent-core";
import type { ImageContent, Message, Model, TextContent, ToolResultMessage } from "@mariozechner/pi-ai";
import type { Component } from "@mariozechner/pi-tui";
import type { Component, TUI } from "@mariozechner/pi-tui";
import type { Theme } from "../../modes/interactive/theme/theme.js";
import type { CompactionPreparation, CompactionResult } from "../compaction/index.js";
import type { ExecOptions, ExecResult } from "../exec.js";
@ -59,12 +59,48 @@ export interface HookUIContext {
/**
* Show a custom component with keyboard focus.
* The component receives keyboard input via handleInput() if implemented.
* The factory receives TUI, theme, and a done() callback to close the component.
* Can be async for fire-and-forget work (don't await the work, just start it).
*
* @param component - Component to display (implement handleInput for keyboard, dispose for cleanup)
* @returns Object with close() to restore normal UI and requestRender() to trigger redraw
* @param factory - Function that creates the component. Call done() when finished.
* @returns Promise that resolves with the value passed to done()
*
* @example
* // Sync factory
* const result = await ctx.ui.custom((tui, theme, done) => {
* const component = new MyComponent(tui, theme);
* component.onFinish = (value) => done(value);
* return component;
* });
*
* // Async factory with fire-and-forget work
* const result = await ctx.ui.custom(async (tui, theme, done) => {
* const loader = new CancellableLoader(tui, theme.fg("accent"), theme.fg("muted"), "Working...");
* loader.onAbort = () => done(null);
* doWork(loader.signal).then(done); // Don't await - fire and forget
* return loader;
* });
*/
custom(component: Component & { dispose?(): void }): { close: () => void; requestRender: () => void };
custom<T>(
factory: (
tui: TUI,
theme: Theme,
done: (result: T) => void,
) => (Component & { dispose?(): void }) | Promise<Component & { dispose?(): void }>,
): Promise<T>;
/**
* Set the text in the core input editor.
* Use this to pre-fill the input box with generated content (e.g., prompt templates, extracted questions).
* @param text - Text to set in the editor
*/
setEditorText(text: string): void;
/**
* Get the current text from the core input editor.
* @returns Current editor text
*/
getEditorText(): string;
}
/**

View file

@ -140,7 +140,7 @@ export interface CreateAgentSessionResult {
// Re-exports
export type { CustomTool } from "./custom-tools/types.js";
export type { HookAPI, HookFactory } from "./hooks/types.js";
export type { HookAPI, HookContext, HookFactory } from "./hooks/types.js";
export type { Settings, SkillsSettings } from "./settings-manager.js";
export type { Skill } from "./skills.js";
export type { FileSlashCommand } from "./slash-commands.js";

View file

@ -88,6 +88,10 @@ export {
discoverSkills,
discoverSlashCommands,
type FileSlashCommand,
// Hook types
type HookAPI,
type HookContext,
type HookFactory,
loadSettings,
// Pre-built tools (use process.cwd())
readOnlyTools,
@ -150,5 +154,7 @@ export {
} from "./core/tools/index.js";
// Main entry point
export { main } from "./main.js";
// UI components for hooks
export { BorderedLoader } from "./modes/interactive/components/bordered-loader.js";
// Theme utilities for custom tools
export { getMarkdownTheme } from "./modes/interactive/theme/theme.js";

View file

@ -0,0 +1,41 @@
import { CancellableLoader, Container, Spacer, Text, type TUI } from "@mariozechner/pi-tui";
import type { Theme } from "../theme/theme.js";
import { DynamicBorder } from "./dynamic-border.js";
/** Loader wrapped with borders for hook UI */
export class BorderedLoader extends Container {
private loader: CancellableLoader;
constructor(tui: TUI, theme: Theme, message: string) {
super();
this.addChild(new DynamicBorder());
this.addChild(new Spacer(1));
this.loader = new CancellableLoader(
tui,
(s) => theme.fg("accent", s),
(s) => theme.fg("muted", s),
message,
);
this.addChild(this.loader);
this.addChild(new Spacer(1));
this.addChild(new Text(theme.fg("muted", "esc cancel"), 1, 0));
this.addChild(new Spacer(1));
this.addChild(new DynamicBorder());
}
get signal(): AbortSignal {
return this.loader.signal;
}
set onAbort(fn: (() => void) | undefined) {
this.loader.onAbort = fn;
}
handleInput(data: string): void {
this.loader.handleInput(data);
}
dispose(): void {
this.loader.dispose();
}
}

View file

@ -54,7 +54,15 @@ import { ToolExecutionComponent } from "./components/tool-execution.js";
import { TreeSelectorComponent } from "./components/tree-selector.js";
import { UserMessageComponent } from "./components/user-message.js";
import { UserMessageSelectorComponent } from "./components/user-message-selector.js";
import { getAvailableThemes, getEditorTheme, getMarkdownTheme, onThemeChange, setTheme, theme } from "./theme/theme.js";
import {
getAvailableThemes,
getEditorTheme,
getMarkdownTheme,
onThemeChange,
setTheme,
type Theme,
theme,
} from "./theme/theme.js";
/** Interface for components that can be expanded/collapsed */
interface Expandable {
@ -356,7 +364,9 @@ export class InteractiveMode {
confirm: (title, message) => this.showHookConfirm(title, message),
input: (title, placeholder) => this.showHookInput(title, placeholder),
notify: (message, type) => this.showHookNotify(message, type),
custom: (component) => this.showHookCustom(component),
custom: (factory) => this.showHookCustom(factory),
setEditorText: (text) => this.editor.setText(text),
getEditorText: () => this.editor.getText(),
};
this.setToolUIContext(uiContext, true);
@ -537,38 +547,37 @@ export class InteractiveMode {
/**
* Show a custom component with keyboard focus.
* Returns a function to call when done.
*/
private showHookCustom(component: Component & { dispose?(): void }): {
close: () => void;
requestRender: () => void;
} {
// Store current editor content
private async showHookCustom<T>(
factory: (
tui: TUI,
theme: Theme,
done: (result: T) => void,
) => (Component & { dispose?(): void }) | Promise<Component & { dispose?(): void }>,
): Promise<T> {
const savedText = this.editor.getText();
// Replace editor with custom component
this.editorContainer.clear();
this.editorContainer.addChild(component);
this.ui.setFocus(component);
this.ui.requestRender();
return new Promise((resolve) => {
let component: Component & { dispose?(): void };
// Return control object
return {
close: () => {
// Call dispose if available
const close = (result: T) => {
component.dispose?.();
// Restore editor
this.editorContainer.clear();
this.editorContainer.addChild(this.editor);
this.editor.setText(savedText);
this.ui.setFocus(this.editor);
this.ui.requestRender();
},
requestRender: () => {
resolve(result);
};
Promise.resolve(factory(this.ui, theme, close)).then((c) => {
component = c;
this.editorContainer.clear();
this.editorContainer.addChild(component);
this.ui.setFocus(component);
this.ui.requestRender();
},
};
});
});
}
/**

View file

@ -119,9 +119,25 @@ export async function runRpcMode(session: AgentSession): Promise<never> {
} as RpcHookUIRequest);
},
custom() {
async custom() {
// Custom UI not supported in RPC mode
return { close: () => {}, requestRender: () => {} };
return undefined as never;
},
setEditorText(text: string): void {
// Fire and forget - host can implement editor control
output({
type: "hook_ui_request",
id: crypto.randomUUID(),
method: "set_editor_text",
text,
} as RpcHookUIRequest);
},
getEditorText(): string {
// Synchronous method can't wait for RPC response
// Host should track editor state locally if needed
return "";
},
});

View file

@ -181,7 +181,8 @@ export type RpcHookUIRequest =
method: "notify";
message: string;
notifyType?: "info" | "warning" | "error";
};
}
| { type: "hook_ui_request"; id: string; method: "set_editor_text"; text: string };
// ============================================================================
// Hook UI Commands (stdin)