feat(coding-agent): add user_bash event and theme API extensions

- user_bash event for intercepting ! and !! commands (#528)
- Extensions can return { operations } or { result } to redirect/replace
- executeBashWithOperations() for custom BashOperations execution
- session.recordBashResult() for extensions handling bash themselves
- Theme API: getAllThemes(), getTheme(), setTheme() on ctx.ui
- mac-system-theme.ts example: sync with macOS dark/light mode
- Updated ssh.ts to use user_bash event
This commit is contained in:
Mario Zechner 2026-01-08 21:50:56 +01:00
parent 16e142ef7d
commit 121823c74d
14 changed files with 405 additions and 36 deletions

View file

@ -103,6 +103,9 @@ export type {
TreePreparation,
TurnEndEvent,
TurnStartEvent,
// Events - User Bash
UserBashEvent,
UserBashEventResult,
WriteToolResultEvent,
} from "./types.js";
// Type guards

View file

@ -5,7 +5,7 @@
import type { AgentMessage } from "@mariozechner/pi-agent-core";
import type { ImageContent, Model } from "@mariozechner/pi-ai";
import type { KeyId } from "@mariozechner/pi-tui";
import { theme } from "../../modes/interactive/theme/theme.js";
import { type Theme, theme } from "../../modes/interactive/theme/theme.js";
import type { ModelRegistry } from "../model-registry.js";
import type { SessionManager } from "../session-manager.js";
import type {
@ -33,6 +33,8 @@ import type {
ToolCallEvent,
ToolCallEventResult,
ToolResultEventResult,
UserBashEvent,
UserBashEventResult,
} from "./types.js";
/** Combined result from all before_agent_start handlers */
@ -89,6 +91,9 @@ const noOpUIContext: ExtensionUIContext = {
get theme() {
return theme;
},
getAllThemes: () => [],
getTheme: () => undefined,
setTheme: (_theme: string | Theme) => ({ success: false, error: "UI not available" }),
};
export class ExtensionRunner {
@ -399,6 +404,35 @@ export class ExtensionRunner {
return result;
}
async emitUserBash(event: UserBashEvent): Promise<UserBashEventResult | undefined> {
const ctx = this.createContext();
for (const ext of this.extensions) {
const handlers = ext.handlers.get("user_bash");
if (!handlers || handlers.length === 0) continue;
for (const handler of handlers) {
try {
const handlerResult = await handler(event, ctx);
if (handlerResult) {
return handlerResult as UserBashEventResult;
}
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
const stack = err instanceof Error ? err.stack : undefined;
this.emitError({
extensionPath: ext.path,
event: "user_bash",
error: message,
stack,
});
}
}
}
return undefined;
}
async emitContext(messages: AgentMessage[]): Promise<AgentMessage[]> {
const ctx = this.createContext();
let currentMessages = structuredClone(messages);

View file

@ -18,6 +18,7 @@ import type { ImageContent, Model, TextContent, ToolResultMessage } from "@mario
import type { Component, EditorComponent, EditorTheme, KeyId, TUI } from "@mariozechner/pi-tui";
import type { Static, TSchema } from "@sinclair/typebox";
import type { Theme } from "../../modes/interactive/theme/theme.js";
import type { BashResult } from "../bash-executor.js";
import type { CompactionPreparation, CompactionResult } from "../compaction/index.js";
import type { EventBus } from "../event-bus.js";
import type { ExecOptions, ExecResult } from "../exec.js";
@ -31,6 +32,7 @@ import type {
SessionEntry,
SessionManager,
} from "../session-manager.js";
import type { BashOperations } from "../tools/bash.js";
import type { EditToolDetails } from "../tools/edit.js";
import type {
BashToolDetails,
@ -147,6 +149,15 @@ export interface ExtensionUIContext {
/** Get the current theme for styling. */
readonly theme: Theme;
/** Get all available themes with their names and file paths. */
getAllThemes(): { name: string; path: string | undefined }[];
/** Load a theme by name without switching to it. Returns undefined if not found. */
getTheme(name: string): Theme | undefined;
/** Set the current theme by name or Theme object. */
setTheme(theme: string | Theme): { success: boolean; error?: string };
}
// ============================================================================
@ -378,6 +389,21 @@ export interface TurnEndEvent {
toolResults: ToolResultMessage[];
}
// ============================================================================
// User Bash Events
// ============================================================================
/** Fired when user executes a bash command via ! or !! prefix */
export interface UserBashEvent {
type: "user_bash";
/** The command to execute */
command: string;
/** True if !! prefix was used (excluded from LLM context) */
excludeFromContext: boolean;
/** Current working directory */
cwd: string;
}
// ============================================================================
// Tool Events
// ============================================================================
@ -481,6 +507,7 @@ export type ExtensionEvent =
| AgentEndEvent
| TurnStartEvent
| TurnEndEvent
| UserBashEvent
| ToolCallEvent
| ToolResultEvent;
@ -497,6 +524,14 @@ export interface ToolCallEventResult {
reason?: string;
}
/** Result from user_bash event handler */
export interface UserBashEventResult {
/** Custom operations to use for execution */
operations?: BashOperations;
/** Full replacement: extension handled execution, use this result */
result?: BashResult;
}
export interface ToolResultEventResult {
content?: (TextContent | ImageContent)[];
details?: unknown;
@ -598,6 +633,7 @@ export interface ExtensionAPI {
on(event: "turn_end", handler: ExtensionHandler<TurnEndEvent>): void;
on(event: "tool_call", handler: ExtensionHandler<ToolCallEvent, ToolCallEventResult>): void;
on(event: "tool_result", handler: ExtensionHandler<ToolResultEvent, ToolResultEventResult>): void;
on(event: "user_bash", handler: ExtensionHandler<UserBashEvent, UserBashEventResult>): void;
// =========================================================================
// Tool Registration