mirror of
https://github.com/getcompanion-ai/co-mono.git
synced 2026-04-16 15:02:32 +00:00
Merge hooks and custom-tools into unified extensions system (#454)
Breaking changes: - Settings: 'hooks' and 'customTools' arrays replaced with 'extensions' - CLI: '--hook' and '--tool' flags replaced with '--extension' / '-e' - API: HookMessage renamed to CustomMessage, role 'hookMessage' to 'custom' - API: FileSlashCommand renamed to PromptTemplate - API: discoverSlashCommands() renamed to discoverPromptTemplates() - Directories: commands/ renamed to prompts/ for prompt templates Migration: - Session version bumped to 3 (auto-migrates v2 sessions) - Old 'hookMessage' role entries converted to 'custom' Structural changes: - src/core/hooks/ and src/core/custom-tools/ merged into src/core/extensions/ - src/core/slash-commands.ts renamed to src/core/prompt-templates.ts - examples/hooks/ and examples/custom-tools/ merged into examples/extensions/ - docs/hooks.md and docs/custom-tools.md merged into docs/extensions.md New test coverage: - test/extensions-runner.test.ts (10 tests) - test/extensions-discovery.test.ts (26 tests) - test/prompt-templates.test.ts
This commit is contained in:
parent
9794868b38
commit
c6fc084534
112 changed files with 2842 additions and 6747 deletions
|
|
@ -2,7 +2,7 @@ import { CancellableLoader, Container, Spacer, Text, type TUI } from "@mariozech
|
|||
import type { Theme } from "../theme/theme.js";
|
||||
import { DynamicBorder } from "./dynamic-border.js";
|
||||
|
||||
/** Loader wrapped with borders for hook UI */
|
||||
/** Loader wrapped with borders for extension UI */
|
||||
export class BorderedLoader extends Container {
|
||||
private loader: CancellableLoader;
|
||||
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import { getMarkdownTheme, theme } from "../theme/theme.js";
|
|||
|
||||
/**
|
||||
* Component that renders a branch summary message with collapsed/expanded state.
|
||||
* Uses same background color as hook messages for visual consistency.
|
||||
* Uses same background color as custom messages for visual consistency.
|
||||
*/
|
||||
export class BranchSummaryMessageComponent extends Box {
|
||||
private expanded = false;
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import { getMarkdownTheme, theme } from "../theme/theme.js";
|
|||
|
||||
/**
|
||||
* Component that renders a compaction message with collapsed/expanded state.
|
||||
* Uses same background color as hook messages for visual consistency.
|
||||
* Uses same background color as custom messages for visual consistency.
|
||||
*/
|
||||
export class CompactionSummaryMessageComponent extends Box {
|
||||
private expanded = false;
|
||||
|
|
|
|||
|
|
@ -12,8 +12,8 @@ export class CustomEditor extends Editor {
|
|||
public onEscape?: () => void;
|
||||
public onCtrlD?: () => void;
|
||||
public onPasteImage?: () => void;
|
||||
/** Handler for hook-registered shortcuts. Returns true if handled. */
|
||||
public onHookShortcut?: (data: string) => boolean;
|
||||
/** Handler for extension-registered shortcuts. Returns true if handled. */
|
||||
public onExtensionShortcut?: (data: string) => boolean;
|
||||
|
||||
constructor(theme: EditorTheme, keybindings: KeybindingsManager) {
|
||||
super(theme);
|
||||
|
|
@ -28,8 +28,8 @@ export class CustomEditor extends Editor {
|
|||
}
|
||||
|
||||
handleInput(data: string): void {
|
||||
// Check hook-registered shortcuts first
|
||||
if (this.onHookShortcut?.(data)) {
|
||||
// Check extension-registered shortcuts first
|
||||
if (this.onExtensionShortcut?.(data)) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,22 +1,22 @@
|
|||
import type { TextContent } from "@mariozechner/pi-ai";
|
||||
import type { Component } from "@mariozechner/pi-tui";
|
||||
import { Box, Container, Markdown, Spacer, Text } from "@mariozechner/pi-tui";
|
||||
import type { HookMessageRenderer } from "../../../core/hooks/types.js";
|
||||
import type { HookMessage } from "../../../core/messages.js";
|
||||
import type { MessageRenderer } from "../../../core/extensions/types.js";
|
||||
import type { CustomMessage } from "../../../core/messages.js";
|
||||
import { getMarkdownTheme, theme } from "../theme/theme.js";
|
||||
|
||||
/**
|
||||
* Component that renders a custom message entry from hooks.
|
||||
* Component that renders a custom message entry from extensions.
|
||||
* Uses distinct styling to differentiate from user messages.
|
||||
*/
|
||||
export class HookMessageComponent extends Container {
|
||||
private message: HookMessage<unknown>;
|
||||
private customRenderer?: HookMessageRenderer;
|
||||
export class CustomMessageComponent extends Container {
|
||||
private message: CustomMessage<unknown>;
|
||||
private customRenderer?: MessageRenderer;
|
||||
private box: Box;
|
||||
private customComponent?: Component;
|
||||
private _expanded = false;
|
||||
|
||||
constructor(message: HookMessage<unknown>, customRenderer?: HookMessageRenderer) {
|
||||
constructor(message: CustomMessage<unknown>, customRenderer?: MessageRenderer) {
|
||||
super();
|
||||
this.message = message;
|
||||
this.customRenderer = customRenderer;
|
||||
|
|
@ -4,9 +4,9 @@ import { theme } from "../theme/theme.js";
|
|||
/**
|
||||
* Dynamic border component that adjusts to viewport width.
|
||||
*
|
||||
* Note: When used from hooks loaded via jiti, the global `theme` may be undefined
|
||||
* Note: When used from extensions loaded via jiti, the global `theme` may be undefined
|
||||
* because jiti creates a separate module cache. Always pass an explicit color
|
||||
* function when using DynamicBorder in components exported for hook use.
|
||||
* function when using DynamicBorder in components exported for extension use.
|
||||
*/
|
||||
export class DynamicBorder implements Component {
|
||||
private color: (str: string) => string;
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
/**
|
||||
* Multi-line editor component for hooks.
|
||||
* Multi-line editor component for extensions.
|
||||
* Supports Ctrl+G for external editor.
|
||||
*/
|
||||
|
||||
|
|
@ -11,7 +11,7 @@ import { Container, Editor, getEditorKeybindings, matchesKey, Spacer, Text, type
|
|||
import { getEditorTheme, theme } from "../theme/theme.js";
|
||||
import { DynamicBorder } from "./dynamic-border.js";
|
||||
|
||||
export class HookEditorComponent extends Container {
|
||||
export class ExtensionEditorComponent extends Container {
|
||||
private editor: Editor;
|
||||
private onSubmitCallback: (value: string) => void;
|
||||
private onCancelCallback: () => void;
|
||||
|
|
@ -91,7 +91,7 @@ export class HookEditorComponent extends Container {
|
|||
}
|
||||
|
||||
const currentText = this.editor.getText();
|
||||
const tmpFile = path.join(os.tmpdir(), `pi-hook-editor-${Date.now()}.md`);
|
||||
const tmpFile = path.join(os.tmpdir(), `pi-extension-editor-${Date.now()}.md`);
|
||||
|
||||
try {
|
||||
fs.writeFileSync(tmpFile, currentText, "utf-8");
|
||||
|
|
@ -1,12 +1,12 @@
|
|||
/**
|
||||
* Simple text input component for hooks.
|
||||
* Simple text input component for extensions.
|
||||
*/
|
||||
|
||||
import { Container, getEditorKeybindings, Input, Spacer, Text } from "@mariozechner/pi-tui";
|
||||
import { theme } from "../theme/theme.js";
|
||||
import { DynamicBorder } from "./dynamic-border.js";
|
||||
|
||||
export class HookInputComponent extends Container {
|
||||
export class ExtensionInputComponent extends Container {
|
||||
private input: Input;
|
||||
private onSubmitCallback: (value: string) => void;
|
||||
private onCancelCallback: () => void;
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
/**
|
||||
* Generic selector component for hooks.
|
||||
* Generic selector component for extensions.
|
||||
* Displays a list of string options with keyboard navigation.
|
||||
*/
|
||||
|
||||
|
|
@ -7,7 +7,7 @@ import { Container, getEditorKeybindings, Spacer, Text } from "@mariozechner/pi-
|
|||
import { theme } from "../theme/theme.js";
|
||||
import { DynamicBorder } from "./dynamic-border.js";
|
||||
|
||||
export class HookSelectorComponent extends Container {
|
||||
export class ExtensionSelectorComponent extends Container {
|
||||
private options: string[];
|
||||
private selectedIndex = 0;
|
||||
private listContainer: Container;
|
||||
|
|
@ -46,7 +46,7 @@ export class FooterComponent implements Component {
|
|||
private gitWatcher: FSWatcher | null = null;
|
||||
private onBranchChange: (() => void) | null = null;
|
||||
private autoCompactEnabled: boolean = true;
|
||||
private hookStatuses: Map<string, string> = new Map();
|
||||
private extensionStatuses: Map<string, string> = new Map();
|
||||
|
||||
constructor(session: AgentSession) {
|
||||
this.session = session;
|
||||
|
|
@ -57,17 +57,17 @@ export class FooterComponent implements Component {
|
|||
}
|
||||
|
||||
/**
|
||||
* Set hook status text to display in the footer.
|
||||
* Set extension status text to display in the footer.
|
||||
* Text is sanitized (newlines/tabs replaced with spaces) and truncated to terminal width.
|
||||
* ANSI escape codes for styling are preserved.
|
||||
* @param key - Unique key to identify this status
|
||||
* @param text - Status text, or undefined to clear
|
||||
*/
|
||||
setHookStatus(key: string, text: string | undefined): void {
|
||||
setExtensionStatus(key: string, text: string | undefined): void {
|
||||
if (text === undefined) {
|
||||
this.hookStatuses.delete(key);
|
||||
this.extensionStatuses.delete(key);
|
||||
} else {
|
||||
this.hookStatuses.set(key, text);
|
||||
this.extensionStatuses.set(key, text);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -309,9 +309,9 @@ export class FooterComponent implements Component {
|
|||
|
||||
const lines = [theme.fg("dim", pwd), dimStatsLeft + dimRemainder];
|
||||
|
||||
// Add hook statuses on a single line, sorted by key alphabetically
|
||||
if (this.hookStatuses.size > 0) {
|
||||
const sortedStatuses = Array.from(this.hookStatuses.entries())
|
||||
// Add extension statuses on a single line, sorted by key alphabetically
|
||||
if (this.extensionStatuses.size > 0) {
|
||||
const sortedStatuses = Array.from(this.extensionStatuses.entries())
|
||||
.sort(([a], [b]) => a.localeCompare(b))
|
||||
.map(([, text]) => sanitizeStatusText(text));
|
||||
const statusLine = sortedStatuses.join(" ");
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ import {
|
|||
type TUI,
|
||||
} from "@mariozechner/pi-tui";
|
||||
import stripAnsi from "strip-ansi";
|
||||
import type { CustomTool } from "../../../core/custom-tools/types.js";
|
||||
import type { ToolDefinition } from "../../../core/extensions/types.js";
|
||||
import { computeEditDiff, type EditDiffError, type EditDiffResult } from "../../../core/tools/edit-diff.js";
|
||||
import { DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, formatSize } from "../../../core/tools/truncate.js";
|
||||
import { convertToPng } from "../../../utils/image-convert.js";
|
||||
|
|
@ -58,7 +58,7 @@ export class ToolExecutionComponent extends Container {
|
|||
private expanded = false;
|
||||
private showImages: boolean;
|
||||
private isPartial = true;
|
||||
private customTool?: CustomTool;
|
||||
private toolDefinition?: ToolDefinition;
|
||||
private ui: TUI;
|
||||
private cwd: string;
|
||||
private result?: {
|
||||
|
|
@ -76,7 +76,7 @@ export class ToolExecutionComponent extends Container {
|
|||
toolName: string,
|
||||
args: any,
|
||||
options: ToolExecutionOptions = {},
|
||||
customTool: CustomTool | undefined,
|
||||
toolDefinition: ToolDefinition | undefined,
|
||||
ui: TUI,
|
||||
cwd: string = process.cwd(),
|
||||
) {
|
||||
|
|
@ -84,7 +84,7 @@ export class ToolExecutionComponent extends Container {
|
|||
this.toolName = toolName;
|
||||
this.args = args;
|
||||
this.showImages = options.showImages ?? true;
|
||||
this.customTool = customTool;
|
||||
this.toolDefinition = toolDefinition;
|
||||
this.ui = ui;
|
||||
this.cwd = cwd;
|
||||
|
||||
|
|
@ -94,7 +94,7 @@ export class ToolExecutionComponent extends Container {
|
|||
this.contentBox = new Box(1, 1, (text: string) => theme.bg("toolPendingBg", text));
|
||||
this.contentText = new Text("", 1, 1, (text: string) => theme.bg("toolPendingBg", text));
|
||||
|
||||
if (customTool || toolName === "bash") {
|
||||
if (toolDefinition || toolName === "bash") {
|
||||
this.addChild(this.contentBox);
|
||||
} else {
|
||||
this.addChild(this.contentText);
|
||||
|
|
@ -214,15 +214,15 @@ export class ToolExecutionComponent extends Container {
|
|||
: (text: string) => theme.bg("toolSuccessBg", text);
|
||||
|
||||
// Check for custom tool rendering
|
||||
if (this.customTool) {
|
||||
if (this.toolDefinition) {
|
||||
// Custom tools use Box for flexible component rendering
|
||||
this.contentBox.setBgFn(bgFn);
|
||||
this.contentBox.clear();
|
||||
|
||||
// Render call component
|
||||
if (this.customTool.renderCall) {
|
||||
if (this.toolDefinition.renderCall) {
|
||||
try {
|
||||
const callComponent = this.customTool.renderCall(this.args, theme);
|
||||
const callComponent = this.toolDefinition.renderCall(this.args, theme);
|
||||
if (callComponent) {
|
||||
this.contentBox.addChild(callComponent);
|
||||
}
|
||||
|
|
@ -236,9 +236,9 @@ export class ToolExecutionComponent extends Container {
|
|||
}
|
||||
|
||||
// Render result component if we have a result
|
||||
if (this.result && this.customTool.renderResult) {
|
||||
if (this.result && this.toolDefinition.renderResult) {
|
||||
try {
|
||||
const resultComponent = this.customTool.renderResult(
|
||||
const resultComponent = this.toolDefinition.renderResult(
|
||||
{ content: this.result.content as any, details: this.result.details },
|
||||
{ expanded: this.expanded, isPartial: this.isPartial },
|
||||
theme,
|
||||
|
|
|
|||
|
|
@ -30,8 +30,12 @@ import {
|
|||
import { exec, spawn, spawnSync } from "child_process";
|
||||
import { APP_NAME, getAuthPath, getDebugLogPath } from "../../config.js";
|
||||
import type { AgentSession, AgentSessionEvent } from "../../core/agent-session.js";
|
||||
import type { CustomToolSessionEvent, LoadedCustomTool } from "../../core/custom-tools/index.js";
|
||||
import type { HookContext, HookRunner, HookUIContext } from "../../core/hooks/index.js";
|
||||
import type {
|
||||
ExtensionContext,
|
||||
ExtensionRunner,
|
||||
ExtensionUIContext,
|
||||
LoadedExtension,
|
||||
} from "../../core/extensions/index.js";
|
||||
import { KeybindingsManager } from "../../core/keybindings.js";
|
||||
import { createCompactionSummaryMessage } from "../../core/messages.js";
|
||||
import { type SessionContext, SessionManager } from "../../core/session-manager.js";
|
||||
|
|
@ -47,12 +51,12 @@ import { BorderedLoader } from "./components/bordered-loader.js";
|
|||
import { BranchSummaryMessageComponent } from "./components/branch-summary-message.js";
|
||||
import { CompactionSummaryMessageComponent } from "./components/compaction-summary-message.js";
|
||||
import { CustomEditor } from "./components/custom-editor.js";
|
||||
import { CustomMessageComponent } from "./components/custom-message.js";
|
||||
import { DynamicBorder } from "./components/dynamic-border.js";
|
||||
import { ExtensionEditorComponent } from "./components/extension-editor.js";
|
||||
import { ExtensionInputComponent } from "./components/extension-input.js";
|
||||
import { ExtensionSelectorComponent } from "./components/extension-selector.js";
|
||||
import { FooterComponent } from "./components/footer.js";
|
||||
import { HookEditorComponent } from "./components/hook-editor.js";
|
||||
import { HookInputComponent } from "./components/hook-input.js";
|
||||
import { HookMessageComponent } from "./components/hook-message.js";
|
||||
import { HookSelectorComponent } from "./components/hook-selector.js";
|
||||
import { ModelSelectorComponent } from "./components/model-selector.js";
|
||||
import { OAuthSelectorComponent } from "./components/oauth-selector.js";
|
||||
import { SessionSelectorComponent } from "./components/session-selector.js";
|
||||
|
|
@ -136,18 +140,15 @@ export class InteractiveMode {
|
|||
private retryLoader: Loader | undefined = undefined;
|
||||
private retryEscapeHandler?: () => void;
|
||||
|
||||
// Hook UI state
|
||||
private hookSelector: HookSelectorComponent | undefined = undefined;
|
||||
private hookInput: HookInputComponent | undefined = undefined;
|
||||
private hookEditor: HookEditorComponent | undefined = undefined;
|
||||
// Extension UI state
|
||||
private extensionSelector: ExtensionSelectorComponent | undefined = undefined;
|
||||
private extensionInput: ExtensionInputComponent | undefined = undefined;
|
||||
private extensionEditor: ExtensionEditorComponent | undefined = undefined;
|
||||
|
||||
// Hook widgets (components rendered above the editor)
|
||||
private hookWidgets = new Map<string, Component & { dispose?(): void }>();
|
||||
// Extension widgets (components rendered above the editor)
|
||||
private extensionWidgets = new Map<string, Component & { dispose?(): void }>();
|
||||
private widgetContainer!: Container;
|
||||
|
||||
// Custom tools for custom rendering
|
||||
private customTools: Map<string, LoadedCustomTool>;
|
||||
|
||||
// Convenience accessors
|
||||
private get agent() {
|
||||
return this.session.agent;
|
||||
|
|
@ -163,14 +164,13 @@ export class InteractiveMode {
|
|||
session: AgentSession,
|
||||
version: string,
|
||||
changelogMarkdown: string | undefined = undefined,
|
||||
customTools: LoadedCustomTool[] = [],
|
||||
private setToolUIContext: (uiContext: HookUIContext, hasUI: boolean) => void = () => {},
|
||||
_extensions: LoadedExtension[] = [],
|
||||
private setExtensionUIContext: (uiContext: ExtensionUIContext, hasUI: boolean) => void = () => {},
|
||||
fdPath: string | undefined = undefined,
|
||||
) {
|
||||
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();
|
||||
|
|
@ -183,7 +183,7 @@ export class InteractiveMode {
|
|||
this.footer = new FooterComponent(session);
|
||||
this.footer.setAutoCompactEnabled(session.autoCompactionEnabled);
|
||||
|
||||
// Define slash commands for autocomplete
|
||||
// Define commands for autocomplete
|
||||
const slashCommands: SlashCommand[] = [
|
||||
{ name: "settings", description: "Open settings menu" },
|
||||
{ name: "model", description: "Select model (opens selector UI)" },
|
||||
|
|
@ -205,21 +205,23 @@ export class InteractiveMode {
|
|||
// Load hide thinking block setting
|
||||
this.hideThinkingBlock = this.settingsManager.getHideThinkingBlock();
|
||||
|
||||
// Convert file commands to SlashCommand format
|
||||
const fileSlashCommands: SlashCommand[] = this.session.fileCommands.map((cmd) => ({
|
||||
// Convert prompt templates to SlashCommand format for autocomplete
|
||||
const templateCommands: SlashCommand[] = this.session.promptTemplates.map((cmd) => ({
|
||||
name: cmd.name,
|
||||
description: cmd.description,
|
||||
}));
|
||||
|
||||
// Convert hook commands to SlashCommand format
|
||||
const hookCommands: SlashCommand[] = (this.session.hookRunner?.getRegisteredCommands() ?? []).map((cmd) => ({
|
||||
name: cmd.name,
|
||||
description: cmd.description ?? "(hook command)",
|
||||
}));
|
||||
// Convert extension commands to SlashCommand format
|
||||
const extensionCommands: SlashCommand[] = (this.session.extensionRunner?.getRegisteredCommands() ?? []).map(
|
||||
(cmd) => ({
|
||||
name: cmd.name,
|
||||
description: cmd.description ?? "(extension command)",
|
||||
}),
|
||||
);
|
||||
|
||||
// Setup autocomplete
|
||||
const autocompleteProvider = new CombinedAutocompleteProvider(
|
||||
[...slashCommands, ...fileSlashCommands, ...hookCommands],
|
||||
[...slashCommands, ...templateCommands, ...extensionCommands],
|
||||
process.cwd(),
|
||||
fdPath,
|
||||
);
|
||||
|
|
@ -348,8 +350,8 @@ export class InteractiveMode {
|
|||
const cwdBasename = path.basename(process.cwd());
|
||||
this.ui.terminal.setTitle(`pi - ${cwdBasename}`);
|
||||
|
||||
// Initialize hooks with TUI-based UI context
|
||||
await this.initHooksAndCustomTools();
|
||||
// Initialize extensions with TUI-based UI context
|
||||
await this.initExtensions();
|
||||
|
||||
// Subscribe to agent events
|
||||
this.subscribeToAgent();
|
||||
|
|
@ -368,13 +370,13 @@ export class InteractiveMode {
|
|||
}
|
||||
|
||||
// =========================================================================
|
||||
// Hook System
|
||||
// Extension System
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* Initialize the hook system with TUI-based UI context.
|
||||
* Initialize the extension system with TUI-based UI context.
|
||||
*/
|
||||
private async initHooksAndCustomTools(): Promise<void> {
|
||||
private async initExtensions(): Promise<void> {
|
||||
// Show loaded project context files
|
||||
const contextFiles = loadProjectContextFiles();
|
||||
if (contextFiles.length > 0) {
|
||||
|
|
@ -403,36 +405,21 @@ export class InteractiveMode {
|
|||
}
|
||||
}
|
||||
|
||||
// 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));
|
||||
// Create and set extension UI context
|
||||
const uiContext = this.createExtensionUIContext();
|
||||
this.setExtensionUIContext(uiContext, true);
|
||||
|
||||
const extensionRunner = this.session.extensionRunner;
|
||||
if (!extensionRunner) {
|
||||
return; // No extensions loaded
|
||||
}
|
||||
|
||||
// Create and set hook & tool UI context
|
||||
const uiContext = this.createHookUIContext();
|
||||
this.setToolUIContext(uiContext, true);
|
||||
|
||||
// Notify custom tools of session start
|
||||
await this.emitCustomToolSessionEvent({
|
||||
reason: "start",
|
||||
previousSessionFile: undefined,
|
||||
});
|
||||
|
||||
const hookRunner = this.session.hookRunner;
|
||||
if (!hookRunner) {
|
||||
return; // No hooks loaded
|
||||
}
|
||||
|
||||
hookRunner.initialize({
|
||||
extensionRunner.initialize({
|
||||
getModel: () => this.session.model,
|
||||
sendMessageHandler: (message, options) => {
|
||||
const wasStreaming = this.session.isStreaming;
|
||||
this.session
|
||||
.sendHookMessage(message, options)
|
||||
.sendCustomMessage(message, options)
|
||||
.then(() => {
|
||||
// For non-streaming cases with display=true, update UI
|
||||
// (streaming cases update via message_end event)
|
||||
|
|
@ -441,7 +428,7 @@ export class InteractiveMode {
|
|||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
this.showError(`Hook sendMessage failed: ${err instanceof Error ? err.message : String(err)}`);
|
||||
this.showError(`Extension sendMessage failed: ${err instanceof Error ? err.message : String(err)}`);
|
||||
});
|
||||
},
|
||||
appendEntryHandler: (customType, data) => {
|
||||
|
|
@ -522,71 +509,47 @@ export class InteractiveMode {
|
|||
hasUI: true,
|
||||
});
|
||||
|
||||
// Subscribe to hook errors
|
||||
hookRunner.onError((error) => {
|
||||
this.showHookError(error.hookPath, error.error, error.stack);
|
||||
// Subscribe to extension errors
|
||||
extensionRunner.onError((error) => {
|
||||
this.showExtensionError(error.extensionPath, error.error, error.stack);
|
||||
});
|
||||
|
||||
// Set up hook-registered shortcuts
|
||||
this.setupHookShortcuts(hookRunner);
|
||||
// Set up extension-registered shortcuts
|
||||
this.setupExtensionShortcuts(extensionRunner);
|
||||
|
||||
// Show loaded hooks
|
||||
const hookPaths = hookRunner.getHookPaths();
|
||||
if (hookPaths.length > 0) {
|
||||
const hookList = hookPaths.map((p) => theme.fg("dim", ` ${p}`)).join("\n");
|
||||
this.chatContainer.addChild(new Text(theme.fg("muted", "Loaded hooks:\n") + hookList, 0, 0));
|
||||
// Show loaded extensions
|
||||
const extensionPaths = extensionRunner.getExtensionPaths();
|
||||
if (extensionPaths.length > 0) {
|
||||
const extList = extensionPaths.map((p) => theme.fg("dim", ` ${p}`)).join("\n");
|
||||
this.chatContainer.addChild(new Text(theme.fg("muted", "Loaded extensions:\n") + extList, 0, 0));
|
||||
this.chatContainer.addChild(new Spacer(1));
|
||||
}
|
||||
|
||||
// Emit session_start event
|
||||
await hookRunner.emit({
|
||||
await extensionRunner.emit({
|
||||
type: "session_start",
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit session event to all custom tools.
|
||||
* Get a registered tool definition by name (for custom rendering).
|
||||
*/
|
||||
private async emitCustomToolSessionEvent(event: CustomToolSessionEvent): Promise<void> {
|
||||
for (const { tool } of this.customTools.values()) {
|
||||
if (tool.onSession) {
|
||||
try {
|
||||
await tool.onSession(event, {
|
||||
sessionManager: this.session.sessionManager,
|
||||
modelRegistry: this.session.modelRegistry,
|
||||
model: this.session.model,
|
||||
isIdle: () => !this.session.isStreaming,
|
||||
hasPendingMessages: () => this.session.pendingMessageCount > 0,
|
||||
abort: () => {
|
||||
this.session.abort();
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
this.showToolError(tool.name, err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
}
|
||||
}
|
||||
private getRegisteredToolDefinition(toolName: string) {
|
||||
const tools = this.session.extensionRunner?.getAllRegisteredTools() ?? [];
|
||||
const registeredTool = tools.find((t) => t.definition.name === toolName);
|
||||
return registeredTool?.definition;
|
||||
}
|
||||
|
||||
/**
|
||||
* Show a tool error in the chat.
|
||||
* Set up keyboard shortcuts registered by extensions.
|
||||
*/
|
||||
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();
|
||||
}
|
||||
|
||||
/**
|
||||
* Set up keyboard shortcuts registered by hooks.
|
||||
*/
|
||||
private setupHookShortcuts(hookRunner: HookRunner): void {
|
||||
const shortcuts = hookRunner.getShortcuts();
|
||||
private setupExtensionShortcuts(extensionRunner: ExtensionRunner): void {
|
||||
const shortcuts = extensionRunner.getShortcuts();
|
||||
if (shortcuts.size === 0) return;
|
||||
|
||||
// Create a context for shortcut handlers
|
||||
const createContext = (): HookContext => ({
|
||||
ui: this.createHookUIContext(),
|
||||
const createContext = (): ExtensionContext => ({
|
||||
ui: this.createExtensionUIContext(),
|
||||
hasUI: true,
|
||||
cwd: process.cwd(),
|
||||
sessionManager: this.sessionManager,
|
||||
|
|
@ -597,10 +560,10 @@ export class InteractiveMode {
|
|||
hasPendingMessages: () => this.session.pendingMessageCount > 0,
|
||||
});
|
||||
|
||||
// Set up the hook shortcut handler on the editor
|
||||
this.editor.onHookShortcut = (data: string) => {
|
||||
// Set up the extension shortcut handler on the editor
|
||||
this.editor.onExtensionShortcut = (data: string) => {
|
||||
for (const [shortcutStr, shortcut] of shortcuts) {
|
||||
// Cast to KeyId - hook shortcuts use the same format
|
||||
// Cast to KeyId - extension shortcuts use the same format
|
||||
if (matchesKey(data, shortcutStr as KeyId)) {
|
||||
// Run handler async, don't block input
|
||||
Promise.resolve(shortcut.handler(createContext())).catch((err) => {
|
||||
|
|
@ -614,26 +577,26 @@ export class InteractiveMode {
|
|||
}
|
||||
|
||||
/**
|
||||
* Set hook status text in the footer.
|
||||
* Set extension status text in the footer.
|
||||
*/
|
||||
private setHookStatus(key: string, text: string | undefined): void {
|
||||
this.footer.setHookStatus(key, text);
|
||||
private setExtensionStatus(key: string, text: string | undefined): void {
|
||||
this.footer.setExtensionStatus(key, text);
|
||||
this.ui.requestRender();
|
||||
}
|
||||
|
||||
/**
|
||||
* Set a hook widget (string array or custom component).
|
||||
* Set an extension widget (string array or custom component).
|
||||
*/
|
||||
private setHookWidget(
|
||||
private setExtensionWidget(
|
||||
key: string,
|
||||
content: string[] | ((tui: TUI, thm: Theme) => Component & { dispose?(): void }) | undefined,
|
||||
): void {
|
||||
// Dispose and remove existing widget
|
||||
const existing = this.hookWidgets.get(key);
|
||||
const existing = this.extensionWidgets.get(key);
|
||||
if (existing?.dispose) existing.dispose();
|
||||
|
||||
if (content === undefined) {
|
||||
this.hookWidgets.delete(key);
|
||||
this.extensionWidgets.delete(key);
|
||||
} else if (Array.isArray(content)) {
|
||||
// Wrap string array in a Container with Text components
|
||||
const container = new Container();
|
||||
|
|
@ -643,11 +606,11 @@ export class InteractiveMode {
|
|||
if (content.length > InteractiveMode.MAX_WIDGET_LINES) {
|
||||
container.addChild(new Text(theme.fg("muted", "... (widget truncated)"), 1, 0));
|
||||
}
|
||||
this.hookWidgets.set(key, container);
|
||||
this.extensionWidgets.set(key, container);
|
||||
} else {
|
||||
// Factory function - create component
|
||||
const component = content(this.ui, theme);
|
||||
this.hookWidgets.set(key, component);
|
||||
this.extensionWidgets.set(key, component);
|
||||
}
|
||||
this.renderWidgets();
|
||||
}
|
||||
|
|
@ -656,18 +619,18 @@ export class InteractiveMode {
|
|||
private static readonly MAX_WIDGET_LINES = 10;
|
||||
|
||||
/**
|
||||
* Render all hook widgets to the widget container.
|
||||
* Render all extension widgets to the widget container.
|
||||
*/
|
||||
private renderWidgets(): void {
|
||||
if (!this.widgetContainer) return;
|
||||
this.widgetContainer.clear();
|
||||
|
||||
if (this.hookWidgets.size === 0) {
|
||||
if (this.extensionWidgets.size === 0) {
|
||||
this.ui.requestRender();
|
||||
return;
|
||||
}
|
||||
|
||||
for (const [_key, component] of this.hookWidgets) {
|
||||
for (const [_key, component] of this.extensionWidgets) {
|
||||
this.widgetContainer.addChild(component);
|
||||
}
|
||||
|
||||
|
|
@ -675,21 +638,21 @@ export class InteractiveMode {
|
|||
}
|
||||
|
||||
/**
|
||||
* Create the HookUIContext for hooks and tools.
|
||||
* Create the ExtensionUIContext for extensions.
|
||||
*/
|
||||
private createHookUIContext(): HookUIContext {
|
||||
private createExtensionUIContext(): ExtensionUIContext {
|
||||
return {
|
||||
select: (title, options) => this.showHookSelector(title, options),
|
||||
confirm: (title, message) => this.showHookConfirm(title, message),
|
||||
input: (title, placeholder) => this.showHookInput(title, placeholder),
|
||||
notify: (message, type) => this.showHookNotify(message, type),
|
||||
setStatus: (key, text) => this.setHookStatus(key, text),
|
||||
setWidget: (key, content) => this.setHookWidget(key, content),
|
||||
select: (title, options) => this.showExtensionSelector(title, options),
|
||||
confirm: (title, message) => this.showExtensionConfirm(title, message),
|
||||
input: (title, placeholder) => this.showExtensionInput(title, placeholder),
|
||||
notify: (message, type) => this.showExtensionNotify(message, type),
|
||||
setStatus: (key, text) => this.setExtensionStatus(key, text),
|
||||
setWidget: (key, content) => this.setExtensionWidget(key, content),
|
||||
setTitle: (title) => this.ui.terminal.setTitle(title),
|
||||
custom: (factory) => this.showHookCustom(factory),
|
||||
custom: (factory) => this.showExtensionCustom(factory),
|
||||
setEditorText: (text) => this.editor.setText(text),
|
||||
getEditorText: () => this.editor.getText(),
|
||||
editor: (title, prefill) => this.showHookEditor(title, prefill),
|
||||
editor: (title, prefill) => this.showExtensionEditor(title, prefill),
|
||||
get theme() {
|
||||
return theme;
|
||||
},
|
||||
|
|
@ -697,126 +660,126 @@ export class InteractiveMode {
|
|||
}
|
||||
|
||||
/**
|
||||
* Show a selector for hooks.
|
||||
* Show a selector for extensions.
|
||||
*/
|
||||
private showHookSelector(title: string, options: string[]): Promise<string | undefined> {
|
||||
private showExtensionSelector(title: string, options: string[]): Promise<string | undefined> {
|
||||
return new Promise((resolve) => {
|
||||
this.hookSelector = new HookSelectorComponent(
|
||||
this.extensionSelector = new ExtensionSelectorComponent(
|
||||
title,
|
||||
options,
|
||||
(option) => {
|
||||
this.hideHookSelector();
|
||||
this.hideExtensionSelector();
|
||||
resolve(option);
|
||||
},
|
||||
() => {
|
||||
this.hideHookSelector();
|
||||
this.hideExtensionSelector();
|
||||
resolve(undefined);
|
||||
},
|
||||
);
|
||||
|
||||
this.editorContainer.clear();
|
||||
this.editorContainer.addChild(this.hookSelector);
|
||||
this.ui.setFocus(this.hookSelector);
|
||||
this.editorContainer.addChild(this.extensionSelector);
|
||||
this.ui.setFocus(this.extensionSelector);
|
||||
this.ui.requestRender();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Hide the hook selector.
|
||||
* Hide the extension selector.
|
||||
*/
|
||||
private hideHookSelector(): void {
|
||||
private hideExtensionSelector(): void {
|
||||
this.editorContainer.clear();
|
||||
this.editorContainer.addChild(this.editor);
|
||||
this.hookSelector = undefined;
|
||||
this.extensionSelector = undefined;
|
||||
this.ui.setFocus(this.editor);
|
||||
this.ui.requestRender();
|
||||
}
|
||||
|
||||
/**
|
||||
* Show a confirmation dialog for hooks.
|
||||
* Show a confirmation dialog for extensions.
|
||||
*/
|
||||
private async showHookConfirm(title: string, message: string): Promise<boolean> {
|
||||
const result = await this.showHookSelector(`${title}\n${message}`, ["Yes", "No"]);
|
||||
private async showExtensionConfirm(title: string, message: string): Promise<boolean> {
|
||||
const result = await this.showExtensionSelector(`${title}\n${message}`, ["Yes", "No"]);
|
||||
return result === "Yes";
|
||||
}
|
||||
|
||||
/**
|
||||
* Show a text input for hooks.
|
||||
* Show a text input for extensions.
|
||||
*/
|
||||
private showHookInput(title: string, placeholder?: string): Promise<string | undefined> {
|
||||
private showExtensionInput(title: string, placeholder?: string): Promise<string | undefined> {
|
||||
return new Promise((resolve) => {
|
||||
this.hookInput = new HookInputComponent(
|
||||
this.extensionInput = new ExtensionInputComponent(
|
||||
title,
|
||||
placeholder,
|
||||
(value) => {
|
||||
this.hideHookInput();
|
||||
this.hideExtensionInput();
|
||||
resolve(value);
|
||||
},
|
||||
() => {
|
||||
this.hideHookInput();
|
||||
this.hideExtensionInput();
|
||||
resolve(undefined);
|
||||
},
|
||||
);
|
||||
|
||||
this.editorContainer.clear();
|
||||
this.editorContainer.addChild(this.hookInput);
|
||||
this.ui.setFocus(this.hookInput);
|
||||
this.editorContainer.addChild(this.extensionInput);
|
||||
this.ui.setFocus(this.extensionInput);
|
||||
this.ui.requestRender();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Hide the hook input.
|
||||
* Hide the extension input.
|
||||
*/
|
||||
private hideHookInput(): void {
|
||||
private hideExtensionInput(): void {
|
||||
this.editorContainer.clear();
|
||||
this.editorContainer.addChild(this.editor);
|
||||
this.hookInput = undefined;
|
||||
this.extensionInput = undefined;
|
||||
this.ui.setFocus(this.editor);
|
||||
this.ui.requestRender();
|
||||
}
|
||||
|
||||
/**
|
||||
* Show a multi-line editor for hooks (with Ctrl+G support).
|
||||
* Show a multi-line editor for extensions (with Ctrl+G support).
|
||||
*/
|
||||
private showHookEditor(title: string, prefill?: string): Promise<string | undefined> {
|
||||
private showExtensionEditor(title: string, prefill?: string): Promise<string | undefined> {
|
||||
return new Promise((resolve) => {
|
||||
this.hookEditor = new HookEditorComponent(
|
||||
this.extensionEditor = new ExtensionEditorComponent(
|
||||
this.ui,
|
||||
title,
|
||||
prefill,
|
||||
(value) => {
|
||||
this.hideHookEditor();
|
||||
this.hideExtensionEditor();
|
||||
resolve(value);
|
||||
},
|
||||
() => {
|
||||
this.hideHookEditor();
|
||||
this.hideExtensionEditor();
|
||||
resolve(undefined);
|
||||
},
|
||||
);
|
||||
|
||||
this.editorContainer.clear();
|
||||
this.editorContainer.addChild(this.hookEditor);
|
||||
this.ui.setFocus(this.hookEditor);
|
||||
this.editorContainer.addChild(this.extensionEditor);
|
||||
this.ui.setFocus(this.extensionEditor);
|
||||
this.ui.requestRender();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Hide the hook editor.
|
||||
* Hide the extension editor.
|
||||
*/
|
||||
private hideHookEditor(): void {
|
||||
private hideExtensionEditor(): void {
|
||||
this.editorContainer.clear();
|
||||
this.editorContainer.addChild(this.editor);
|
||||
this.hookEditor = undefined;
|
||||
this.extensionEditor = undefined;
|
||||
this.ui.setFocus(this.editor);
|
||||
this.ui.requestRender();
|
||||
}
|
||||
|
||||
/**
|
||||
* Show a notification for hooks.
|
||||
* Show a notification for extensions.
|
||||
*/
|
||||
private showHookNotify(message: string, type?: "info" | "warning" | "error"): void {
|
||||
private showExtensionNotify(message: string, type?: "info" | "warning" | "error"): void {
|
||||
if (type === "error") {
|
||||
this.showError(message);
|
||||
} else if (type === "warning") {
|
||||
|
|
@ -829,7 +792,7 @@ export class InteractiveMode {
|
|||
/**
|
||||
* Show a custom component with keyboard focus.
|
||||
*/
|
||||
private async showHookCustom<T>(
|
||||
private async showExtensionCustom<T>(
|
||||
factory: (
|
||||
tui: TUI,
|
||||
theme: Theme,
|
||||
|
|
@ -862,10 +825,10 @@ export class InteractiveMode {
|
|||
}
|
||||
|
||||
/**
|
||||
* Show a hook error in the UI.
|
||||
* Show an extension error in the UI.
|
||||
*/
|
||||
private showHookError(hookPath: string, error: string, stack?: string): void {
|
||||
const errorMsg = `Hook "${hookPath}" error: ${error}`;
|
||||
private showExtensionError(extensionPath: string, error: string, stack?: string): void {
|
||||
const errorMsg = `Extension "${extensionPath}" error: ${error}`;
|
||||
const errorText = new Text(theme.fg("error", errorMsg), 1, 0);
|
||||
this.chatContainer.addChild(errorText);
|
||||
if (stack) {
|
||||
|
|
@ -882,10 +845,6 @@ export class InteractiveMode {
|
|||
this.ui.requestRender();
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle pi.send() from hooks.
|
||||
* If streaming, queue the message. Otherwise, start a new agent loop.
|
||||
*/
|
||||
// =========================================================================
|
||||
// Key Handlers
|
||||
// =========================================================================
|
||||
|
|
@ -984,7 +943,7 @@ export class InteractiveMode {
|
|||
text = text.trim();
|
||||
if (!text) return;
|
||||
|
||||
// Handle slash commands
|
||||
// Handle commands
|
||||
if (text === "/settings") {
|
||||
this.showSettingsSelector();
|
||||
this.editor.setText("");
|
||||
|
|
@ -1106,7 +1065,7 @@ export class InteractiveMode {
|
|||
}
|
||||
|
||||
// If streaming, use prompt() with steer behavior
|
||||
// This handles hook commands (execute immediately), slash command expansion, and queueing
|
||||
// This handles extension commands (execute immediately), prompt template expansion, and queueing
|
||||
if (this.session.isStreaming) {
|
||||
this.editor.addToHistory(text);
|
||||
this.editor.setText("");
|
||||
|
|
@ -1157,7 +1116,7 @@ export class InteractiveMode {
|
|||
break;
|
||||
|
||||
case "message_start":
|
||||
if (event.message.role === "hookMessage") {
|
||||
if (event.message.role === "custom") {
|
||||
this.addMessageToChat(event.message);
|
||||
this.ui.requestRender();
|
||||
} else if (event.message.role === "user") {
|
||||
|
|
@ -1189,7 +1148,7 @@ export class InteractiveMode {
|
|||
{
|
||||
showImages: this.settingsManager.getShowImages(),
|
||||
},
|
||||
this.customTools.get(content.name)?.tool,
|
||||
this.getRegisteredToolDefinition(content.name),
|
||||
this.ui,
|
||||
);
|
||||
component.setExpanded(this.toolOutputExpanded);
|
||||
|
|
@ -1246,7 +1205,7 @@ export class InteractiveMode {
|
|||
{
|
||||
showImages: this.settingsManager.getShowImages(),
|
||||
},
|
||||
this.customTools.get(event.toolName)?.tool,
|
||||
this.getRegisteredToolDefinition(event.toolName),
|
||||
this.ui,
|
||||
);
|
||||
component.setExpanded(this.toolOutputExpanded);
|
||||
|
|
@ -1441,10 +1400,10 @@ export class InteractiveMode {
|
|||
this.chatContainer.addChild(component);
|
||||
break;
|
||||
}
|
||||
case "hookMessage": {
|
||||
case "custom": {
|
||||
if (message.display) {
|
||||
const renderer = this.session.hookRunner?.getMessageRenderer(message.customType);
|
||||
this.chatContainer.addChild(new HookMessageComponent(message, renderer));
|
||||
const renderer = this.session.extensionRunner?.getMessageRenderer(message.customType);
|
||||
this.chatContainer.addChild(new CustomMessageComponent(message, renderer));
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
|
@ -1516,7 +1475,7 @@ export class InteractiveMode {
|
|||
content.name,
|
||||
content.arguments,
|
||||
{ showImages: this.settingsManager.getShowImages() },
|
||||
this.customTools.get(content.name)?.tool,
|
||||
this.getRegisteredToolDefinition(content.name),
|
||||
this.ui,
|
||||
);
|
||||
component.setExpanded(this.toolOutputExpanded);
|
||||
|
|
@ -1601,20 +1560,17 @@ export class InteractiveMode {
|
|||
|
||||
/**
|
||||
* Gracefully shutdown the agent.
|
||||
* Emits shutdown event to hooks and tools, then exits.
|
||||
* Emits shutdown event to extensions, then exits.
|
||||
*/
|
||||
private async shutdown(): Promise<void> {
|
||||
// Emit shutdown event to hooks
|
||||
const hookRunner = this.session.hookRunner;
|
||||
if (hookRunner?.hasHandlers("session_shutdown")) {
|
||||
await hookRunner.emit({
|
||||
// Emit shutdown event to extensions
|
||||
const extensionRunner = this.session.extensionRunner;
|
||||
if (extensionRunner?.hasHandlers("session_shutdown")) {
|
||||
await extensionRunner.emit({
|
||||
type: "session_shutdown",
|
||||
});
|
||||
}
|
||||
|
||||
// Emit shutdown event to custom tools
|
||||
await this.session.emitCustomToolSessionEvent("shutdown");
|
||||
|
||||
this.stop();
|
||||
process.exit(0);
|
||||
}
|
||||
|
|
@ -1638,7 +1594,7 @@ export class InteractiveMode {
|
|||
if (!text) return;
|
||||
|
||||
// Alt+Enter queues a follow-up message (waits until agent finishes)
|
||||
// This handles hook commands (execute immediately), slash command expansion, and queueing
|
||||
// This handles extension commands (execute immediately), prompt template expansion, and queueing
|
||||
if (this.session.isStreaming) {
|
||||
this.editor.addToHistory(text);
|
||||
this.editor.setText("");
|
||||
|
|
@ -1979,7 +1935,7 @@ export class InteractiveMode {
|
|||
async (entryId) => {
|
||||
const result = await this.session.branch(entryId);
|
||||
if (result.cancelled) {
|
||||
// Hook cancelled the branch
|
||||
// Extension cancelled the branch
|
||||
done();
|
||||
this.ui.requestRender();
|
||||
return;
|
||||
|
|
@ -2034,7 +1990,7 @@ export class InteractiveMode {
|
|||
// Ask about summarization
|
||||
done(); // Close selector first
|
||||
|
||||
const wantsSummary = await this.showHookConfirm(
|
||||
const wantsSummary = await this.showExtensionConfirm(
|
||||
"Summarize branch?",
|
||||
"Create a summary of the branch you're leaving?",
|
||||
);
|
||||
|
|
@ -2137,7 +2093,7 @@ export class InteractiveMode {
|
|||
this.streamingMessage = undefined;
|
||||
this.pendingTools.clear();
|
||||
|
||||
// Switch session via AgentSession (emits hook and tool session events)
|
||||
// Switch session via AgentSession (emits extension session events)
|
||||
await this.session.switchSession(sessionPath);
|
||||
|
||||
// Clear and re-render the chat
|
||||
|
|
@ -2542,18 +2498,18 @@ export class InteractiveMode {
|
|||
| \`!\` | Run bash command |
|
||||
`;
|
||||
|
||||
// Add hook-registered shortcuts
|
||||
const hookRunner = this.session.hookRunner;
|
||||
if (hookRunner) {
|
||||
const shortcuts = hookRunner.getShortcuts();
|
||||
// Add extension-registered shortcuts
|
||||
const extensionRunner = this.session.extensionRunner;
|
||||
if (extensionRunner) {
|
||||
const shortcuts = extensionRunner.getShortcuts();
|
||||
if (shortcuts.size > 0) {
|
||||
hotkeys += `
|
||||
**Hooks**
|
||||
**Extensions**
|
||||
| Key | Action |
|
||||
|-----|--------|
|
||||
`;
|
||||
for (const [key, shortcut] of shortcuts) {
|
||||
const description = shortcut.description ?? shortcut.hookPath;
|
||||
const description = shortcut.description ?? shortcut.extensionPath;
|
||||
hotkeys += `| \`${key}\` | ${description} |\n`;
|
||||
}
|
||||
}
|
||||
|
|
@ -2576,7 +2532,7 @@ export class InteractiveMode {
|
|||
}
|
||||
this.statusContainer.clear();
|
||||
|
||||
// New session via session (emits hook and tool session events)
|
||||
// New session via session (emits extension session events)
|
||||
await this.session.newSession();
|
||||
|
||||
// Clear UI state
|
||||
|
|
|
|||
|
|
@ -26,15 +26,15 @@ export async function runPrintMode(
|
|||
initialMessage?: string,
|
||||
initialImages?: ImageContent[],
|
||||
): Promise<void> {
|
||||
// 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;
|
||||
if (hookRunner) {
|
||||
hookRunner.initialize({
|
||||
// Extension runner already has no-op UI context by default (set in loader)
|
||||
// Set up extensions for print mode (no UI)
|
||||
const extensionRunner = session.extensionRunner;
|
||||
if (extensionRunner) {
|
||||
extensionRunner.initialize({
|
||||
getModel: () => session.model,
|
||||
sendMessageHandler: (message, options) => {
|
||||
session.sendHookMessage(message, options).catch((e) => {
|
||||
console.error(`Hook sendMessage failed: ${e instanceof Error ? e.message : String(e)}`);
|
||||
session.sendCustomMessage(message, options).catch((e) => {
|
||||
console.error(`Extension sendMessage failed: ${e instanceof Error ? e.message : String(e)}`);
|
||||
});
|
||||
},
|
||||
appendEntryHandler: (customType, data) => {
|
||||
|
|
@ -44,41 +44,15 @@ export async function runPrintMode(
|
|||
getAllToolsHandler: () => session.getAllToolNames(),
|
||||
setActiveToolsHandler: (toolNames: string[]) => session.setActiveToolsByName(toolNames),
|
||||
});
|
||||
hookRunner.onError((err) => {
|
||||
console.error(`Hook error (${err.hookPath}): ${err.error}`);
|
||||
extensionRunner.onError((err) => {
|
||||
console.error(`Extension error (${err.extensionPath}): ${err.error}`);
|
||||
});
|
||||
// Emit session_start event
|
||||
await hookRunner.emit({
|
||||
await extensionRunner.emit({
|
||||
type: "session_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(
|
||||
{
|
||||
reason: "start",
|
||||
previousSessionFile: undefined,
|
||||
},
|
||||
{
|
||||
sessionManager: session.sessionManager,
|
||||
modelRegistry: session.modelRegistry,
|
||||
model: session.model,
|
||||
isIdle: () => !session.isStreaming,
|
||||
hasPendingMessages: () => session.pendingMessageCount > 0,
|
||||
abort: () => {
|
||||
session.abort();
|
||||
},
|
||||
},
|
||||
);
|
||||
} catch (_err) {
|
||||
// Silently ignore tool errors
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Always subscribe to enable session persistence via _handleAgentEvent
|
||||
session.subscribe((event) => {
|
||||
// In JSON mode, output all events
|
||||
|
|
|
|||
|
|
@ -196,7 +196,7 @@ export class RpcClient {
|
|||
/**
|
||||
* Start a new session, optionally with parent tracking.
|
||||
* @param parentSession - Optional parent session path for lineage tracking
|
||||
* @returns Object with `cancelled: true` if a hook cancelled the new session
|
||||
* @returns Object with `cancelled: true` if an extension cancelled the new session
|
||||
*/
|
||||
async newSession(parentSession?: string): Promise<{ cancelled: boolean }> {
|
||||
const response = await this.send({ type: "new_session", parentSession });
|
||||
|
|
@ -330,7 +330,7 @@ export class RpcClient {
|
|||
|
||||
/**
|
||||
* Switch to a different session file.
|
||||
* @returns Object with `cancelled: true` if a hook cancelled the switch
|
||||
* @returns Object with `cancelled: true` if an extension cancelled the switch
|
||||
*/
|
||||
async switchSession(sessionPath: string): Promise<{ cancelled: boolean }> {
|
||||
const response = await this.send({ type: "switch_session", sessionPath });
|
||||
|
|
@ -339,7 +339,7 @@ export class RpcClient {
|
|||
|
||||
/**
|
||||
* Branch from a specific message.
|
||||
* @returns Object with `text` (the message text) and `cancelled` (if hook cancelled)
|
||||
* @returns Object with `text` (the message text) and `cancelled` (if extension cancelled)
|
||||
*/
|
||||
async branch(entryId: string): Promise<{ text: string; cancelled: boolean }> {
|
||||
const response = await this.send({ type: "branch", entryId });
|
||||
|
|
|
|||
|
|
@ -8,25 +8,37 @@
|
|||
* - Commands: JSON objects with `type` field, optional `id` for correlation
|
||||
* - Responses: JSON objects with `type: "response"`, `command`, `success`, and optional `data`/`error`
|
||||
* - Events: AgentSessionEvent objects streamed as they occur
|
||||
* - Hook UI: Hook UI requests are emitted, client responds with hook_ui_response
|
||||
* - Extension UI: Extension UI requests are emitted, client responds with extension_ui_response
|
||||
*/
|
||||
|
||||
import * as crypto from "node:crypto";
|
||||
import * as readline from "readline";
|
||||
import type { AgentSession } from "../../core/agent-session.js";
|
||||
import type { HookUIContext } from "../../core/hooks/index.js";
|
||||
import type { ExtensionUIContext } from "../../core/extensions/index.js";
|
||||
import { theme } from "../interactive/theme/theme.js";
|
||||
import type { RpcCommand, RpcHookUIRequest, RpcHookUIResponse, RpcResponse, RpcSessionState } from "./rpc-types.js";
|
||||
import type {
|
||||
RpcCommand,
|
||||
RpcExtensionUIRequest,
|
||||
RpcExtensionUIResponse,
|
||||
RpcResponse,
|
||||
RpcSessionState,
|
||||
} from "./rpc-types.js";
|
||||
|
||||
// Re-export types for consumers
|
||||
export type { RpcCommand, RpcHookUIRequest, RpcHookUIResponse, RpcResponse, RpcSessionState } from "./rpc-types.js";
|
||||
export type {
|
||||
RpcCommand,
|
||||
RpcExtensionUIRequest,
|
||||
RpcExtensionUIResponse,
|
||||
RpcResponse,
|
||||
RpcSessionState,
|
||||
} from "./rpc-types.js";
|
||||
|
||||
/**
|
||||
* Run in RPC mode.
|
||||
* Listens for JSON commands on stdin, outputs events and responses on stdout.
|
||||
*/
|
||||
export async function runRpcMode(session: AgentSession): Promise<never> {
|
||||
const output = (obj: RpcResponse | RpcHookUIRequest | object) => {
|
||||
const output = (obj: RpcResponse | RpcExtensionUIRequest | object) => {
|
||||
console.log(JSON.stringify(obj));
|
||||
};
|
||||
|
||||
|
|
@ -45,18 +57,21 @@ export async function runRpcMode(session: AgentSession): Promise<never> {
|
|||
return { id, type: "response", command, success: false, error: message };
|
||||
};
|
||||
|
||||
// Pending hook UI requests waiting for response
|
||||
const pendingHookRequests = new Map<string, { resolve: (value: any) => void; reject: (error: Error) => void }>();
|
||||
// Pending extension UI requests waiting for response
|
||||
const pendingExtensionRequests = new Map<
|
||||
string,
|
||||
{ resolve: (value: any) => void; reject: (error: Error) => void }
|
||||
>();
|
||||
|
||||
/**
|
||||
* Create a hook UI context that uses the RPC protocol.
|
||||
* Create an extension UI context that uses the RPC protocol.
|
||||
*/
|
||||
const createHookUIContext = (): HookUIContext => ({
|
||||
const createExtensionUIContext = (): ExtensionUIContext => ({
|
||||
async select(title: string, options: string[]): Promise<string | undefined> {
|
||||
const id = crypto.randomUUID();
|
||||
return new Promise((resolve, reject) => {
|
||||
pendingHookRequests.set(id, {
|
||||
resolve: (response: RpcHookUIResponse) => {
|
||||
pendingExtensionRequests.set(id, {
|
||||
resolve: (response: RpcExtensionUIResponse) => {
|
||||
if ("cancelled" in response && response.cancelled) {
|
||||
resolve(undefined);
|
||||
} else if ("value" in response) {
|
||||
|
|
@ -67,15 +82,15 @@ export async function runRpcMode(session: AgentSession): Promise<never> {
|
|||
},
|
||||
reject,
|
||||
});
|
||||
output({ type: "hook_ui_request", id, method: "select", title, options } as RpcHookUIRequest);
|
||||
output({ type: "extension_ui_request", id, method: "select", title, options } as RpcExtensionUIRequest);
|
||||
});
|
||||
},
|
||||
|
||||
async confirm(title: string, message: string): Promise<boolean> {
|
||||
const id = crypto.randomUUID();
|
||||
return new Promise((resolve, reject) => {
|
||||
pendingHookRequests.set(id, {
|
||||
resolve: (response: RpcHookUIResponse) => {
|
||||
pendingExtensionRequests.set(id, {
|
||||
resolve: (response: RpcExtensionUIResponse) => {
|
||||
if ("cancelled" in response && response.cancelled) {
|
||||
resolve(false);
|
||||
} else if ("confirmed" in response) {
|
||||
|
|
@ -86,15 +101,15 @@ export async function runRpcMode(session: AgentSession): Promise<never> {
|
|||
},
|
||||
reject,
|
||||
});
|
||||
output({ type: "hook_ui_request", id, method: "confirm", title, message } as RpcHookUIRequest);
|
||||
output({ type: "extension_ui_request", id, method: "confirm", title, message } as RpcExtensionUIRequest);
|
||||
});
|
||||
},
|
||||
|
||||
async input(title: string, placeholder?: string): Promise<string | undefined> {
|
||||
const id = crypto.randomUUID();
|
||||
return new Promise((resolve, reject) => {
|
||||
pendingHookRequests.set(id, {
|
||||
resolve: (response: RpcHookUIResponse) => {
|
||||
pendingExtensionRequests.set(id, {
|
||||
resolve: (response: RpcExtensionUIResponse) => {
|
||||
if ("cancelled" in response && response.cancelled) {
|
||||
resolve(undefined);
|
||||
} else if ("value" in response) {
|
||||
|
|
@ -105,42 +120,42 @@ export async function runRpcMode(session: AgentSession): Promise<never> {
|
|||
},
|
||||
reject,
|
||||
});
|
||||
output({ type: "hook_ui_request", id, method: "input", title, placeholder } as RpcHookUIRequest);
|
||||
output({ type: "extension_ui_request", id, method: "input", title, placeholder } as RpcExtensionUIRequest);
|
||||
});
|
||||
},
|
||||
|
||||
notify(message: string, type?: "info" | "warning" | "error"): void {
|
||||
// Fire and forget - no response needed
|
||||
output({
|
||||
type: "hook_ui_request",
|
||||
type: "extension_ui_request",
|
||||
id: crypto.randomUUID(),
|
||||
method: "notify",
|
||||
message,
|
||||
notifyType: type,
|
||||
} as RpcHookUIRequest);
|
||||
} as RpcExtensionUIRequest);
|
||||
},
|
||||
|
||||
setStatus(key: string, text: string | undefined): void {
|
||||
// Fire and forget - no response needed
|
||||
output({
|
||||
type: "hook_ui_request",
|
||||
type: "extension_ui_request",
|
||||
id: crypto.randomUUID(),
|
||||
method: "setStatus",
|
||||
statusKey: key,
|
||||
statusText: text,
|
||||
} as RpcHookUIRequest);
|
||||
} as RpcExtensionUIRequest);
|
||||
},
|
||||
|
||||
setWidget(key: string, content: unknown): void {
|
||||
// Only support string arrays in RPC mode - factory functions are ignored
|
||||
if (content === undefined || Array.isArray(content)) {
|
||||
output({
|
||||
type: "hook_ui_request",
|
||||
type: "extension_ui_request",
|
||||
id: crypto.randomUUID(),
|
||||
method: "setWidget",
|
||||
widgetKey: key,
|
||||
widgetLines: content as string[] | undefined,
|
||||
} as RpcHookUIRequest);
|
||||
} as RpcExtensionUIRequest);
|
||||
}
|
||||
// Component factories are not supported in RPC mode - would need TUI access
|
||||
},
|
||||
|
|
@ -148,11 +163,11 @@ export async function runRpcMode(session: AgentSession): Promise<never> {
|
|||
setTitle(title: string): void {
|
||||
// Fire and forget - host can implement terminal title control
|
||||
output({
|
||||
type: "hook_ui_request",
|
||||
type: "extension_ui_request",
|
||||
id: crypto.randomUUID(),
|
||||
method: "setTitle",
|
||||
title,
|
||||
} as RpcHookUIRequest);
|
||||
} as RpcExtensionUIRequest);
|
||||
},
|
||||
|
||||
async custom() {
|
||||
|
|
@ -163,11 +178,11 @@ export async function runRpcMode(session: AgentSession): Promise<never> {
|
|||
setEditorText(text: string): void {
|
||||
// Fire and forget - host can implement editor control
|
||||
output({
|
||||
type: "hook_ui_request",
|
||||
type: "extension_ui_request",
|
||||
id: crypto.randomUUID(),
|
||||
method: "set_editor_text",
|
||||
text,
|
||||
} as RpcHookUIRequest);
|
||||
} as RpcExtensionUIRequest);
|
||||
},
|
||||
|
||||
getEditorText(): string {
|
||||
|
|
@ -179,8 +194,8 @@ export async function runRpcMode(session: AgentSession): Promise<never> {
|
|||
async editor(title: string, prefill?: string): Promise<string | undefined> {
|
||||
const id = crypto.randomUUID();
|
||||
return new Promise((resolve, reject) => {
|
||||
pendingHookRequests.set(id, {
|
||||
resolve: (response: RpcHookUIResponse) => {
|
||||
pendingExtensionRequests.set(id, {
|
||||
resolve: (response: RpcExtensionUIResponse) => {
|
||||
if ("cancelled" in response && response.cancelled) {
|
||||
resolve(undefined);
|
||||
} else if ("value" in response) {
|
||||
|
|
@ -191,7 +206,7 @@ export async function runRpcMode(session: AgentSession): Promise<never> {
|
|||
},
|
||||
reject,
|
||||
});
|
||||
output({ type: "hook_ui_request", id, method: "editor", title, prefill } as RpcHookUIRequest);
|
||||
output({ type: "extension_ui_request", id, method: "editor", title, prefill } as RpcExtensionUIRequest);
|
||||
});
|
||||
},
|
||||
|
||||
|
|
@ -200,14 +215,14 @@ export async function runRpcMode(session: AgentSession): Promise<never> {
|
|||
},
|
||||
});
|
||||
|
||||
// Set up hooks with RPC-based UI context
|
||||
const hookRunner = session.hookRunner;
|
||||
if (hookRunner) {
|
||||
hookRunner.initialize({
|
||||
// Set up extensions with RPC-based UI context
|
||||
const extensionRunner = session.extensionRunner;
|
||||
if (extensionRunner) {
|
||||
extensionRunner.initialize({
|
||||
getModel: () => session.agent.state.model,
|
||||
sendMessageHandler: (message, options) => {
|
||||
session.sendHookMessage(message, options).catch((e) => {
|
||||
output(error(undefined, "hook_send", e.message));
|
||||
session.sendCustomMessage(message, options).catch((e) => {
|
||||
output(error(undefined, "extension_send", e.message));
|
||||
});
|
||||
},
|
||||
appendEntryHandler: (customType, data) => {
|
||||
|
|
@ -216,45 +231,18 @@ export async function runRpcMode(session: AgentSession): Promise<never> {
|
|||
getActiveToolsHandler: () => session.getActiveToolNames(),
|
||||
getAllToolsHandler: () => session.getAllToolNames(),
|
||||
setActiveToolsHandler: (toolNames: string[]) => session.setActiveToolsByName(toolNames),
|
||||
uiContext: createHookUIContext(),
|
||||
uiContext: createExtensionUIContext(),
|
||||
hasUI: false,
|
||||
});
|
||||
hookRunner.onError((err) => {
|
||||
output({ type: "hook_error", hookPath: err.hookPath, event: err.event, error: err.error });
|
||||
extensionRunner.onError((err) => {
|
||||
output({ type: "extension_error", extensionPath: err.extensionPath, event: err.event, error: err.error });
|
||||
});
|
||||
// Emit session_start event
|
||||
await hookRunner.emit({
|
||||
await extensionRunner.emit({
|
||||
type: "session_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(
|
||||
{
|
||||
previousSessionFile: undefined,
|
||||
reason: "start",
|
||||
},
|
||||
{
|
||||
sessionManager: session.sessionManager,
|
||||
modelRegistry: session.modelRegistry,
|
||||
model: session.model,
|
||||
isIdle: () => !session.isStreaming,
|
||||
hasPendingMessages: () => session.pendingMessageCount > 0,
|
||||
abort: () => {
|
||||
session.abort();
|
||||
},
|
||||
},
|
||||
);
|
||||
} catch (_err) {
|
||||
// Silently ignore tool errors
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Output all agent events as JSON
|
||||
session.subscribe((event) => {
|
||||
output(event);
|
||||
|
|
@ -271,7 +259,7 @@ export async function runRpcMode(session: AgentSession): Promise<never> {
|
|||
|
||||
case "prompt": {
|
||||
// Don't await - events will stream
|
||||
// Hook commands are executed immediately, file slash commands are expanded
|
||||
// Extension commands are executed immediately, file prompt templates are expanded
|
||||
// If streaming and streamingBehavior specified, queues via steer/followUp
|
||||
session
|
||||
.prompt(command.message, {
|
||||
|
|
@ -484,12 +472,12 @@ export async function runRpcMode(session: AgentSession): Promise<never> {
|
|||
try {
|
||||
const parsed = JSON.parse(line);
|
||||
|
||||
// Handle hook UI responses
|
||||
if (parsed.type === "hook_ui_response") {
|
||||
const response = parsed as RpcHookUIResponse;
|
||||
const pending = pendingHookRequests.get(response.id);
|
||||
// Handle extension UI responses
|
||||
if (parsed.type === "extension_ui_response") {
|
||||
const response = parsed as RpcExtensionUIResponse;
|
||||
const pending = pendingExtensionRequests.get(response.id);
|
||||
if (pending) {
|
||||
pendingHookRequests.delete(response.id);
|
||||
pendingExtensionRequests.delete(response.id);
|
||||
pending.resolve(response);
|
||||
}
|
||||
return;
|
||||
|
|
|
|||
|
|
@ -172,42 +172,48 @@ export type RpcResponse =
|
|||
| { id?: string; type: "response"; command: string; success: false; error: string };
|
||||
|
||||
// ============================================================================
|
||||
// Hook UI Events (stdout)
|
||||
// Extension UI Events (stdout)
|
||||
// ============================================================================
|
||||
|
||||
/** Emitted when a hook needs user input */
|
||||
export type RpcHookUIRequest =
|
||||
| { type: "hook_ui_request"; id: string; method: "select"; title: string; options: string[] }
|
||||
| { type: "hook_ui_request"; id: string; method: "confirm"; title: string; message: string }
|
||||
| { type: "hook_ui_request"; id: string; method: "input"; title: string; placeholder?: string }
|
||||
| { type: "hook_ui_request"; id: string; method: "editor"; title: string; prefill?: string }
|
||||
/** Emitted when an extension needs user input */
|
||||
export type RpcExtensionUIRequest =
|
||||
| { type: "extension_ui_request"; id: string; method: "select"; title: string; options: string[] }
|
||||
| { type: "extension_ui_request"; id: string; method: "confirm"; title: string; message: string }
|
||||
| { type: "extension_ui_request"; id: string; method: "input"; title: string; placeholder?: string }
|
||||
| { type: "extension_ui_request"; id: string; method: "editor"; title: string; prefill?: string }
|
||||
| {
|
||||
type: "hook_ui_request";
|
||||
type: "extension_ui_request";
|
||||
id: string;
|
||||
method: "notify";
|
||||
message: string;
|
||||
notifyType?: "info" | "warning" | "error";
|
||||
}
|
||||
| { type: "hook_ui_request"; id: string; method: "setStatus"; statusKey: string; statusText: string | undefined }
|
||||
| {
|
||||
type: "hook_ui_request";
|
||||
type: "extension_ui_request";
|
||||
id: string;
|
||||
method: "setStatus";
|
||||
statusKey: string;
|
||||
statusText: string | undefined;
|
||||
}
|
||||
| {
|
||||
type: "extension_ui_request";
|
||||
id: string;
|
||||
method: "setWidget";
|
||||
widgetKey: string;
|
||||
widgetLines: string[] | undefined;
|
||||
}
|
||||
| { type: "hook_ui_request"; id: string; method: "setTitle"; title: string }
|
||||
| { type: "hook_ui_request"; id: string; method: "set_editor_text"; text: string };
|
||||
| { type: "extension_ui_request"; id: string; method: "setTitle"; title: string }
|
||||
| { type: "extension_ui_request"; id: string; method: "set_editor_text"; text: string };
|
||||
|
||||
// ============================================================================
|
||||
// Hook UI Commands (stdin)
|
||||
// Extension UI Commands (stdin)
|
||||
// ============================================================================
|
||||
|
||||
/** Response to a hook UI request */
|
||||
export type RpcHookUIResponse =
|
||||
| { type: "hook_ui_response"; id: string; value: string }
|
||||
| { type: "hook_ui_response"; id: string; confirmed: boolean }
|
||||
| { type: "hook_ui_response"; id: string; cancelled: true };
|
||||
/** Response to an extension UI request */
|
||||
export type RpcExtensionUIResponse =
|
||||
| { type: "extension_ui_response"; id: string; value: string }
|
||||
| { type: "extension_ui_response"; id: string; confirmed: boolean }
|
||||
| { type: "extension_ui_response"; id: string; cancelled: true };
|
||||
|
||||
// ============================================================================
|
||||
// Helper type for extracting command types
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue