mirror of
https://github.com/getcompanion-ai/co-mono.git
synced 2026-04-17 10:02:23 +00:00
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:
parent
16e142ef7d
commit
121823c74d
14 changed files with 405 additions and 36 deletions
|
|
@ -8,8 +8,10 @@
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
|
- `ctx.ui.getAllThemes()`, `ctx.ui.getTheme(name)`, and `ctx.ui.setTheme(name | Theme)` methods for extensions to list, load, and switch themes at runtime ([#576](https://github.com/badlogic/pi-mono/pull/576))
|
||||||
- `--no-tools` flag to disable all built-in tools, allowing extension-only tool setups ([#557](https://github.com/badlogic/pi-mono/pull/557) by [@cv](https://github.com/cv))
|
- `--no-tools` flag to disable all built-in tools, allowing extension-only tool setups ([#557](https://github.com/badlogic/pi-mono/pull/557) by [@cv](https://github.com/cv))
|
||||||
- Pluggable operations for built-in tools enabling remote execution via SSH or other transports ([#564](https://github.com/badlogic/pi-mono/issues/564)). Interfaces: `ReadOperations`, `WriteOperations`, `EditOperations`, `BashOperations`, `LsOperations`, `GrepOperations`, `FindOperations`
|
- Pluggable operations for built-in tools enabling remote execution via SSH or other transports ([#564](https://github.com/badlogic/pi-mono/issues/564)). Interfaces: `ReadOperations`, `WriteOperations`, `EditOperations`, `BashOperations`, `LsOperations`, `GrepOperations`, `FindOperations`
|
||||||
|
- `user_bash` event for intercepting user `!`/`!!` commands, allowing extensions to redirect to remote systems ([#528](https://github.com/badlogic/pi-mono/issues/528))
|
||||||
- `setActiveTools()` in ExtensionAPI for dynamic tool management
|
- `setActiveTools()` in ExtensionAPI for dynamic tool management
|
||||||
- Built-in renderers used automatically for tool overrides without custom `renderCall`/`renderResult`
|
- Built-in renderers used automatically for tool overrides without custom `renderCall`/`renderResult`
|
||||||
- `ssh.ts` example: remote tool execution via `--ssh user@host:/path`
|
- `ssh.ts` example: remote tool execution via `--ssh user@host:/path`
|
||||||
|
|
|
||||||
|
|
@ -523,6 +523,28 @@ pi.on("tool_result", async (event, ctx) => {
|
||||||
|
|
||||||
**Examples:** [git-checkpoint.ts](../examples/extensions/git-checkpoint.ts), [plan-mode.ts](../examples/extensions/plan-mode.ts)
|
**Examples:** [git-checkpoint.ts](../examples/extensions/git-checkpoint.ts), [plan-mode.ts](../examples/extensions/plan-mode.ts)
|
||||||
|
|
||||||
|
### User Bash Events
|
||||||
|
|
||||||
|
#### user_bash
|
||||||
|
|
||||||
|
Fired when user executes `!` or `!!` commands. **Can intercept.**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
pi.on("user_bash", (event, ctx) => {
|
||||||
|
// event.command - the bash command
|
||||||
|
// event.excludeFromContext - true if !! prefix
|
||||||
|
// event.cwd - working directory
|
||||||
|
|
||||||
|
// Option 1: Provide custom operations (e.g., SSH)
|
||||||
|
return { operations: remoteBashOps };
|
||||||
|
|
||||||
|
// Option 2: Full replacement - return result directly
|
||||||
|
return { result: { output: "...", exitCode: 0, cancelled: false, truncated: false } };
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
**Examples:** [ssh.ts](../examples/extensions/ssh.ts)
|
||||||
|
|
||||||
## ExtensionContext
|
## ExtensionContext
|
||||||
|
|
||||||
Every handler receives `ctx: ExtensionContext`:
|
Every handler receives `ctx: ExtensionContext`:
|
||||||
|
|
@ -1256,6 +1278,16 @@ const current = ctx.ui.getEditorText();
|
||||||
// Custom editor (vim mode, emacs mode, etc.)
|
// Custom editor (vim mode, emacs mode, etc.)
|
||||||
ctx.ui.setEditorComponent((tui, theme, keybindings) => new VimEditor(tui, theme, keybindings));
|
ctx.ui.setEditorComponent((tui, theme, keybindings) => new VimEditor(tui, theme, keybindings));
|
||||||
ctx.ui.setEditorComponent(undefined); // Restore default editor
|
ctx.ui.setEditorComponent(undefined); // Restore default editor
|
||||||
|
|
||||||
|
// Theme management
|
||||||
|
const themes = ctx.ui.getAllThemes(); // [{ name: "dark", path: "/..." | undefined }, ...]
|
||||||
|
const lightTheme = ctx.ui.getTheme("light"); // Load without switching
|
||||||
|
const result = ctx.ui.setTheme("light"); // Switch by name
|
||||||
|
if (!result.success) {
|
||||||
|
ctx.ui.notify(`Failed: ${result.error}`, "error");
|
||||||
|
}
|
||||||
|
ctx.ui.setTheme(lightTheme!); // Or switch by Theme object
|
||||||
|
ctx.ui.theme.fg("accent", "styled text"); // Access current theme
|
||||||
```
|
```
|
||||||
|
|
||||||
**Examples:**
|
**Examples:**
|
||||||
|
|
@ -1264,6 +1296,7 @@ ctx.ui.setEditorComponent(undefined); // Restore default editor
|
||||||
- `ctx.ui.setFooter()`: [custom-footer.ts](../examples/extensions/custom-footer.ts)
|
- `ctx.ui.setFooter()`: [custom-footer.ts](../examples/extensions/custom-footer.ts)
|
||||||
- `ctx.ui.setHeader()`: [custom-header.ts](../examples/extensions/custom-header.ts)
|
- `ctx.ui.setHeader()`: [custom-header.ts](../examples/extensions/custom-header.ts)
|
||||||
- `ctx.ui.setEditorComponent()`: [modal-editor.ts](../examples/extensions/modal-editor.ts)
|
- `ctx.ui.setEditorComponent()`: [modal-editor.ts](../examples/extensions/modal-editor.ts)
|
||||||
|
- `ctx.ui.setTheme()`: [mac-system-theme.ts](../examples/extensions/mac-system-theme.ts)
|
||||||
|
|
||||||
### Custom Components
|
### Custom Components
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,25 @@
|
||||||
|
/**
|
||||||
|
* Syncs pi theme with macOS system appearance (dark/light mode).
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* pi -e examples/extensions/mac-system-theme.ts
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { execSync } from "node:child_process";
|
||||||
|
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
||||||
|
|
||||||
|
function isDarkMode(): boolean {
|
||||||
|
try {
|
||||||
|
execSync("defaults read -g AppleInterfaceStyle", { encoding: "utf-8" });
|
||||||
|
return true; // Returns "Dark" if dark mode
|
||||||
|
} catch {
|
||||||
|
return false; // Throws if light mode
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function (pi: ExtensionAPI) {
|
||||||
|
pi.on("session_start", (_event, ctx) => {
|
||||||
|
const theme = isDarkMode() ? "dark" : "light";
|
||||||
|
ctx.ui.setTheme(theme);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -199,6 +199,13 @@ export default function (pi: ExtensionAPI) {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Handle user ! commands via SSH
|
||||||
|
pi.on("user_bash", (_event) => {
|
||||||
|
const ssh = getSsh();
|
||||||
|
if (!ssh) return; // No SSH, use local execution
|
||||||
|
return { operations: createRemoteBashOps(ssh.remote, ssh.remoteCwd, localCwd) };
|
||||||
|
});
|
||||||
|
|
||||||
// Replace local cwd with remote cwd in system prompt
|
// Replace local cwd with remote cwd in system prompt
|
||||||
pi.on("before_agent_start", async (event) => {
|
pi.on("before_agent_start", async (event) => {
|
||||||
const ssh = getSsh();
|
const ssh = getSsh();
|
||||||
|
|
|
||||||
|
|
@ -24,7 +24,7 @@ import type {
|
||||||
import type { AssistantMessage, ImageContent, Message, Model, TextContent } from "@mariozechner/pi-ai";
|
import type { AssistantMessage, ImageContent, Message, Model, TextContent } from "@mariozechner/pi-ai";
|
||||||
import { isContextOverflow, modelsAreEqual, supportsXhigh } from "@mariozechner/pi-ai";
|
import { isContextOverflow, modelsAreEqual, supportsXhigh } from "@mariozechner/pi-ai";
|
||||||
import { getAuthPath } from "../config.js";
|
import { getAuthPath } from "../config.js";
|
||||||
import { type BashResult, executeBash as executeBashCommand } from "./bash-executor.js";
|
import { type BashResult, executeBash as executeBashCommand, executeBashWithOperations } from "./bash-executor.js";
|
||||||
import {
|
import {
|
||||||
type CompactionResult,
|
type CompactionResult,
|
||||||
calculateContextTokens,
|
calculateContextTokens,
|
||||||
|
|
@ -50,6 +50,7 @@ import type { ModelRegistry } from "./model-registry.js";
|
||||||
import { expandPromptTemplate, type PromptTemplate } from "./prompt-templates.js";
|
import { expandPromptTemplate, type PromptTemplate } from "./prompt-templates.js";
|
||||||
import type { BranchSummaryEntry, CompactionEntry, NewSessionOptions, SessionManager } from "./session-manager.js";
|
import type { BranchSummaryEntry, CompactionEntry, NewSessionOptions, SessionManager } from "./session-manager.js";
|
||||||
import type { SettingsManager, SkillsSettings } from "./settings-manager.js";
|
import type { SettingsManager, SkillsSettings } from "./settings-manager.js";
|
||||||
|
import type { BashOperations } from "./tools/bash.js";
|
||||||
|
|
||||||
/** Session-specific events that extend the core AgentEvent */
|
/** Session-specific events that extend the core AgentEvent */
|
||||||
export type AgentSessionEvent =
|
export type AgentSessionEvent =
|
||||||
|
|
@ -1617,51 +1618,63 @@ export class AgentSession {
|
||||||
* @param command The bash command to execute
|
* @param command The bash command to execute
|
||||||
* @param onChunk Optional streaming callback for output
|
* @param onChunk Optional streaming callback for output
|
||||||
* @param options.excludeFromContext If true, command output won't be sent to LLM (!! prefix)
|
* @param options.excludeFromContext If true, command output won't be sent to LLM (!! prefix)
|
||||||
|
* @param options.operations Custom BashOperations for remote execution
|
||||||
*/
|
*/
|
||||||
async executeBash(
|
async executeBash(
|
||||||
command: string,
|
command: string,
|
||||||
onChunk?: (chunk: string) => void,
|
onChunk?: (chunk: string) => void,
|
||||||
options?: { excludeFromContext?: boolean },
|
options?: { excludeFromContext?: boolean; operations?: BashOperations },
|
||||||
): Promise<BashResult> {
|
): Promise<BashResult> {
|
||||||
this._bashAbortController = new AbortController();
|
this._bashAbortController = new AbortController();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await executeBashCommand(command, {
|
const result = options?.operations
|
||||||
onChunk,
|
? await executeBashWithOperations(command, process.cwd(), options.operations, {
|
||||||
signal: this._bashAbortController.signal,
|
onChunk,
|
||||||
});
|
signal: this._bashAbortController.signal,
|
||||||
|
})
|
||||||
// Create and save message
|
: await executeBashCommand(command, {
|
||||||
const bashMessage: BashExecutionMessage = {
|
onChunk,
|
||||||
role: "bashExecution",
|
signal: this._bashAbortController.signal,
|
||||||
command,
|
});
|
||||||
output: result.output,
|
|
||||||
exitCode: result.exitCode,
|
|
||||||
cancelled: result.cancelled,
|
|
||||||
truncated: result.truncated,
|
|
||||||
fullOutputPath: result.fullOutputPath,
|
|
||||||
timestamp: Date.now(),
|
|
||||||
excludeFromContext: options?.excludeFromContext,
|
|
||||||
};
|
|
||||||
|
|
||||||
// If agent is streaming, defer adding to avoid breaking tool_use/tool_result ordering
|
|
||||||
if (this.isStreaming) {
|
|
||||||
// Queue for later - will be flushed on agent_end
|
|
||||||
this._pendingBashMessages.push(bashMessage);
|
|
||||||
} else {
|
|
||||||
// Add to agent state immediately
|
|
||||||
this.agent.appendMessage(bashMessage);
|
|
||||||
|
|
||||||
// Save to session
|
|
||||||
this.sessionManager.appendMessage(bashMessage);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
this.recordBashResult(command, result, options);
|
||||||
return result;
|
return result;
|
||||||
} finally {
|
} finally {
|
||||||
this._bashAbortController = undefined;
|
this._bashAbortController = undefined;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Record a bash execution result in session history.
|
||||||
|
* Used by executeBash and by extensions that handle bash execution themselves.
|
||||||
|
*/
|
||||||
|
recordBashResult(command: string, result: BashResult, options?: { excludeFromContext?: boolean }): void {
|
||||||
|
const bashMessage: BashExecutionMessage = {
|
||||||
|
role: "bashExecution",
|
||||||
|
command,
|
||||||
|
output: result.output,
|
||||||
|
exitCode: result.exitCode,
|
||||||
|
cancelled: result.cancelled,
|
||||||
|
truncated: result.truncated,
|
||||||
|
fullOutputPath: result.fullOutputPath,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
excludeFromContext: options?.excludeFromContext,
|
||||||
|
};
|
||||||
|
|
||||||
|
// If agent is streaming, defer adding to avoid breaking tool_use/tool_result ordering
|
||||||
|
if (this.isStreaming) {
|
||||||
|
// Queue for later - will be flushed on agent_end
|
||||||
|
this._pendingBashMessages.push(bashMessage);
|
||||||
|
} else {
|
||||||
|
// Add to agent state immediately
|
||||||
|
this.agent.appendMessage(bashMessage);
|
||||||
|
|
||||||
|
// Save to session
|
||||||
|
this.sessionManager.appendMessage(bashMessage);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Cancel running bash command.
|
* Cancel running bash command.
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ import { join } from "node:path";
|
||||||
import { type ChildProcess, spawn } from "child_process";
|
import { type ChildProcess, spawn } from "child_process";
|
||||||
import stripAnsi from "strip-ansi";
|
import stripAnsi from "strip-ansi";
|
||||||
import { getShellConfig, killProcessTree, sanitizeBinaryOutput } from "../utils/shell.js";
|
import { getShellConfig, killProcessTree, sanitizeBinaryOutput } from "../utils/shell.js";
|
||||||
|
import type { BashOperations } from "./tools/bash.js";
|
||||||
import { DEFAULT_MAX_BYTES, truncateTail } from "./tools/truncate.js";
|
import { DEFAULT_MAX_BYTES, truncateTail } from "./tools/truncate.js";
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
@ -177,3 +178,98 @@ export function executeBash(command: string, options?: BashExecutorOptions): Pro
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute a bash command using custom BashOperations.
|
||||||
|
* Used for remote execution (SSH, containers, etc.).
|
||||||
|
*/
|
||||||
|
export async function executeBashWithOperations(
|
||||||
|
command: string,
|
||||||
|
cwd: string,
|
||||||
|
operations: BashOperations,
|
||||||
|
options?: BashExecutorOptions,
|
||||||
|
): Promise<BashResult> {
|
||||||
|
const outputChunks: string[] = [];
|
||||||
|
let outputBytes = 0;
|
||||||
|
const maxOutputBytes = DEFAULT_MAX_BYTES * 2;
|
||||||
|
|
||||||
|
let tempFilePath: string | undefined;
|
||||||
|
let tempFileStream: WriteStream | undefined;
|
||||||
|
let totalBytes = 0;
|
||||||
|
|
||||||
|
const onData = (data: Buffer) => {
|
||||||
|
totalBytes += data.length;
|
||||||
|
|
||||||
|
// Sanitize: strip ANSI, replace binary garbage, normalize newlines
|
||||||
|
const text = sanitizeBinaryOutput(stripAnsi(data.toString("utf-8"))).replace(/\r/g, "");
|
||||||
|
|
||||||
|
// Start writing to temp file if exceeds threshold
|
||||||
|
if (totalBytes > DEFAULT_MAX_BYTES && !tempFilePath) {
|
||||||
|
const id = randomBytes(8).toString("hex");
|
||||||
|
tempFilePath = join(tmpdir(), `pi-bash-${id}.log`);
|
||||||
|
tempFileStream = createWriteStream(tempFilePath);
|
||||||
|
for (const chunk of outputChunks) {
|
||||||
|
tempFileStream.write(chunk);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tempFileStream) {
|
||||||
|
tempFileStream.write(text);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keep rolling buffer
|
||||||
|
outputChunks.push(text);
|
||||||
|
outputBytes += text.length;
|
||||||
|
while (outputBytes > maxOutputBytes && outputChunks.length > 1) {
|
||||||
|
const removed = outputChunks.shift()!;
|
||||||
|
outputBytes -= removed.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stream to callback
|
||||||
|
if (options?.onChunk) {
|
||||||
|
options.onChunk(text);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await operations.exec(command, cwd, {
|
||||||
|
onData,
|
||||||
|
signal: options?.signal,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (tempFileStream) {
|
||||||
|
tempFileStream.end();
|
||||||
|
}
|
||||||
|
|
||||||
|
const fullOutput = outputChunks.join("");
|
||||||
|
const truncationResult = truncateTail(fullOutput);
|
||||||
|
const cancelled = options?.signal?.aborted ?? false;
|
||||||
|
|
||||||
|
return {
|
||||||
|
output: truncationResult.truncated ? truncationResult.content : fullOutput,
|
||||||
|
exitCode: cancelled ? undefined : (result.exitCode ?? undefined),
|
||||||
|
cancelled,
|
||||||
|
truncated: truncationResult.truncated,
|
||||||
|
fullOutputPath: tempFilePath,
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
if (tempFileStream) {
|
||||||
|
tempFileStream.end();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if it was an abort
|
||||||
|
if (options?.signal?.aborted) {
|
||||||
|
const fullOutput = outputChunks.join("");
|
||||||
|
const truncationResult = truncateTail(fullOutput);
|
||||||
|
return {
|
||||||
|
output: truncationResult.truncated ? truncationResult.content : fullOutput,
|
||||||
|
exitCode: undefined,
|
||||||
|
cancelled: true,
|
||||||
|
truncated: truncationResult.truncated,
|
||||||
|
fullOutputPath: tempFilePath,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -103,6 +103,9 @@ export type {
|
||||||
TreePreparation,
|
TreePreparation,
|
||||||
TurnEndEvent,
|
TurnEndEvent,
|
||||||
TurnStartEvent,
|
TurnStartEvent,
|
||||||
|
// Events - User Bash
|
||||||
|
UserBashEvent,
|
||||||
|
UserBashEventResult,
|
||||||
WriteToolResultEvent,
|
WriteToolResultEvent,
|
||||||
} from "./types.js";
|
} from "./types.js";
|
||||||
// Type guards
|
// Type guards
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@
|
||||||
import type { AgentMessage } from "@mariozechner/pi-agent-core";
|
import type { AgentMessage } from "@mariozechner/pi-agent-core";
|
||||||
import type { ImageContent, Model } from "@mariozechner/pi-ai";
|
import type { ImageContent, Model } from "@mariozechner/pi-ai";
|
||||||
import type { KeyId } from "@mariozechner/pi-tui";
|
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 { ModelRegistry } from "../model-registry.js";
|
||||||
import type { SessionManager } from "../session-manager.js";
|
import type { SessionManager } from "../session-manager.js";
|
||||||
import type {
|
import type {
|
||||||
|
|
@ -33,6 +33,8 @@ import type {
|
||||||
ToolCallEvent,
|
ToolCallEvent,
|
||||||
ToolCallEventResult,
|
ToolCallEventResult,
|
||||||
ToolResultEventResult,
|
ToolResultEventResult,
|
||||||
|
UserBashEvent,
|
||||||
|
UserBashEventResult,
|
||||||
} from "./types.js";
|
} from "./types.js";
|
||||||
|
|
||||||
/** Combined result from all before_agent_start handlers */
|
/** Combined result from all before_agent_start handlers */
|
||||||
|
|
@ -89,6 +91,9 @@ const noOpUIContext: ExtensionUIContext = {
|
||||||
get theme() {
|
get theme() {
|
||||||
return theme;
|
return theme;
|
||||||
},
|
},
|
||||||
|
getAllThemes: () => [],
|
||||||
|
getTheme: () => undefined,
|
||||||
|
setTheme: (_theme: string | Theme) => ({ success: false, error: "UI not available" }),
|
||||||
};
|
};
|
||||||
|
|
||||||
export class ExtensionRunner {
|
export class ExtensionRunner {
|
||||||
|
|
@ -399,6 +404,35 @@ export class ExtensionRunner {
|
||||||
return result;
|
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[]> {
|
async emitContext(messages: AgentMessage[]): Promise<AgentMessage[]> {
|
||||||
const ctx = this.createContext();
|
const ctx = this.createContext();
|
||||||
let currentMessages = structuredClone(messages);
|
let currentMessages = structuredClone(messages);
|
||||||
|
|
|
||||||
|
|
@ -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 { Component, EditorComponent, EditorTheme, KeyId, TUI } from "@mariozechner/pi-tui";
|
||||||
import type { Static, TSchema } from "@sinclair/typebox";
|
import type { Static, TSchema } from "@sinclair/typebox";
|
||||||
import type { Theme } from "../../modes/interactive/theme/theme.js";
|
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 { CompactionPreparation, CompactionResult } from "../compaction/index.js";
|
||||||
import type { EventBus } from "../event-bus.js";
|
import type { EventBus } from "../event-bus.js";
|
||||||
import type { ExecOptions, ExecResult } from "../exec.js";
|
import type { ExecOptions, ExecResult } from "../exec.js";
|
||||||
|
|
@ -31,6 +32,7 @@ import type {
|
||||||
SessionEntry,
|
SessionEntry,
|
||||||
SessionManager,
|
SessionManager,
|
||||||
} from "../session-manager.js";
|
} from "../session-manager.js";
|
||||||
|
import type { BashOperations } from "../tools/bash.js";
|
||||||
import type { EditToolDetails } from "../tools/edit.js";
|
import type { EditToolDetails } from "../tools/edit.js";
|
||||||
import type {
|
import type {
|
||||||
BashToolDetails,
|
BashToolDetails,
|
||||||
|
|
@ -147,6 +149,15 @@ export interface ExtensionUIContext {
|
||||||
|
|
||||||
/** Get the current theme for styling. */
|
/** Get the current theme for styling. */
|
||||||
readonly theme: Theme;
|
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[];
|
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
|
// Tool Events
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
@ -481,6 +507,7 @@ export type ExtensionEvent =
|
||||||
| AgentEndEvent
|
| AgentEndEvent
|
||||||
| TurnStartEvent
|
| TurnStartEvent
|
||||||
| TurnEndEvent
|
| TurnEndEvent
|
||||||
|
| UserBashEvent
|
||||||
| ToolCallEvent
|
| ToolCallEvent
|
||||||
| ToolResultEvent;
|
| ToolResultEvent;
|
||||||
|
|
||||||
|
|
@ -497,6 +524,14 @@ export interface ToolCallEventResult {
|
||||||
reason?: string;
|
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 {
|
export interface ToolResultEventResult {
|
||||||
content?: (TextContent | ImageContent)[];
|
content?: (TextContent | ImageContent)[];
|
||||||
details?: unknown;
|
details?: unknown;
|
||||||
|
|
@ -598,6 +633,7 @@ export interface ExtensionAPI {
|
||||||
on(event: "turn_end", handler: ExtensionHandler<TurnEndEvent>): void;
|
on(event: "turn_end", handler: ExtensionHandler<TurnEndEvent>): void;
|
||||||
on(event: "tool_call", handler: ExtensionHandler<ToolCallEvent, ToolCallEventResult>): void;
|
on(event: "tool_call", handler: ExtensionHandler<ToolCallEvent, ToolCallEventResult>): void;
|
||||||
on(event: "tool_result", handler: ExtensionHandler<ToolResultEvent, ToolResultEventResult>): void;
|
on(event: "tool_result", handler: ExtensionHandler<ToolResultEvent, ToolResultEventResult>): void;
|
||||||
|
on(event: "user_bash", handler: ExtensionHandler<UserBashEvent, UserBashEventResult>): void;
|
||||||
|
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
// Tool Registration
|
// Tool Registration
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,7 @@ export {
|
||||||
type PromptOptions,
|
type PromptOptions,
|
||||||
type SessionStats,
|
type SessionStats,
|
||||||
} from "./agent-session.js";
|
} from "./agent-session.js";
|
||||||
export { type BashExecutorOptions, type BashResult, executeBash } from "./bash-executor.js";
|
export { type BashExecutorOptions, type BashResult, executeBash, executeBashWithOperations } from "./bash-executor.js";
|
||||||
export type { CompactionResult } from "./compaction/index.js";
|
export type { CompactionResult } from "./compaction/index.js";
|
||||||
export { createEventBus, type EventBus, type EventBusController } from "./event-bus.js";
|
export { createEventBus, type EventBus, type EventBusController } from "./event-bus.js";
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -83,6 +83,8 @@ export type {
|
||||||
ToolResultEvent,
|
ToolResultEvent,
|
||||||
TurnEndEvent,
|
TurnEndEvent,
|
||||||
TurnStartEvent,
|
TurnStartEvent,
|
||||||
|
UserBashEvent,
|
||||||
|
UserBashEventResult,
|
||||||
} from "./core/extensions/index.js";
|
} from "./core/extensions/index.js";
|
||||||
export {
|
export {
|
||||||
createExtensionRuntime,
|
createExtensionRuntime,
|
||||||
|
|
|
||||||
|
|
@ -75,12 +75,15 @@ import { UserMessageComponent } from "./components/user-message.js";
|
||||||
import { UserMessageSelectorComponent } from "./components/user-message-selector.js";
|
import { UserMessageSelectorComponent } from "./components/user-message-selector.js";
|
||||||
import {
|
import {
|
||||||
getAvailableThemes,
|
getAvailableThemes,
|
||||||
|
getAvailableThemesWithPaths,
|
||||||
getEditorTheme,
|
getEditorTheme,
|
||||||
getMarkdownTheme,
|
getMarkdownTheme,
|
||||||
|
getThemeByName,
|
||||||
initTheme,
|
initTheme,
|
||||||
onThemeChange,
|
onThemeChange,
|
||||||
setTheme,
|
setTheme,
|
||||||
type Theme,
|
setThemeInstance,
|
||||||
|
Theme,
|
||||||
theme,
|
theme,
|
||||||
} from "./theme/theme.js";
|
} from "./theme/theme.js";
|
||||||
|
|
||||||
|
|
@ -937,6 +940,20 @@ export class InteractiveMode {
|
||||||
get theme() {
|
get theme() {
|
||||||
return theme;
|
return theme;
|
||||||
},
|
},
|
||||||
|
getAllThemes: () => getAvailableThemesWithPaths(),
|
||||||
|
getTheme: (name) => getThemeByName(name),
|
||||||
|
setTheme: (themeOrName) => {
|
||||||
|
if (themeOrName instanceof Theme) {
|
||||||
|
setThemeInstance(themeOrName);
|
||||||
|
this.ui.requestRender();
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
const result = setTheme(themeOrName, true);
|
||||||
|
if (result.success) {
|
||||||
|
this.ui.requestRender();
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -3140,6 +3157,50 @@ export class InteractiveMode {
|
||||||
}
|
}
|
||||||
|
|
||||||
private async handleBashCommand(command: string, excludeFromContext = false): Promise<void> {
|
private async handleBashCommand(command: string, excludeFromContext = false): Promise<void> {
|
||||||
|
const extensionRunner = this.session.extensionRunner;
|
||||||
|
|
||||||
|
// Emit user_bash event to let extensions intercept
|
||||||
|
const eventResult = extensionRunner
|
||||||
|
? await extensionRunner.emitUserBash({
|
||||||
|
type: "user_bash",
|
||||||
|
command,
|
||||||
|
excludeFromContext,
|
||||||
|
cwd: process.cwd(),
|
||||||
|
})
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
// If extension returned a full result, use it directly
|
||||||
|
if (eventResult?.result) {
|
||||||
|
const result = eventResult.result;
|
||||||
|
|
||||||
|
// Create UI component for display
|
||||||
|
this.bashComponent = new BashExecutionComponent(command, this.ui, excludeFromContext);
|
||||||
|
if (this.session.isStreaming) {
|
||||||
|
this.pendingMessagesContainer.addChild(this.bashComponent);
|
||||||
|
this.pendingBashComponents.push(this.bashComponent);
|
||||||
|
} else {
|
||||||
|
this.chatContainer.addChild(this.bashComponent);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show output and complete
|
||||||
|
if (result.output) {
|
||||||
|
this.bashComponent.appendOutput(result.output);
|
||||||
|
}
|
||||||
|
this.bashComponent.setComplete(
|
||||||
|
result.exitCode,
|
||||||
|
result.cancelled,
|
||||||
|
result.truncated ? ({ truncated: true, content: result.output } as TruncationResult) : undefined,
|
||||||
|
result.fullOutputPath,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Record the result in session
|
||||||
|
this.session.recordBashResult(command, result, { excludeFromContext });
|
||||||
|
this.bashComponent = undefined;
|
||||||
|
this.ui.requestRender();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normal execution path (possibly with custom operations)
|
||||||
const isDeferred = this.session.isStreaming;
|
const isDeferred = this.session.isStreaming;
|
||||||
this.bashComponent = new BashExecutionComponent(command, this.ui, excludeFromContext);
|
this.bashComponent = new BashExecutionComponent(command, this.ui, excludeFromContext);
|
||||||
|
|
||||||
|
|
@ -3162,7 +3223,7 @@ export class InteractiveMode {
|
||||||
this.ui.requestRender();
|
this.ui.requestRender();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{ excludeFromContext },
|
{ excludeFromContext, operations: eventResult?.operations },
|
||||||
);
|
);
|
||||||
|
|
||||||
if (this.bashComponent) {
|
if (this.bashComponent) {
|
||||||
|
|
|
||||||
|
|
@ -456,6 +456,36 @@ export function getAvailableThemes(): string[] {
|
||||||
return Array.from(themes).sort();
|
return Array.from(themes).sort();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ThemeInfo {
|
||||||
|
name: string;
|
||||||
|
path: string | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAvailableThemesWithPaths(): ThemeInfo[] {
|
||||||
|
const themesDir = getThemesDir();
|
||||||
|
const customThemesDir = getCustomThemesDir();
|
||||||
|
const result: ThemeInfo[] = [];
|
||||||
|
|
||||||
|
// Built-in themes
|
||||||
|
for (const name of Object.keys(getBuiltinThemes())) {
|
||||||
|
result.push({ name, path: path.join(themesDir, `${name}.json`) });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Custom themes
|
||||||
|
if (fs.existsSync(customThemesDir)) {
|
||||||
|
for (const file of fs.readdirSync(customThemesDir)) {
|
||||||
|
if (file.endsWith(".json")) {
|
||||||
|
const name = file.slice(0, -5);
|
||||||
|
if (!result.some((t) => t.name === name)) {
|
||||||
|
result.push({ name, path: path.join(customThemesDir, file) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result.sort((a, b) => a.name.localeCompare(b.name));
|
||||||
|
}
|
||||||
|
|
||||||
function loadThemeJson(name: string): ThemeJson {
|
function loadThemeJson(name: string): ThemeJson {
|
||||||
const builtinThemes = getBuiltinThemes();
|
const builtinThemes = getBuiltinThemes();
|
||||||
if (name in builtinThemes) {
|
if (name in builtinThemes) {
|
||||||
|
|
@ -532,6 +562,14 @@ function loadTheme(name: string, mode?: ColorMode): Theme {
|
||||||
return createTheme(themeJson, mode);
|
return createTheme(themeJson, mode);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getThemeByName(name: string): Theme | undefined {
|
||||||
|
try {
|
||||||
|
return loadTheme(name);
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function detectTerminalBackground(): "dark" | "light" {
|
function detectTerminalBackground(): "dark" | "light" {
|
||||||
const colorfgbg = process.env.COLORFGBG || "";
|
const colorfgbg = process.env.COLORFGBG || "";
|
||||||
if (colorfgbg) {
|
if (colorfgbg) {
|
||||||
|
|
@ -596,6 +634,12 @@ export function setTheme(name: string, enableWatcher: boolean = false): { succes
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function setThemeInstance(themeInstance: Theme): void {
|
||||||
|
theme = themeInstance;
|
||||||
|
currentThemeName = "<in-memory>";
|
||||||
|
stopThemeWatcher(); // Can't watch a direct instance
|
||||||
|
}
|
||||||
|
|
||||||
export function onThemeChange(callback: () => void): void {
|
export function onThemeChange(callback: () => void): void {
|
||||||
onThemeChangeCallback = callback;
|
onThemeChangeCallback = callback;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,7 @@ import * as crypto from "node:crypto";
|
||||||
import * as readline from "readline";
|
import * as readline from "readline";
|
||||||
import type { AgentSession } from "../../core/agent-session.js";
|
import type { AgentSession } from "../../core/agent-session.js";
|
||||||
import type { ExtensionUIContext, ExtensionUIDialogOptions } from "../../core/extensions/index.js";
|
import type { ExtensionUIContext, ExtensionUIDialogOptions } from "../../core/extensions/index.js";
|
||||||
import { theme } from "../interactive/theme/theme.js";
|
import { type Theme, theme } from "../interactive/theme/theme.js";
|
||||||
import type {
|
import type {
|
||||||
RpcCommand,
|
RpcCommand,
|
||||||
RpcExtensionUIRequest,
|
RpcExtensionUIRequest,
|
||||||
|
|
@ -229,6 +229,19 @@ export async function runRpcMode(session: AgentSession): Promise<never> {
|
||||||
get theme() {
|
get theme() {
|
||||||
return theme;
|
return theme;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
getAllThemes() {
|
||||||
|
return [];
|
||||||
|
},
|
||||||
|
|
||||||
|
getTheme(_name: string) {
|
||||||
|
return undefined;
|
||||||
|
},
|
||||||
|
|
||||||
|
setTheme(_theme: string | Theme) {
|
||||||
|
// Theme switching not supported in RPC mode
|
||||||
|
return { success: false, error: "Theme switching not supported in RPC mode" };
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Set up extensions with RPC-based UI context
|
// Set up extensions with RPC-based UI context
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue