mirror of
https://github.com/getcompanion-ai/co-mono.git
synced 2026-04-16 06:02:42 +00:00
feat(coding-agent): implement hooks system
- Add hooks infrastructure in core/hooks/ (loader, runner, types)
- HookUIContext interface with mode-specific implementations
- Interactive mode: TUI-based selector/input/confirm dialogs
- RPC mode: JSON protocol for hook UI requests/responses
- Print mode: no-op UI context (hooks run but can't prompt)
- AgentSession.branch() now async, returns { selectedText, skipped }
- Settings: hooks[] and hookTimeout configuration
- Export hook types from package for hook authors
Based on PR #147 proposal, adapted for new architecture.
This commit is contained in:
parent
195760d8ee
commit
04d59f31ea
17 changed files with 1264 additions and 126 deletions
|
|
@ -0,0 +1,64 @@
|
|||
/**
|
||||
* Simple text input component for hooks.
|
||||
*/
|
||||
|
||||
import { Container, Input, Spacer, Text } from "@mariozechner/pi-tui";
|
||||
import { theme } from "../theme/theme.js";
|
||||
import { DynamicBorder } from "./dynamic-border.js";
|
||||
|
||||
export class HookInputComponent extends Container {
|
||||
private input: Input;
|
||||
private onSubmitCallback: (value: string) => void;
|
||||
private onCancelCallback: () => void;
|
||||
|
||||
constructor(
|
||||
title: string,
|
||||
_placeholder: string | undefined,
|
||||
onSubmit: (value: string) => void,
|
||||
onCancel: () => void,
|
||||
) {
|
||||
super();
|
||||
|
||||
this.onSubmitCallback = onSubmit;
|
||||
this.onCancelCallback = onCancel;
|
||||
|
||||
// Add top border
|
||||
this.addChild(new DynamicBorder());
|
||||
this.addChild(new Spacer(1));
|
||||
|
||||
// Add title
|
||||
this.addChild(new Text(theme.fg("accent", title), 1, 0));
|
||||
this.addChild(new Spacer(1));
|
||||
|
||||
// Create input
|
||||
this.input = new Input();
|
||||
this.addChild(this.input);
|
||||
|
||||
this.addChild(new Spacer(1));
|
||||
|
||||
// Add hint
|
||||
this.addChild(new Text(theme.fg("dim", "enter submit esc cancel"), 1, 0));
|
||||
|
||||
this.addChild(new Spacer(1));
|
||||
|
||||
// Add bottom border
|
||||
this.addChild(new DynamicBorder());
|
||||
}
|
||||
|
||||
handleInput(keyData: string): void {
|
||||
// Enter
|
||||
if (keyData === "\r" || keyData === "\n") {
|
||||
this.onSubmitCallback(this.input.getValue());
|
||||
return;
|
||||
}
|
||||
|
||||
// Escape to cancel
|
||||
if (keyData === "\x1b") {
|
||||
this.onCancelCallback();
|
||||
return;
|
||||
}
|
||||
|
||||
// Forward to input
|
||||
this.input.handleInput(keyData);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,91 @@
|
|||
/**
|
||||
* Generic selector component for hooks.
|
||||
* Displays a list of string options with keyboard navigation.
|
||||
*/
|
||||
|
||||
import { Container, Spacer, Text } from "@mariozechner/pi-tui";
|
||||
import { theme } from "../theme/theme.js";
|
||||
import { DynamicBorder } from "./dynamic-border.js";
|
||||
|
||||
export class HookSelectorComponent extends Container {
|
||||
private options: string[];
|
||||
private selectedIndex = 0;
|
||||
private listContainer: Container;
|
||||
private onSelectCallback: (option: string) => void;
|
||||
private onCancelCallback: () => void;
|
||||
|
||||
constructor(title: string, options: string[], onSelect: (option: string) => void, onCancel: () => void) {
|
||||
super();
|
||||
|
||||
this.options = options;
|
||||
this.onSelectCallback = onSelect;
|
||||
this.onCancelCallback = onCancel;
|
||||
|
||||
// Add top border
|
||||
this.addChild(new DynamicBorder());
|
||||
this.addChild(new Spacer(1));
|
||||
|
||||
// Add title
|
||||
this.addChild(new Text(theme.fg("accent", title), 1, 0));
|
||||
this.addChild(new Spacer(1));
|
||||
|
||||
// Create list container
|
||||
this.listContainer = new Container();
|
||||
this.addChild(this.listContainer);
|
||||
|
||||
this.addChild(new Spacer(1));
|
||||
|
||||
// Add hint
|
||||
this.addChild(new Text(theme.fg("dim", "↑↓ navigate enter select esc cancel"), 1, 0));
|
||||
|
||||
this.addChild(new Spacer(1));
|
||||
|
||||
// Add bottom border
|
||||
this.addChild(new DynamicBorder());
|
||||
|
||||
// Initial render
|
||||
this.updateList();
|
||||
}
|
||||
|
||||
private updateList(): void {
|
||||
this.listContainer.clear();
|
||||
|
||||
for (let i = 0; i < this.options.length; i++) {
|
||||
const option = this.options[i];
|
||||
const isSelected = i === this.selectedIndex;
|
||||
|
||||
let text = "";
|
||||
if (isSelected) {
|
||||
text = theme.fg("accent", "→ ") + theme.fg("accent", option);
|
||||
} else {
|
||||
text = " " + theme.fg("text", option);
|
||||
}
|
||||
|
||||
this.listContainer.addChild(new Text(text, 1, 0));
|
||||
}
|
||||
}
|
||||
|
||||
handleInput(keyData: string): void {
|
||||
// Up arrow or k
|
||||
if (keyData === "\x1b[A" || keyData === "k") {
|
||||
this.selectedIndex = Math.max(0, this.selectedIndex - 1);
|
||||
this.updateList();
|
||||
}
|
||||
// Down arrow or j
|
||||
else if (keyData === "\x1b[B" || keyData === "j") {
|
||||
this.selectedIndex = Math.min(this.options.length - 1, this.selectedIndex + 1);
|
||||
this.updateList();
|
||||
}
|
||||
// Enter
|
||||
else if (keyData === "\r" || keyData === "\n") {
|
||||
const selected = this.options[this.selectedIndex];
|
||||
if (selected) {
|
||||
this.onSelectCallback(selected);
|
||||
}
|
||||
}
|
||||
// Escape
|
||||
else if (keyData === "\x1b") {
|
||||
this.onCancelCallback();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -25,6 +25,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 { HookUIContext } from "../../core/hooks/index.js";
|
||||
import { isBashExecutionMessage } from "../../core/messages.js";
|
||||
import { invalidateOAuthCache } from "../../core/model-config.js";
|
||||
import { listOAuthProviders, login, logout, type SupportedOAuthProvider } from "../../core/oauth/index.js";
|
||||
|
|
@ -38,6 +39,8 @@ import { CompactionComponent } from "./components/compaction.js";
|
|||
import { CustomEditor } from "./components/custom-editor.js";
|
||||
import { DynamicBorder } from "./components/dynamic-border.js";
|
||||
import { FooterComponent } from "./components/footer.js";
|
||||
import { HookInputComponent } from "./components/hook-input.js";
|
||||
import { HookSelectorComponent } from "./components/hook-selector.js";
|
||||
import { ModelSelectorComponent } from "./components/model-selector.js";
|
||||
import { OAuthSelectorComponent } from "./components/oauth-selector.js";
|
||||
import { QueueModeSelectorComponent } from "./components/queue-mode-selector.js";
|
||||
|
|
@ -98,6 +101,10 @@ export class InteractiveMode {
|
|||
private autoCompactionLoader: Loader | null = null;
|
||||
private autoCompactionEscapeHandler?: () => void;
|
||||
|
||||
// Hook UI state
|
||||
private hookSelector: HookSelectorComponent | null = null;
|
||||
private hookInput: HookInputComponent | null = null;
|
||||
|
||||
// Convenience accessors
|
||||
private get agent() {
|
||||
return this.session.agent;
|
||||
|
|
@ -242,6 +249,9 @@ export class InteractiveMode {
|
|||
this.ui.start();
|
||||
this.isInitialized = true;
|
||||
|
||||
// Initialize hooks with TUI-based UI context
|
||||
await this.initHooks();
|
||||
|
||||
// Subscribe to agent events
|
||||
this.subscribeToAgent();
|
||||
|
||||
|
|
@ -258,6 +268,144 @@ export class InteractiveMode {
|
|||
});
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Hook System
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* Initialize the hook system with TUI-based UI context.
|
||||
*/
|
||||
private async initHooks(): Promise<void> {
|
||||
// Create hook UI context
|
||||
const hookUIContext = this.createHookUIContext();
|
||||
|
||||
// Set context on session
|
||||
this.session.setHookUIContext(hookUIContext, (error) => {
|
||||
this.showHookError(error.hookPath, error.error);
|
||||
});
|
||||
|
||||
// Initialize hooks and report any loading errors
|
||||
const loadErrors = await this.session.initHooks();
|
||||
for (const { path, error } of loadErrors) {
|
||||
this.showHookError(path, error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the UI context for hooks.
|
||||
*/
|
||||
private createHookUIContext(): HookUIContext {
|
||||
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),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Show a selector for hooks.
|
||||
*/
|
||||
private showHookSelector(title: string, options: string[]): Promise<string | null> {
|
||||
return new Promise((resolve) => {
|
||||
this.hookSelector = new HookSelectorComponent(
|
||||
title,
|
||||
options,
|
||||
(option) => {
|
||||
this.hideHookSelector();
|
||||
resolve(option);
|
||||
},
|
||||
() => {
|
||||
this.hideHookSelector();
|
||||
resolve(null);
|
||||
},
|
||||
);
|
||||
|
||||
this.editorContainer.clear();
|
||||
this.editorContainer.addChild(this.hookSelector);
|
||||
this.ui.setFocus(this.hookSelector);
|
||||
this.ui.requestRender();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Hide the hook selector.
|
||||
*/
|
||||
private hideHookSelector(): void {
|
||||
this.editorContainer.clear();
|
||||
this.editorContainer.addChild(this.editor);
|
||||
this.hookSelector = null;
|
||||
this.ui.setFocus(this.editor);
|
||||
this.ui.requestRender();
|
||||
}
|
||||
|
||||
/**
|
||||
* Show a confirmation dialog for hooks.
|
||||
*/
|
||||
private async showHookConfirm(title: string, message: string): Promise<boolean> {
|
||||
const result = await this.showHookSelector(`${title}\n${message}`, ["Yes", "No"]);
|
||||
return result === "Yes";
|
||||
}
|
||||
|
||||
/**
|
||||
* Show a text input for hooks.
|
||||
*/
|
||||
private showHookInput(title: string, placeholder?: string): Promise<string | null> {
|
||||
return new Promise((resolve) => {
|
||||
this.hookInput = new HookInputComponent(
|
||||
title,
|
||||
placeholder,
|
||||
(value) => {
|
||||
this.hideHookInput();
|
||||
resolve(value);
|
||||
},
|
||||
() => {
|
||||
this.hideHookInput();
|
||||
resolve(null);
|
||||
},
|
||||
);
|
||||
|
||||
this.editorContainer.clear();
|
||||
this.editorContainer.addChild(this.hookInput);
|
||||
this.ui.setFocus(this.hookInput);
|
||||
this.ui.requestRender();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Hide the hook input.
|
||||
*/
|
||||
private hideHookInput(): void {
|
||||
this.editorContainer.clear();
|
||||
this.editorContainer.addChild(this.editor);
|
||||
this.hookInput = null;
|
||||
this.ui.setFocus(this.editor);
|
||||
this.ui.requestRender();
|
||||
}
|
||||
|
||||
/**
|
||||
* Show a notification for hooks.
|
||||
*/
|
||||
private showHookNotify(message: string, type?: "info" | "warning" | "error"): void {
|
||||
const color = type === "error" ? "error" : type === "warning" ? "warning" : "dim";
|
||||
const text = new Text(theme.fg(color, `[Hook] ${message}`), 1, 0);
|
||||
this.chatContainer.addChild(text);
|
||||
this.ui.requestRender();
|
||||
}
|
||||
|
||||
/**
|
||||
* Show a hook error in the UI.
|
||||
*/
|
||||
private showHookError(hookPath: string, error: string): void {
|
||||
const errorText = new Text(theme.fg("error", `Hook "${hookPath}" error: ${error}`), 1, 0);
|
||||
this.chatContainer.addChild(errorText);
|
||||
this.ui.requestRender();
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Key Handlers
|
||||
// =========================================================================
|
||||
|
||||
private setupKeyHandlers(): void {
|
||||
this.editor.onEscape = () => {
|
||||
if (this.loadingAnimation) {
|
||||
|
|
@ -1029,12 +1177,18 @@ export class InteractiveMode {
|
|||
this.showSelector((done) => {
|
||||
const selector = new UserMessageSelectorComponent(
|
||||
userMessages.map((m) => ({ index: m.entryIndex, text: m.text })),
|
||||
(entryIndex) => {
|
||||
const selectedText = this.session.branch(entryIndex);
|
||||
async (entryIndex) => {
|
||||
const result = await this.session.branch(entryIndex);
|
||||
if (result.skipped) {
|
||||
// Hook requested to skip conversation restore
|
||||
done();
|
||||
this.ui.requestRender();
|
||||
return;
|
||||
}
|
||||
this.chatContainer.clear();
|
||||
this.isFirstUserMessage = true;
|
||||
this.renderInitialMessages(this.session.state);
|
||||
this.editor.setText(selectedText);
|
||||
this.editor.setText(result.selectedText);
|
||||
done();
|
||||
this.showStatus("Branched to new session");
|
||||
},
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue