From 121823c74de80ad125e56a942154c1e4026a952d Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Thu, 8 Jan 2026 21:50:56 +0100 Subject: [PATCH] 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 --- packages/coding-agent/CHANGELOG.md | 2 + packages/coding-agent/docs/extensions.md | 33 +++++++ .../examples/extensions/mac-system-theme.ts | 25 +++++ .../coding-agent/examples/extensions/ssh.ts | 7 ++ .../coding-agent/src/core/agent-session.ts | 75 +++++++++------ .../coding-agent/src/core/bash-executor.ts | 96 +++++++++++++++++++ .../coding-agent/src/core/extensions/index.ts | 3 + .../src/core/extensions/runner.ts | 36 ++++++- .../coding-agent/src/core/extensions/types.ts | 36 +++++++ packages/coding-agent/src/core/index.ts | 2 +- packages/coding-agent/src/index.ts | 2 + .../src/modes/interactive/interactive-mode.ts | 65 ++++++++++++- .../src/modes/interactive/theme/theme.ts | 44 +++++++++ .../coding-agent/src/modes/rpc/rpc-mode.ts | 15 ++- 14 files changed, 405 insertions(+), 36 deletions(-) create mode 100644 packages/coding-agent/examples/extensions/mac-system-theme.ts diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 0a163443..b480ae77 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -8,8 +8,10 @@ ### 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)) - 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 - Built-in renderers used automatically for tool overrides without custom `renderCall`/`renderResult` - `ssh.ts` example: remote tool execution via `--ssh user@host:/path` diff --git a/packages/coding-agent/docs/extensions.md b/packages/coding-agent/docs/extensions.md index 7ec51839..5b3b2ab0 100644 --- a/packages/coding-agent/docs/extensions.md +++ b/packages/coding-agent/docs/extensions.md @@ -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) +### 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 Every handler receives `ctx: ExtensionContext`: @@ -1256,6 +1278,16 @@ const current = ctx.ui.getEditorText(); // Custom editor (vim mode, emacs mode, etc.) ctx.ui.setEditorComponent((tui, theme, keybindings) => new VimEditor(tui, theme, keybindings)); 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:** @@ -1264,6 +1296,7 @@ ctx.ui.setEditorComponent(undefined); // Restore default editor - `ctx.ui.setFooter()`: [custom-footer.ts](../examples/extensions/custom-footer.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.setTheme()`: [mac-system-theme.ts](../examples/extensions/mac-system-theme.ts) ### Custom Components diff --git a/packages/coding-agent/examples/extensions/mac-system-theme.ts b/packages/coding-agent/examples/extensions/mac-system-theme.ts new file mode 100644 index 00000000..9c765712 --- /dev/null +++ b/packages/coding-agent/examples/extensions/mac-system-theme.ts @@ -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); + }); +} diff --git a/packages/coding-agent/examples/extensions/ssh.ts b/packages/coding-agent/examples/extensions/ssh.ts index 5d182266..5c2eb217 100644 --- a/packages/coding-agent/examples/extensions/ssh.ts +++ b/packages/coding-agent/examples/extensions/ssh.ts @@ -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 pi.on("before_agent_start", async (event) => { const ssh = getSsh(); diff --git a/packages/coding-agent/src/core/agent-session.ts b/packages/coding-agent/src/core/agent-session.ts index 2d4444c7..5526bdaa 100644 --- a/packages/coding-agent/src/core/agent-session.ts +++ b/packages/coding-agent/src/core/agent-session.ts @@ -24,7 +24,7 @@ import type { import type { AssistantMessage, ImageContent, Message, Model, TextContent } from "@mariozechner/pi-ai"; import { isContextOverflow, modelsAreEqual, supportsXhigh } from "@mariozechner/pi-ai"; 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 { type CompactionResult, calculateContextTokens, @@ -50,6 +50,7 @@ import type { ModelRegistry } from "./model-registry.js"; import { expandPromptTemplate, type PromptTemplate } from "./prompt-templates.js"; import type { BranchSummaryEntry, CompactionEntry, NewSessionOptions, SessionManager } from "./session-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 */ export type AgentSessionEvent = @@ -1617,51 +1618,63 @@ export class AgentSession { * @param command The bash command to execute * @param onChunk Optional streaming callback for output * @param options.excludeFromContext If true, command output won't be sent to LLM (!! prefix) + * @param options.operations Custom BashOperations for remote execution */ async executeBash( command: string, onChunk?: (chunk: string) => void, - options?: { excludeFromContext?: boolean }, + options?: { excludeFromContext?: boolean; operations?: BashOperations }, ): Promise { this._bashAbortController = new AbortController(); try { - const result = await executeBashCommand(command, { - onChunk, - signal: this._bashAbortController.signal, - }); - - // Create and save message - 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); - } + const result = options?.operations + ? await executeBashWithOperations(command, process.cwd(), options.operations, { + onChunk, + signal: this._bashAbortController.signal, + }) + : await executeBashCommand(command, { + onChunk, + signal: this._bashAbortController.signal, + }); + this.recordBashResult(command, result, options); return result; } finally { 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. */ diff --git a/packages/coding-agent/src/core/bash-executor.ts b/packages/coding-agent/src/core/bash-executor.ts index 59ae546f..12da78b1 100644 --- a/packages/coding-agent/src/core/bash-executor.ts +++ b/packages/coding-agent/src/core/bash-executor.ts @@ -13,6 +13,7 @@ import { join } from "node:path"; import { type ChildProcess, spawn } from "child_process"; import stripAnsi from "strip-ansi"; import { getShellConfig, killProcessTree, sanitizeBinaryOutput } from "../utils/shell.js"; +import type { BashOperations } from "./tools/bash.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 { + 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; + } +} diff --git a/packages/coding-agent/src/core/extensions/index.ts b/packages/coding-agent/src/core/extensions/index.ts index ec2aa1e1..8a831f79 100644 --- a/packages/coding-agent/src/core/extensions/index.ts +++ b/packages/coding-agent/src/core/extensions/index.ts @@ -103,6 +103,9 @@ export type { TreePreparation, TurnEndEvent, TurnStartEvent, + // Events - User Bash + UserBashEvent, + UserBashEventResult, WriteToolResultEvent, } from "./types.js"; // Type guards diff --git a/packages/coding-agent/src/core/extensions/runner.ts b/packages/coding-agent/src/core/extensions/runner.ts index 733f7642..e9ddf4c5 100644 --- a/packages/coding-agent/src/core/extensions/runner.ts +++ b/packages/coding-agent/src/core/extensions/runner.ts @@ -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 { + 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 { const ctx = this.createContext(); let currentMessages = structuredClone(messages); diff --git a/packages/coding-agent/src/core/extensions/types.ts b/packages/coding-agent/src/core/extensions/types.ts index d2469e5c..2edc4c77 100644 --- a/packages/coding-agent/src/core/extensions/types.ts +++ b/packages/coding-agent/src/core/extensions/types.ts @@ -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): void; on(event: "tool_call", handler: ExtensionHandler): void; on(event: "tool_result", handler: ExtensionHandler): void; + on(event: "user_bash", handler: ExtensionHandler): void; // ========================================================================= // Tool Registration diff --git a/packages/coding-agent/src/core/index.ts b/packages/coding-agent/src/core/index.ts index 6aa4276c..5161194b 100644 --- a/packages/coding-agent/src/core/index.ts +++ b/packages/coding-agent/src/core/index.ts @@ -11,7 +11,7 @@ export { type PromptOptions, type SessionStats, } 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 { createEventBus, type EventBus, type EventBusController } from "./event-bus.js"; diff --git a/packages/coding-agent/src/index.ts b/packages/coding-agent/src/index.ts index 7bf128bb..d2408ae9 100644 --- a/packages/coding-agent/src/index.ts +++ b/packages/coding-agent/src/index.ts @@ -83,6 +83,8 @@ export type { ToolResultEvent, TurnEndEvent, TurnStartEvent, + UserBashEvent, + UserBashEventResult, } from "./core/extensions/index.js"; export { createExtensionRuntime, diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index 4f31733b..0fc3cf38 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -75,12 +75,15 @@ import { UserMessageComponent } from "./components/user-message.js"; import { UserMessageSelectorComponent } from "./components/user-message-selector.js"; import { getAvailableThemes, + getAvailableThemesWithPaths, getEditorTheme, getMarkdownTheme, + getThemeByName, initTheme, onThemeChange, setTheme, - type Theme, + setThemeInstance, + Theme, theme, } from "./theme/theme.js"; @@ -937,6 +940,20 @@ export class InteractiveMode { get 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 { + 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; this.bashComponent = new BashExecutionComponent(command, this.ui, excludeFromContext); @@ -3162,7 +3223,7 @@ export class InteractiveMode { this.ui.requestRender(); } }, - { excludeFromContext }, + { excludeFromContext, operations: eventResult?.operations }, ); if (this.bashComponent) { diff --git a/packages/coding-agent/src/modes/interactive/theme/theme.ts b/packages/coding-agent/src/modes/interactive/theme/theme.ts index ae291813..d3fa6896 100644 --- a/packages/coding-agent/src/modes/interactive/theme/theme.ts +++ b/packages/coding-agent/src/modes/interactive/theme/theme.ts @@ -456,6 +456,36 @@ export function getAvailableThemes(): string[] { 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 { const builtinThemes = getBuiltinThemes(); if (name in builtinThemes) { @@ -532,6 +562,14 @@ function loadTheme(name: string, mode?: ColorMode): Theme { return createTheme(themeJson, mode); } +export function getThemeByName(name: string): Theme | undefined { + try { + return loadTheme(name); + } catch { + return undefined; + } +} + function detectTerminalBackground(): "dark" | "light" { const colorfgbg = process.env.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 = ""; + stopThemeWatcher(); // Can't watch a direct instance +} + export function onThemeChange(callback: () => void): void { onThemeChangeCallback = callback; } diff --git a/packages/coding-agent/src/modes/rpc/rpc-mode.ts b/packages/coding-agent/src/modes/rpc/rpc-mode.ts index 1d044a74..a748b406 100644 --- a/packages/coding-agent/src/modes/rpc/rpc-mode.ts +++ b/packages/coding-agent/src/modes/rpc/rpc-mode.ts @@ -15,7 +15,7 @@ import * as crypto from "node:crypto"; import * as readline from "readline"; import type { AgentSession } from "../../core/agent-session.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 { RpcCommand, RpcExtensionUIRequest, @@ -229,6 +229,19 @@ export async function runRpcMode(session: AgentSession): Promise { get 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