From 180c5d6a69a299aac1fab10de4b5fb6d756ccfaf Mon Sep 17 00:00:00 2001 From: BT Odoy Date: Wed, 8 Apr 2026 15:49:32 -0400 Subject: [PATCH 1/9] fix: resolve Claude Agent SDK cli path in packaged builds Resolve the unpacked @anthropic-ai/claude-agent-sdk cli.js before falling back to the legacy claude-code path, fixing agent startup failures in packaged apps. Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/IDE_BUILD_PLAN.md | 6 +- electron-builder.yml | 12 +- packages/main/src/chat/cliAgentManager.ts | 133 +++++++++++++++------- tests/unit/cliAgentManager.test.ts | 79 ++++++++++++- 4 files changed, 179 insertions(+), 51 deletions(-) diff --git a/docs/IDE_BUILD_PLAN.md b/docs/IDE_BUILD_PLAN.md index c014f1b..5804572 100644 --- a/docs/IDE_BUILD_PLAN.md +++ b/docs/IDE_BUILD_PLAN.md @@ -660,7 +660,7 @@ Terminal pane supports tabs internally. One tab for the interactive shell, one f Replace raw terminal with structured integration: ```typescript -import { query } from '@anthropic-ai/claude-code' +import { query } from '@anthropic-ai/claude-agent-sdk' const agentProcess = new UtilityProcess('./agent-worker.js', { env: { ANTHROPIC_API_KEY: getStoredApiKey() }, @@ -1043,7 +1043,7 @@ The full workspace switching experience. You can create two workspaces, start Cl - Display: estimated cost (token count × pricing) - [ ] **5.2** Claude Agent SDK integration - - Install `@anthropic-ai/claude-code` + - Install `@anthropic-ai/claude-agent-sdk` - Build `AgentWorker` UtilityProcess that runs the SDK's `query()` loop - Implement IPC message protocol between AgentWorker and renderer - Stream events to `AgentPane`: `file_read`, `file_write`, `command_run`, `thinking`, `complete` @@ -1365,7 +1365,7 @@ Track milestone completion here. Update as you go. | 5.1c CLI native session hydration | ✅ Complete | `ClaudeNativeSessionWatcher.loadMessages(sessionId)` reads `~/.claude/projects//.jsonl`, skips sidechains / thinking / file-history blocks, and maps rows to `CliAgentMessage[]`. IPC `cli-agent:load-messages` takes `(workspaceId, conversationId)`, returns `[]` when that workspace is not active (avoids native-prefix reads against the wrong project dir), and `useCliAgent` uses a hydrate generation guard, avoids clobbering a non-empty transcript with a stale empty IPC result, clears transcript only when the workspace+conversation key changes, and re-fetches native history once when `CONVERSATION_LIST_CHANGED` reports a `claude-native` row with messages but the pane is still empty. `CliAgentPane` waits on `historyHydrated`. Aide-managed sessions use `ConversationStore.loadMessages`. `CliAgentManager.start` sets `claudeSessionId` from `claude-native:` for `--resume`. | | 5.1d CLI multi-tab isolation | ✅ Complete | New `cliAgentPane` instances receive a provisional `conversationId` (`crypto.randomUUID()` from `agent.open` and default workspace layout in `workspaceSwitcher`). `useCliAgent` stops calling `cliAgentGetSession(workspaceId)` without a session id so tabs are not hydrated from `CliAgentManager.getSession`'s first in-memory match for the workspace. Command `agent.open` (e.g. Cmd+K Cmd+A) always adds a new agent panel for parallel work instead of focusing an existing tab. | | 5.1e Multi-worktree agent orchestration | ✅ Complete | Per-panel worktree isolation: `worktreePath` threaded through `CliAgentManager.start()` → spawn `cwd`, `CliAgentSession`, IPC, `useCliAgent` hook, `CliAgentPane` params. `AgentTab` custom tab component with branch badge pill (registered in `DockviewContainer.tabComponents`). "Start Agent in Worktree" button + context menu entry in `WorktreePanel`/`WorktreeItem`. Built-in agent isolation via `ToolContext.effectiveRoot` in `agentTools.ts` (terminal_exec, search_files, git_status, git_diff). `ChatSession.worktreePath` loaded from `ConversationMeta`. Worktree removal confirmation guard. Terminal panels from worktrees also get branch badges. | -| 5.2 Claude Agent SDK integration | ⬜ Not started | | +| 5.2 Claude Agent SDK integration | ✅ Complete | `CliAgentManager` streams Claude responses via `@anthropic-ai/claude-agent-sdk` `query()`, persists/resumes sessions, and now resolves the packaged executable from the SDK's own unpacked `cli.js` before falling back to the legacy `@anthropic-ai/claude-code` path. This fixes packaged-app startup failures caused by stale Claude Code path resolution. OpenCode/Codex SDK integration remains future work. | | 5.3 Diff preview before apply | ⬜ Not started | | | 5.4 Agent edit highlighting in editor | ⬜ Not started | | | 5.5 Crash recovery | ⬜ Not started | | diff --git a/electron-builder.yml b/electron-builder.yml index 6ad94a4..cac1d09 100644 --- a/electron-builder.yml +++ b/electron-builder.yml @@ -12,10 +12,12 @@ files: - node_modules/**/* asarUnpack: - - "**/node-pty/**" - - "**/@vscode/ripgrep/**" - - "**/@anthropic-ai/claude-code/**" - - "**/.pnpm/@anthropic-ai+claude-code@*/node_modules/@anthropic-ai/claude-code/**" + - '**/node-pty/**' + - '**/@vscode/ripgrep/**' + - '**/@anthropic-ai/claude-agent-sdk/**' + - '**/.pnpm/@anthropic-ai+claude-agent-sdk@*/node_modules/@anthropic-ai/claude-agent-sdk/**' + - '**/@anthropic-ai/claude-code/**' + - '**/.pnpm/@anthropic-ai+claude-code@*/node_modules/@anthropic-ai/claude-code/**' extraMetadata: main: packages/main/dist/index.js @@ -35,7 +37,7 @@ mac: gatekeeperAssess: false dmg: - artifactName: "${productName}-${version}-${os}-${arch}.${ext}" + artifactName: '${productName}-${version}-${os}-${arch}.${ext}' linux: target: diff --git a/packages/main/src/chat/cliAgentManager.ts b/packages/main/src/chat/cliAgentManager.ts index cfa308a..21d8b63 100644 --- a/packages/main/src/chat/cliAgentManager.ts +++ b/packages/main/src/chat/cliAgentManager.ts @@ -10,23 +10,29 @@ * loop that normalizes SDK messages into CliAgentMessage and emits them * via IPC. Session continuity uses the SDK's `resume` option. * - * The SDK needs `pathToClaudeCodeExecutable` to find the Claude Code CLI. - * Resolution order: explicit setting → bundled in app → workspace - * node_modules → global `claude` in PATH. + * The SDK ships its own `cli.js`, but packaged Electron apps need that file + * unpacked onto the real filesystem so a child Node process can execute it. + * Resolution order: explicit setting -> bundled Agent SDK in app -> legacy + * bundled Claude Code package -> workspace node_modules -> global `claude`. */ import { randomUUID } from 'crypto' import { execFileSync } from 'child_process' import { existsSync } from 'fs' -import { join, dirname } from 'path' +import { join } from 'path' import { app, type WebContents } from 'electron' import { query } from '@anthropic-ai/claude-agent-sdk' import type { Query } from '@anthropic-ai/claude-agent-sdk' import { IpcChannels, deriveTitle } from '@aide/shared' import type { - AgentBackend, CliAgentProcessStatus, CliAgentMessage, - CliAgentSession, CliAgentStreamDelta, - CliAgentStatusPayload, CliAgentResultPayload, CliAgentMessagePayload, + AgentBackend, + CliAgentProcessStatus, + CliAgentMessage, + CliAgentSession, + CliAgentStreamDelta, + CliAgentStatusPayload, + CliAgentResultPayload, + CliAgentMessagePayload, ConversationListChangedPayload, } from '@aide/shared' import type { ConversationStore } from './conversationStore' @@ -67,11 +73,12 @@ export interface CliAgentManagerOpts { } function comparableHistoryCount(messages: CliAgentMessage[]): number { - return messages.filter((message) => - message.type === 'user' || - message.type === 'assistant' || - message.type === 'tool_use' || - message.type === 'tool_result' + return messages.filter( + (message) => + message.type === 'user' || + message.type === 'assistant' || + message.type === 'tool_use' || + message.type === 'tool_result', ).length } @@ -131,7 +138,10 @@ export class CliAgentManager { if (meta?.claudeSessionId) { existingClaudeSessionId = meta.claudeSessionId } - const saved = await this.conversationStore.loadMessages(conversationId) as { messages?: CliAgentMessage[], claudeSessionId?: string } | null + const saved = (await this.conversationStore.loadMessages(conversationId)) as { + messages?: CliAgentMessage[] + claudeSessionId?: string + } | null if (saved?.messages) { existingMessages = saved.messages } @@ -199,10 +209,7 @@ export class CliAgentManager { * Send a prompt to the CLI agent. Starts an SDK query that streams * messages back via IPC. Uses `resume` for conversation continuity. */ - async send( - sessionId: string, - content: string, - ): Promise<{ success: true } | { error: string }> { + async send(sessionId: string, content: string): Promise<{ success: true } | { error: string }> { const session = this.sessions.get(sessionId) if (!session) return { error: 'Session not found' } @@ -228,9 +235,12 @@ export class CliAgentManager { session.lastError = undefined // Fire and forget the consumption loop - this.consumeQuery(session, content).catch(err => { + this.consumeQuery(session, content).catch((err) => { const errMsg = err instanceof Error ? err.message : String(err) - console.error(`[CliAgentManager] Unhandled consumeQuery error for session ${session.id}:`, errMsg) + console.error( + `[CliAgentManager] Unhandled consumeQuery error for session ${session.id}:`, + errMsg, + ) if (err instanceof Error && err.stack) console.error(`[CliAgentManager] Stack:`, err.stack) session.lastError = errMsg this.setStatus(session, 'error') @@ -340,10 +350,26 @@ export class CliAgentManager { const bundledCandidates: string[] = [] if (app.isPackaged) { bundledCandidates.push( - join(process.resourcesPath, 'app.asar.unpacked', 'node_modules', '@anthropic-ai', 'claude-code', 'cli.js'), + join( + process.resourcesPath, + 'app.asar.unpacked', + 'node_modules', + '@anthropic-ai', + 'claude-agent-sdk', + 'cli.js', + ), + join( + process.resourcesPath, + 'app.asar.unpacked', + 'node_modules', + '@anthropic-ai', + 'claude-code', + 'cli.js', + ), ) } bundledCandidates.push( + join(app.getAppPath(), 'node_modules', '@anthropic-ai', 'claude-agent-sdk', 'cli.js'), join(app.getAppPath(), 'node_modules', '@anthropic-ai', 'claude-code', 'cli.js'), ) for (const candidate of bundledCandidates) { @@ -355,11 +381,16 @@ export class CliAgentManager { } // 3. Workspace-local installation - const workspaceCli = join(this.workspaceRoot, 'node_modules', '@anthropic-ai', 'claude-code', 'cli.js') - if (existsSync(workspaceCli)) { - console.log(`[CliAgentManager] Using workspace Claude Code: ${workspaceCli}`) - this.resolvedClaudeCodePath = workspaceCli - return workspaceCli + const workspaceCandidates = [ + join(this.workspaceRoot, 'node_modules', '@anthropic-ai', 'claude-agent-sdk', 'cli.js'), + join(this.workspaceRoot, 'node_modules', '@anthropic-ai', 'claude-code', 'cli.js'), + ] + for (const candidate of workspaceCandidates) { + if (existsSync(candidate)) { + console.log(`[CliAgentManager] Using workspace Claude Code: ${candidate}`) + this.resolvedClaudeCodePath = candidate + return candidate + } } // 4. Global `claude` in PATH @@ -380,10 +411,7 @@ export class CliAgentManager { // ─── SDK Query Consumption ────────────────── - private async consumeQuery( - session: CliAgentSessionInternal, - prompt: string, - ): Promise { + private async consumeQuery(session: CliAgentSessionInternal, prompt: string): Promise { const abortController = new AbortController() session.abortController = abortController @@ -391,7 +419,9 @@ export class CliAgentManager { console.log(`[CliAgentManager] Starting SDK query for session ${session.id}`) console.log(`[CliAgentManager] cwd: ${cwd}`) console.log(`[CliAgentManager] resume: ${session.claudeSessionId ?? '(new session)'}`) - console.log(`[CliAgentManager] prompt: ${prompt.slice(0, 200)}${prompt.length > 200 ? '...' : ''}`) + console.log( + `[CliAgentManager] prompt: ${prompt.slice(0, 200)}${prompt.length > 200 ? '...' : ''}`, + ) // Collect stderr output for diagnostics const stderrChunks: string[] = [] @@ -399,7 +429,8 @@ export class CliAgentManager { // Resolve the Claude Code executable path for the SDK const executablePath = this.resolveClaudeCodeExecutable() if (!executablePath) { - session.lastError = 'Claude Code CLI not found. Install @anthropic-ai/claude-code globally or set agent.claudeCodePath in settings.' + session.lastError = + 'Claude Code CLI not found. Install @anthropic-ai/claude-code globally or set agent.claudeCodePath in settings.' const errorMsg: CliAgentMessage = { id: randomUUID(), type: 'error', @@ -464,7 +495,9 @@ export class CliAgentManager { messageCount++ this.handleSDKMessage(session, message) } - console.log(`[CliAgentManager] Query completed for session ${session.id} — ${messageCount} messages received`) + console.log( + `[CliAgentManager] Query completed for session ${session.id} — ${messageCount} messages received`, + ) } catch (err) { if (!abortController.signal.aborted) { const errMsg = err instanceof Error ? err.message : String(err) @@ -516,7 +549,9 @@ export class CliAgentManager { // Log all non-streaming messages (stream_event is too noisy) if (type !== 'stream_event') { - console.log(`[CliAgentManager] SDK message: type=${type}${subtype ? ` subtype=${subtype}` : ''} session=${session.id.slice(0, 8)}`) + console.log( + `[CliAgentManager] SDK message: type=${type}${subtype ? ` subtype=${subtype}` : ''} session=${session.id.slice(0, 8)}`, + ) } if (type === 'system') { @@ -536,7 +571,10 @@ export class CliAgentManager { this.setStatus(session, 'rate_limited') } else { // Log unknown message types so we can add handling later - console.log(`[CliAgentManager] Unhandled SDK message type: ${type}`, JSON.stringify(message).slice(0, 500)) + console.log( + `[CliAgentManager] Unhandled SDK message type: ${type}`, + JSON.stringify(message).slice(0, 500), + ) } } @@ -549,7 +587,9 @@ export class CliAgentManager { const sdkSessionId = message.session_id as string | undefined if (sdkSessionId) { session.claudeSessionId = sdkSessionId - this.conversationStore?.updateMeta(session.id, { claudeSessionId: sdkSessionId }).catch(() => {}) + this.conversationStore + ?.updateMeta(session.id, { claudeSessionId: sdkSessionId }) + .catch(() => {}) } session.model = (message.model as string) ?? undefined @@ -679,7 +719,9 @@ export class CliAgentManager { const resultSessionId = message.session_id as string | undefined if (resultSessionId && !session.claudeSessionId) { session.claudeSessionId = resultSessionId - this.conversationStore?.updateMeta(session.id, { claudeSessionId: resultSessionId }).catch(() => {}) + this.conversationStore + ?.updateMeta(session.id, { claudeSessionId: resultSessionId }) + .catch(() => {}) } // Build detailed error content for non-success results @@ -689,9 +731,14 @@ export class CliAgentManager { if (errors?.length) { errorDetail = errors.join('\n') } - console.error(`[CliAgentManager] Result error for session ${session.id.slice(0, 8)}: subtype=${subtype}`) + console.error( + `[CliAgentManager] Result error for session ${session.id.slice(0, 8)}: subtype=${subtype}`, + ) if (errorDetail) console.error(`[CliAgentManager] Error details:\n${errorDetail}`) - console.error(`[CliAgentManager] Full result message:`, JSON.stringify(message).slice(0, 2000)) + console.error( + `[CliAgentManager] Full result message:`, + JSON.stringify(message).slice(0, 2000), + ) } const msg: CliAgentMessage = { @@ -737,7 +784,11 @@ export class CliAgentManager { } private emitMessage(session: CliAgentSessionInternal, msg: CliAgentMessage): void { - const ipcMsg: CliAgentMessagePayload = { ...msg, workspaceId: session.workspaceId, sessionId: session.id } + const ipcMsg: CliAgentMessagePayload = { + ...msg, + workspaceId: session.workspaceId, + sessionId: session.id, + } this.getWebContents()?.send(IpcChannels.CLI_AGENT_MESSAGE, ipcMsg) if (session.processStatus === 'rate_limited' && msg.type !== 'status') { @@ -756,7 +807,7 @@ export class CliAgentManager { await this.conversationStore.updateMeta(session.id, { updatedAt: Date.now(), messageCount: session.messages.length, - firstMessage: session.messages.find(m => m.type === 'user')?.content.slice(0, 100), + firstMessage: session.messages.find((m) => m.type === 'user')?.content.slice(0, 100), claudeSessionId: session.claudeSessionId, worktreePath: session.worktreePath, }) @@ -765,7 +816,7 @@ export class CliAgentManager { private async maybeAutoTitle(session: CliAgentSessionInternal, content: string): Promise { if (!this.conversationStore) return - const userMessages = session.messages.filter(m => m.type === 'user') + const userMessages = session.messages.filter((m) => m.type === 'user') if (userMessages.length !== 1) return const meta = await this.conversationStore.get(session.id) diff --git a/tests/unit/cliAgentManager.test.ts b/tests/unit/cliAgentManager.test.ts index d807c1b..666da7d 100644 --- a/tests/unit/cliAgentManager.test.ts +++ b/tests/unit/cliAgentManager.test.ts @@ -1,6 +1,26 @@ -import { describe, it, expect, vi } from 'vitest' +import { beforeEach, describe, it, expect, vi } from 'vitest' import type { CliAgentMessage } from '@aide/shared' +const mocks = vi.hoisted(() => ({ + execFileSync: vi.fn(), + existsSync: vi.fn(), + query: vi.fn(), +})) + +vi.mock('child_process', () => ({ + default: { + execFileSync: mocks.execFileSync, + }, + execFileSync: mocks.execFileSync, +})) + +vi.mock('fs', () => ({ + default: { + existsSync: mocks.existsSync, + }, + existsSync: mocks.existsSync, +})) + vi.mock('electron', () => ({ app: { isPackaged: false, @@ -9,11 +29,26 @@ vi.mock('electron', () => ({ })) vi.mock('@anthropic-ai/claude-agent-sdk', () => ({ - query: vi.fn(), + query: mocks.query, })) +import { app } from 'electron' import { CliAgentManager } from '@main/chat/cliAgentManager' +const existsSyncMock = vi.mocked(mocks.existsSync) +const execFileSyncMock = vi.mocked(mocks.execFileSync) +const queryMock = vi.mocked(mocks.query) +const appMock = app as { isPackaged: boolean; getAppPath: () => string } + +function makeQueryStub() { + return { + close: vi.fn(), + async *[Symbol.asyncIterator]() { + return + }, + } +} + function makeStore(overrides: Record = {}) { return { get: vi.fn().mockResolvedValue(null), @@ -27,6 +62,20 @@ function makeStore(overrides: Record = {}) { } describe('CliAgentManager', () => { + beforeEach(() => { + vi.clearAllMocks() + appMock.isPackaged = false + existsSyncMock.mockReturnValue(false) + execFileSyncMock.mockImplementation(() => { + throw new Error('not found') + }) + queryMock.mockReturnValue(makeQueryStub() as never) + Object.defineProperty(process, 'resourcesPath', { + value: '/resources', + configurable: true, + }) + }) + it('prefers fresher native Claude history over the stored shadow copy', async () => { const storedMessages: CliAgentMessage[] = [ { id: 'u-1', type: 'user', content: 'old prompt', timestamp: 1 }, @@ -84,4 +133,30 @@ describe('CliAgentManager', () => { expect(manager.getSessionById('conv-2')?.messages).toEqual(storedMessages) }) + + it('uses the unpacked Agent SDK cli in packaged builds', async () => { + appMock.isPackaged = true + const expectedCliPath = + '/resources/app.asar.unpacked/node_modules/@anthropic-ai/claude-agent-sdk/cli.js' + existsSyncMock.mockImplementation((candidate) => candidate === expectedCliPath) + + const manager = new CliAgentManager({ + workspaceRoot: '/workspace', + getWebContents: () => null, + }) + + const started = await manager.start('ws-1', 'claude-code') + if ('error' in started) throw new Error(started.error) + + await manager.send(started.sessionId, 'hello from test') + + expect(queryMock).toHaveBeenCalledWith( + expect.objectContaining({ + prompt: 'hello from test', + options: expect.objectContaining({ + pathToClaudeCodeExecutable: expectedCliPath, + }), + }), + ) + }) }) From bab71adf90b0ed371fc087f97c3bf453df0a53bf Mon Sep 17 00:00:00 2001 From: BT Odoy Date: Wed, 8 Apr 2026 15:49:43 -0400 Subject: [PATCH 2/9] refactor: adopt shared ui primitives in chat pane Introduce reusable Button and related UI components and migrate chat pane elements to use them for a more consistent look and feel. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/components/chat/ChatInput.tsx | 24 +- .../src/components/chat/MessageBubble.tsx | 6 +- .../src/components/chat/MessageList.tsx | 2 + .../src/components/chat/ModeSelector.tsx | 28 +- .../components/chat/PermissionTierBadge.tsx | 18 +- .../src/components/chat/ToolCallCard.tsx | 127 ++- .../src/components/chat/WorkingSetPicker.tsx | 12 +- .../src/components/panes/ChatPane.tsx | 12 +- packages/renderer/src/components/ui/Badge.tsx | 19 + .../renderer/src/components/ui/Button.tsx | 23 + .../src/components/ui/SegmentedControl.tsx | 45 ++ packages/renderer/src/main.tsx | 1 + packages/renderer/src/styles/chat-pane.css | 726 ++++++------------ .../renderer/src/styles/cli-agent-pane.css | 39 +- packages/renderer/src/styles/global.css | 31 + packages/renderer/src/styles/ui.css | 172 +++++ 16 files changed, 652 insertions(+), 633 deletions(-) create mode 100644 packages/renderer/src/components/ui/Badge.tsx create mode 100644 packages/renderer/src/components/ui/Button.tsx create mode 100644 packages/renderer/src/components/ui/SegmentedControl.tsx create mode 100644 packages/renderer/src/styles/ui.css diff --git a/packages/renderer/src/components/chat/ChatInput.tsx b/packages/renderer/src/components/chat/ChatInput.tsx index 751e3e2..d0e4b15 100644 --- a/packages/renderer/src/components/chat/ChatInput.tsx +++ b/packages/renderer/src/components/chat/ChatInput.tsx @@ -1,5 +1,6 @@ import { useRef, useCallback, useState } from 'react' import type { ChatMode, ChatSessionStatus } from '@aide/shared' +import { Button } from '../ui/Button' interface ChatInputProps { onSend: (content: string) => void @@ -8,13 +9,7 @@ interface ChatInputProps { mode: ChatMode } -const PLACEHOLDERS: Record = { - ask: 'Ask a question...', - edit: 'Describe changes...', - agent: 'What should I do?', -} - -export function ChatInput({ onSend, onStop, status, mode }: ChatInputProps) { +export function ChatInput({ onSend, onStop, status }: ChatInputProps) { const textareaRef = useRef(null) const [value, setValue] = useState('') const isActive = status !== 'idle' @@ -68,22 +63,23 @@ export function ChatInput({ onSend, onStop, status, mode }: ChatInputProps) { value={value} onChange={handleChange} onKeyDown={handleKeyDown} - placeholder={PLACEHOLDERS[mode]} + placeholder="Ask, edit, or build…" rows={1} disabled={isActive} /> {isActive ? ( - + ) : ( - + )} ) diff --git a/packages/renderer/src/components/chat/MessageBubble.tsx b/packages/renderer/src/components/chat/MessageBubble.tsx index 357cf3f..f0d858a 100644 --- a/packages/renderer/src/components/chat/MessageBubble.tsx +++ b/packages/renderer/src/components/chat/MessageBubble.tsx @@ -17,7 +17,8 @@ export function MessageBubble({ message, isStreaming, streamingContent }: Messag message.toolResults?.map((r) => r.output).filter(Boolean).join('\n\n') || message.content return (
- {output} +
{isError ? 'Tool error' : 'Tool result'}
+
{output}
) } @@ -25,7 +26,7 @@ export function MessageBubble({ message, isStreaming, streamingContent }: Messag if (message.role === 'user') { return (
- > +
You
{message.content}
) @@ -43,6 +44,7 @@ function AssistantMessage({ content, isStreaming }: { content: string; isStreami return (
+
Claude
{/* Safe: HTML is sanitized through DOMPurify in renderMarkdown */} diff --git a/packages/renderer/src/components/chat/MessageList.tsx b/packages/renderer/src/components/chat/MessageList.tsx index 733f079..a1e81c7 100644 --- a/packages/renderer/src/components/chat/MessageList.tsx +++ b/packages/renderer/src/components/chat/MessageList.tsx @@ -46,6 +46,7 @@ export function MessageList({ return (
+
{messages.map((msg) => { const isStreamingThis = msg.id === streamingMessageId return ( @@ -91,6 +92,7 @@ export function MessageList({ {status === 'awaiting_approval' && (
Waiting for approval
)} +
) } diff --git a/packages/renderer/src/components/chat/ModeSelector.tsx b/packages/renderer/src/components/chat/ModeSelector.tsx index 3fe812a..24277cc 100644 --- a/packages/renderer/src/components/chat/ModeSelector.tsx +++ b/packages/renderer/src/components/chat/ModeSelector.tsx @@ -1,4 +1,5 @@ import type { ChatMode } from '@aide/shared' +import { SegmentedControl } from '../ui/SegmentedControl' interface ModeSelectorProps { mode: ChatMode @@ -6,25 +7,20 @@ interface ModeSelectorProps { disabled: boolean } -const MODES: { value: ChatMode; label: string }[] = [ - { value: 'ask', label: 'ASK' }, - { value: 'edit', label: 'EDIT' }, - { value: 'agent', label: 'AGENT' }, +const OPTIONS: { value: ChatMode; label: string }[] = [ + { value: 'ask', label: 'Ask' }, + { value: 'edit', label: 'Edit' }, + { value: 'agent', label: 'Agent' }, ] export function ModeSelector({ mode, onModeChange, disabled }: ModeSelectorProps) { return ( -
- {MODES.map((m) => ( - - ))} -
+ ) } diff --git a/packages/renderer/src/components/chat/PermissionTierBadge.tsx b/packages/renderer/src/components/chat/PermissionTierBadge.tsx index c22076b..bd05562 100644 --- a/packages/renderer/src/components/chat/PermissionTierBadge.tsx +++ b/packages/renderer/src/components/chat/PermissionTierBadge.tsx @@ -1,10 +1,13 @@ import { useState, useEffect } from 'react' import type { PermissionTier } from '@aide/shared' +import { Badge } from '../ui/Badge' -const TIER_CONFIG: Record = { - 'confirm': { label: 'Confirm', icon: '⛨', className: 'confirm' }, - 'auto-approve': { label: 'Auto', icon: '⚡', className: 'auto' }, - 'autopilot': { label: 'Autopilot', icon: '◉', className: 'autopilot' }, +type BadgeVariant = 'warning' | 'info' | 'success' + +const TIER_CONFIG: Record = { + 'confirm': { label: 'Confirm', icon: '⛨', variant: 'warning' }, + 'auto-approve': { label: 'Auto', icon: '⚡', variant: 'info' }, + 'autopilot': { label: 'Autopilot', icon: '◉', variant: 'success' }, } interface PermissionTierBadgeProps { @@ -41,9 +44,8 @@ export function PermissionTierBadge({ workspaceId }: PermissionTierBadgeProps) { const config = TIER_CONFIG[tier] return ( -
- {config.icon} - {config.label} -
+ + {config.label} + ) } diff --git a/packages/renderer/src/components/chat/ToolCallCard.tsx b/packages/renderer/src/components/chat/ToolCallCard.tsx index be366e5..18006bb 100644 --- a/packages/renderer/src/components/chat/ToolCallCard.tsx +++ b/packages/renderer/src/components/chat/ToolCallCard.tsx @@ -1,5 +1,7 @@ -import { useState, useRef, useEffect } from 'react' +import { useState } from 'react' import type { ToolCall } from '@aide/shared' +import { Badge } from '../ui/Badge' +import { Button } from '../ui/Button' interface ToolCallCardProps { toolCall: ToolCall @@ -18,11 +20,16 @@ const TOOL_ICONS: Record = { browser_read: '◎', } -const STATUS_META: Record = { - pending: { label: 'awaiting', className: 'pending' }, - approved: { label: 'approved', className: 'approved' }, - rejected: { label: 'denied', className: 'rejected' }, - completed: { label: 'done', className: 'completed' }, +type BadgeVariant = 'neutral' | 'success' | 'warning' | 'error' | 'info' + +const STATUS_META: Record< + string, + { label: string; className: string; variant: BadgeVariant } +> = { + pending: { label: 'awaiting', className: 'pending', variant: 'warning' }, + approved: { label: 'approved', className: 'approved', variant: 'success' }, + rejected: { label: 'denied', className: 'rejected', variant: 'error' }, + completed: { label: 'done', className: 'completed', variant: 'success' }, } function formatToolInput(input: Record): string { @@ -63,12 +70,6 @@ function getToolSummary(name: string, input: Record): string { export function ToolCallCard({ toolCall, onApprove, onReject }: ToolCallCardProps) { const [expanded, setExpanded] = useState(false) - const [animateIn, setAnimateIn] = useState(false) - const cardRef = useRef(null) - - useEffect(() => { - requestAnimationFrame(() => setAnimateIn(true)) - }, []) const meta = STATUS_META[toolCall.status] ?? STATUS_META.pending const icon = TOOL_ICONS[toolCall.name] ?? '⬡' @@ -77,74 +78,50 @@ export function ToolCallCard({ toolCall, onApprove, onReject }: ToolCallCardProp const isDone = toolCall.status === 'completed' || toolCall.status === 'approved' return ( -
- {/* Tier glow bar */} -
- -
- {/* Tool icon + name row */} -
- {icon} -
- {toolCall.name} - {summary && {summary}} -
-
- - {meta.label} - {toolCall.autoApproved && ( - auto - )} -
+
+
+ {icon} + {toolCall.name} + {summary && ( + <> + · + {summary} + + )} +
+ {toolCall.autoApproved && auto} + {meta.label}
+
- {/* Expandable input detail */} - + - {expanded && ( -
-            {formatToolInput(toolCall.input)}
-          
- )} + {expanded &&
{formatToolInput(toolCall.input)}
} - {/* Approval buttons */} - {isPending && !toolCall.autoApproved && ( -
- - -
- )} + {isPending && !toolCall.autoApproved && ( +
+ + +
+ )} - {/* Completed state — subtle checkmark */} - {isDone && !toolCall.autoApproved && ( -
- - executed -
- )} -
+ {isDone && !toolCall.autoApproved && ( +
+ + executed +
+ )}
) } diff --git a/packages/renderer/src/components/chat/WorkingSetPicker.tsx b/packages/renderer/src/components/chat/WorkingSetPicker.tsx index 791c62d..94d8484 100644 --- a/packages/renderer/src/components/chat/WorkingSetPicker.tsx +++ b/packages/renderer/src/components/chat/WorkingSetPicker.tsx @@ -1,4 +1,5 @@ import { useState, useCallback, useEffect, useRef } from 'react' +import { Button } from '../ui/Button' interface WorkingSetPickerProps { workingSet: string[] @@ -62,23 +63,26 @@ export function WorkingSetPicker({ workingSet, onWorkingSetChange, workspaceRoot return (
+ Working set {workingSet.map((path) => ( {basename(path)} ))} - + + Add + {dropdownOpen && (
diff --git a/packages/renderer/src/components/panes/ChatPane.tsx b/packages/renderer/src/components/panes/ChatPane.tsx index 1fb7c25..17f3498 100644 --- a/packages/renderer/src/components/panes/ChatPane.tsx +++ b/packages/renderer/src/components/panes/ChatPane.tsx @@ -46,11 +46,13 @@ export function ChatPane({ params, api }: IDockviewPanelProps)
{chat.mode === 'edit' && ( - +
+ +
)}
diff --git a/packages/renderer/src/components/ui/Badge.tsx b/packages/renderer/src/components/ui/Badge.tsx new file mode 100644 index 0000000..916f2e3 --- /dev/null +++ b/packages/renderer/src/components/ui/Badge.tsx @@ -0,0 +1,19 @@ +import type { ReactNode } from 'react' + +type BadgeVariant = 'neutral' | 'success' | 'warning' | 'error' | 'info' + +interface BadgeProps { + variant?: BadgeVariant + icon?: ReactNode + title?: string + children: ReactNode +} + +export function Badge({ variant = 'neutral', icon, title, children }: BadgeProps) { + return ( + + {icon != null && {icon}} + {children} + + ) +} diff --git a/packages/renderer/src/components/ui/Button.tsx b/packages/renderer/src/components/ui/Button.tsx new file mode 100644 index 0000000..85c70ef --- /dev/null +++ b/packages/renderer/src/components/ui/Button.tsx @@ -0,0 +1,23 @@ +import { forwardRef } from 'react' +import type { ButtonHTMLAttributes, ReactNode } from 'react' + +type ButtonVariant = 'ghost' | 'outline' | 'accent' | 'danger' +type ButtonSize = 'sm' | 'md' + +interface ButtonProps extends ButtonHTMLAttributes { + variant?: ButtonVariant + size?: ButtonSize + children?: ReactNode +} + +export const Button = forwardRef(function Button( + { variant = 'ghost', size = 'md', className, children, type = 'button', ...rest }, + ref, +) { + const cls = `ui-btn ui-btn--${variant} ui-btn--${size}${className ? ` ${className}` : ''}` + return ( + + ) +}) diff --git a/packages/renderer/src/components/ui/SegmentedControl.tsx b/packages/renderer/src/components/ui/SegmentedControl.tsx new file mode 100644 index 0000000..b3b9e96 --- /dev/null +++ b/packages/renderer/src/components/ui/SegmentedControl.tsx @@ -0,0 +1,45 @@ +interface SegmentedControlOption { + value: T + label: string +} + +interface SegmentedControlProps { + options: SegmentedControlOption[] + value: T + onChange: (value: T) => void + disabled?: boolean + ariaLabel?: string +} + +export function SegmentedControl({ + options, + value, + onChange, + disabled, + ariaLabel, +}: SegmentedControlProps) { + return ( +
+ {options.map((opt) => { + const active = opt.value === value + return ( + + ) + })} +
+ ) +} diff --git a/packages/renderer/src/main.tsx b/packages/renderer/src/main.tsx index 47ebad3..f7fe2be 100644 --- a/packages/renderer/src/main.tsx +++ b/packages/renderer/src/main.tsx @@ -17,6 +17,7 @@ import './styles/find-in-files.css' import './styles/gitignore-modal.css' import './styles/welcome-pane.css' import './styles/settings-pane.css' +import './styles/ui.css' import './styles/chat-pane.css' const rootElement = document.getElementById('root') diff --git a/packages/renderer/src/styles/chat-pane.css b/packages/renderer/src/styles/chat-pane.css index 6020ab0..1453c24 100644 --- a/packages/renderer/src/styles/chat-pane.css +++ b/packages/renderer/src/styles/chat-pane.css @@ -6,184 +6,30 @@ height: 100%; background: var(--bg-base); color: var(--text-primary); - font-family: -apple-system, BlinkMacSystemFont, 'SF Pro Text', 'Helvetica Neue', sans-serif; - font-size: 13px; + font-family: var(--font-ui); + font-size: var(--font-size-sm); line-height: 1.4; } +/* --- Header ----------------------------------------------------- */ + .chat-pane__header { flex-shrink: 0; - padding: 6px 8px; - border-bottom: 1px solid var(--border-base); background: var(--bg-elevated); + border-bottom: 1px solid var(--border-base); } .chat-pane__header-row { display: flex; align-items: center; justify-content: space-between; - gap: 8px; -} - -.chat-pane__footer { - flex-shrink: 0; - padding: 6px 8px; - border-top: 1px solid var(--border-base); - background: var(--bg-elevated); -} - -/* --- Mode Selector ---------------------------------------------- */ - -.chat-mode-selector { - display: flex; - border: 1px solid var(--border-base); - border-radius: 4px; - overflow: hidden; - height: 22px; -} - -.chat-mode-selector--disabled { - opacity: 0.5; - pointer-events: none; -} - -.chat-mode-selector__btn { - flex: 1; - border: none; - background: transparent; - color: var(--text-secondary); - font-family: inherit; - font-size: 10px; - font-weight: 600; - letter-spacing: 0.05em; - text-transform: uppercase; - cursor: pointer; - padding: 0 4px; - transition: background 0.1s, color 0.1s; -} - -.chat-mode-selector__btn:not(:last-child) { - border-right: 1px solid var(--border-base); -} - -.chat-mode-selector__btn:hover { - background: var(--bg-hover); -} - -.chat-mode-selector__btn--active { - background: var(--bg-selection); - color: var(--text-primary); -} - -/* --- Working Set ------------------------------------------------ */ - -.chat-working-set { - display: flex; - flex-wrap: wrap; - align-items: center; - gap: 4px; - margin-top: 6px; - position: relative; -} - -.chat-working-set__chip { - display: inline-flex; - align-items: center; - gap: 3px; - background: var(--bg-sunken); - border: 1px solid var(--border-subtle); - border-radius: 3px; - padding: 1px 6px; - font-family: 'SF Mono', Menlo, Monaco, 'Courier New', monospace; - font-size: 11px; - color: var(--text-secondary); - max-width: 160px; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.chat-working-set__chip-remove { - border: none; - background: none; - color: var(--text-muted); - cursor: pointer; - padding: 0; - font-size: 12px; - line-height: 1; - flex-shrink: 0; -} - -.chat-working-set__chip-remove:hover { - color: var(--text-error); + gap: var(--space-3); + padding: var(--space-3) var(--space-4); + min-height: 36px; } -.chat-working-set__add { - border: 1px dashed var(--border-subtle); - background: none; - color: var(--text-muted); - border-radius: 3px; - padding: 1px 8px; - font-size: 12px; - cursor: pointer; - line-height: 1; -} - -.chat-working-set__add:hover { - border-color: var(--accent); - color: var(--accent); -} - -.chat-working-set__dropdown { - position: absolute; - top: 100%; - left: 0; - right: 0; - margin-top: 4px; - background: var(--bg-overlay); - border: 1px solid var(--border-base); - border-radius: 4px; - max-height: 200px; - overflow-y: auto; - z-index: 100; - box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); -} - -.chat-working-set__dropdown-input { - width: 100%; - border: none; - border-bottom: 1px solid var(--border-base); - background: transparent; - color: var(--text-primary); - font-family: 'SF Mono', Menlo, Monaco, 'Courier New', monospace; - font-size: 11px; - padding: 5px 8px; - outline: none; - box-sizing: border-box; -} - -.chat-working-set__dropdown-item { - display: flex; - align-items: center; - gap: 6px; - padding: 3px 8px; - font-family: 'SF Mono', Menlo, Monaco, 'Courier New', monospace; - font-size: 11px; - color: var(--text-secondary); - cursor: pointer; - border: none; - background: none; - width: 100%; - text-align: left; -} - -.chat-working-set__dropdown-item:hover { - background: var(--bg-hover); -} - -.chat-working-set__dropdown-item--selected { - color: var(--text-muted); - opacity: 0.6; +.chat-pane__header-row + .chat-pane__header-row { + border-top: 1px solid var(--border-subtle); } /* --- Messages --------------------------------------------------- */ @@ -194,54 +40,65 @@ overflow-x: hidden; } +.chat-messages__inner { + max-width: 720px; + margin: 0 auto; + padding: var(--space-5) var(--space-4) var(--space-6); + display: flex; + flex-direction: column; + gap: var(--space-5); +} + .chat-messages__empty { display: flex; align-items: center; justify-content: center; height: 100%; color: var(--text-muted); - font-size: 13px; + font-size: var(--font-size-md); } /* --- Message Bubble --------------------------------------------- */ .chat-msg { - padding: 8px 12px; - border-bottom: 1px solid var(--border-subtle); -} - -.chat-msg--user { display: flex; - gap: 6px; + flex-direction: column; + gap: var(--space-2); } -.chat-msg__prefix { - flex-shrink: 0; +.chat-msg__role { + display: inline-flex; + align-items: center; + gap: var(--space-2); + font-size: var(--font-size-xs); + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; color: var(--text-muted); - font-family: 'SF Mono', Menlo, Monaco, 'Courier New', monospace; - font-size: 11px; - line-height: 1.6; - user-select: none; } -.chat-msg__body { +.chat-msg__role::after { + content: ''; flex: 1; - min-width: 0; - overflow-wrap: break-word; + height: 1px; + background: var(--border-subtle); + min-width: var(--space-5); } -.chat-msg--user .chat-msg__body { +.chat-msg__body { color: var(--text-primary); - white-space: pre-wrap; + font-size: var(--font-size-lg); + line-height: 1.6; + overflow-wrap: break-word; } -.chat-msg--assistant .chat-msg__body { - color: var(--text-secondary); +.chat-msg--user .chat-msg__body { + white-space: pre-wrap; } /* Markdown content within assistant messages */ .chat-msg--assistant .chat-msg__body p { - margin: 0 0 8px 0; + margin: 0 0 var(--space-3) 0; } .chat-msg--assistant .chat-msg__body p:last-child { @@ -251,30 +108,30 @@ .chat-msg--assistant .chat-msg__body pre { background: var(--bg-sunken); border: 1px solid var(--border-subtle); - border-radius: 3px; - padding: 8px 10px; - margin: 6px 0; + border-radius: var(--radius-md); + padding: var(--space-3) var(--space-4); + margin: var(--space-3) 0; overflow-x: auto; - font-family: 'SF Mono', Menlo, Monaco, 'Courier New', monospace; - font-size: 12px; - line-height: 1.4; + font-family: var(--font-mono); + font-size: var(--font-size-md); + line-height: 1.5; } .chat-msg--assistant .chat-msg__body code { - font-family: 'SF Mono', Menlo, Monaco, 'Courier New', monospace; - font-size: 12px; + font-family: var(--font-mono); + font-size: var(--font-size-md); } .chat-msg--assistant .chat-msg__body :not(pre) > code { background: var(--bg-sunken); - padding: 1px 4px; - border-radius: 2px; + padding: 1px var(--space-2); + border-radius: var(--radius-sm); } .chat-msg--assistant .chat-msg__body ul, .chat-msg--assistant .chat-msg__body ol { - margin: 4px 0; - padding-left: 20px; + margin: var(--space-2) 0; + padding-left: var(--space-5); } .chat-msg--assistant .chat-msg__body a { @@ -287,235 +144,231 @@ } /* Tool result message */ -.chat-msg--tool-result { +.chat-msg--tool-result .chat-msg__body { background: var(--bg-sunken); + border: 1px solid var(--border-subtle); border-left: 2px solid var(--border-subtle); - font-family: 'SF Mono', Menlo, Monaco, 'Courier New', monospace; - font-size: 11px; + border-radius: var(--radius-md); + padding: var(--space-3) var(--space-4); + font-family: var(--font-mono); + font-size: var(--font-size-sm); color: var(--text-secondary); white-space: pre-wrap; - overflow-wrap: break-word; + line-height: 1.5; } -.chat-msg--tool-result-error { +.chat-msg--tool-result-error .chat-msg__body { border-left-color: var(--text-error); } /* Streaming cursor */ .chat-msg__cursor { + display: inline-block; + margin-left: 1px; color: var(--accent); - animation: chat-cursor-blink 530ms step-end infinite; + animation: chat-cursor-blink 1s steps(2, jump-none) infinite; } -/* --- Permission Tier Badge -------------------------------------- */ +@keyframes chat-cursor-blink { + 0%, 100% { opacity: 1; } + 50% { opacity: 0; } +} -.perm-tier-badge { - display: inline-flex; +/* --- Working Set ------------------------------------------------ */ + +.chat-working-set { + display: flex; + flex-wrap: wrap; align-items: center; - gap: 4px; - padding: 2px 8px; - border-radius: 3px; - font-size: 10px; + gap: var(--space-2); + position: relative; + flex: 1; + min-width: 0; +} + +.chat-working-set__label { + font-size: var(--font-size-xs); font-weight: 600; - letter-spacing: 0.04em; + letter-spacing: 0.05em; text-transform: uppercase; - border: 1px solid var(--border-subtle); - background: var(--bg-sunken); color: var(--text-muted); - transition: background 0.2s, color 0.2s, border-color 0.2s; - cursor: default; - flex-shrink: 0; + margin-right: var(--space-2); } -.perm-tier-badge__icon { - font-size: 11px; - line-height: 1; -} - -.perm-tier-badge--confirm { - border-color: color-mix(in srgb, var(--text-warning) 30%, transparent); - color: var(--text-warning); +.chat-working-set__chip { + display: inline-flex; + align-items: center; + gap: var(--space-2); + background: var(--bg-sunken); + border: 1px solid var(--border-subtle); + border-radius: var(--radius-md); + padding: 1px var(--space-2) 1px var(--space-3); + font-family: var(--font-mono); + font-size: var(--font-size-sm); + color: var(--text-secondary); + max-width: 180px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } -.perm-tier-badge--auto { - border-color: color-mix(in srgb, var(--text-info) 30%, transparent); - color: var(--text-info); +.chat-working-set__chip-remove { + border: none; + background: none; + color: var(--text-muted); + cursor: pointer; + padding: 0; + font-size: var(--font-size-md); + line-height: 1; + flex-shrink: 0; + transition: color var(--motion-fast); } -.perm-tier-badge--autopilot { - border-color: color-mix(in srgb, var(--text-success) 30%, transparent); - background: color-mix(in srgb, var(--text-success) 6%, transparent); - color: var(--text-success); +.chat-working-set__chip-remove:hover { + color: var(--text-error); } -/* --- Tool Call Card (redesigned) -------------------------------- */ - -.tc-card { - margin: 6px 10px; - border-radius: 6px; +.chat-working-set__dropdown { + position: absolute; + top: 100%; + left: 0; + right: 0; + margin-top: var(--space-2); + background: var(--bg-overlay); border: 1px solid var(--border-base); - background: var(--bg-elevated); - overflow: hidden; - position: relative; - opacity: 0; - transform: translateY(4px); - transition: opacity 0.2s ease-out, transform 0.2s ease-out; + border-radius: var(--radius-md); + max-height: 220px; + overflow-y: auto; + z-index: 100; + box-shadow: 0 6px 18px rgba(0, 0, 0, 0.35); } -.tc-card--visible { - opacity: 1; - transform: translateY(0); +.chat-working-set__dropdown-input { + width: 100%; + border: none; + border-bottom: 1px solid var(--border-base); + background: transparent; + color: var(--text-primary); + font-family: var(--font-mono); + font-size: var(--font-size-sm); + padding: var(--space-2) var(--space-3); + outline: none; } -/* Left accent glow bar */ -.tc-card__glow { - position: absolute; - top: 0; - left: 0; - bottom: 0; - width: 3px; - border-radius: 3px 0 0 3px; - background: var(--border-subtle); - transition: background 0.3s; +.chat-working-set__dropdown-item { + display: flex; + align-items: center; + gap: var(--space-2); + padding: var(--space-1) var(--space-3); + font-family: var(--font-mono); + font-size: var(--font-size-sm); + color: var(--text-secondary); + cursor: pointer; + border: none; + background: none; + width: 100%; + text-align: left; } -.tc-card--pending .tc-card__glow { - background: var(--text-warning); - box-shadow: 0 0 8px color-mix(in srgb, var(--text-warning) 30%, transparent); - animation: tc-glow-pulse 2s ease-in-out infinite; +.chat-working-set__dropdown-item:hover { + background: var(--bg-hover); } -.tc-card--approved .tc-card__glow, -.tc-card--completed .tc-card__glow { - background: var(--text-success); +.chat-working-set__dropdown-item--selected { + color: var(--text-muted); + opacity: 0.6; } -.tc-card--rejected .tc-card__glow { - background: var(--text-error); -} +/* --- Tool Call Card --------------------------------------------- */ -@keyframes tc-glow-pulse { - 0%, 100% { opacity: 1; } - 50% { opacity: 0.5; } +.tc-card { + position: relative; + display: flex; + flex-direction: column; + gap: var(--space-2); + padding: var(--space-3) var(--space-3) var(--space-3) var(--space-4); + background: var(--bg-elevated); + border: 1px solid var(--border-subtle); + border-left: 2px solid var(--border-subtle); + border-radius: var(--radius-md); } -.tc-card__main { - padding: 8px 10px 8px 14px; +.tc-card--pending { + border-left-color: var(--text-warning); +} +.tc-card--approved, +.tc-card--completed { + border-left-color: var(--text-success); +} +.tc-card--rejected { + border-left-color: var(--text-error); + opacity: 0.7; } -/* Header row */ .tc-card__header { display: flex; align-items: center; - gap: 8px; + gap: var(--space-3); + min-width: 0; } .tc-card__icon { flex-shrink: 0; - width: 24px; - height: 24px; + width: 18px; + height: 18px; display: flex; align-items: center; justify-content: center; - font-size: 12px; - font-weight: 700; + font-family: var(--font-mono); + font-size: var(--font-size-sm); color: var(--text-secondary); - background: var(--bg-sunken); - border: 1px solid var(--border-subtle); - border-radius: 4px; - font-family: 'SF Mono', Menlo, Monaco, 'Courier New', monospace; line-height: 1; } -.tc-card__title-group { - flex: 1; - min-width: 0; - display: flex; - flex-direction: column; - gap: 1px; -} - .tc-card__name { - font-family: 'SF Mono', Menlo, Monaco, 'Courier New', monospace; - font-size: 11px; + font-family: var(--font-mono); + font-size: var(--font-size-sm); font-weight: 600; color: var(--text-primary); - line-height: 1.2; + flex-shrink: 0; +} + +.tc-card__sep { + color: var(--text-muted); + flex-shrink: 0; } .tc-card__summary { - font-family: 'SF Mono', Menlo, Monaco, 'Courier New', monospace; - font-size: 10px; + font-family: var(--font-mono); + font-size: var(--font-size-sm); color: var(--text-muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; - line-height: 1.2; + flex: 1; + min-width: 0; } -/* Status area */ .tc-card__status-area { flex-shrink: 0; display: flex; align-items: center; - gap: 5px; -} - -.tc-card__dot { - width: 6px; - height: 6px; - border-radius: 50%; - flex-shrink: 0; -} - -.tc-card__dot--pending { - background: var(--text-warning); - animation: chat-pulse 1.5s ease-in-out infinite; -} - -.tc-card__dot--approved, -.tc-card__dot--completed { - background: var(--text-success); -} - -.tc-card__dot--rejected { - background: var(--text-error); -} - -.tc-card__status-label { - font-size: 10px; - color: var(--text-muted); - text-transform: uppercase; - letter-spacing: 0.03em; - font-weight: 500; -} - -.tc-card__auto-tag { - font-size: 9px; - font-weight: 700; - letter-spacing: 0.04em; - text-transform: uppercase; - color: var(--text-info, var(--accent)); - background: color-mix(in srgb, var(--text-info, var(--accent)) 10%, transparent); - border-radius: 3px; - padding: 1px 5px; - line-height: 1; + gap: var(--space-2); } -/* Expand/collapse */ .tc-card__expand-btn { - display: flex; + display: inline-flex; align-items: center; - gap: 4px; - margin-top: 4px; + gap: var(--space-2); padding: 0; border: none; background: none; color: var(--text-muted); - font-family: inherit; - font-size: 10px; + font-family: var(--font-ui); + font-size: var(--font-size-xs); cursor: pointer; - transition: color 0.1s; + align-self: flex-start; + transition: color var(--motion-fast); } .tc-card__expand-btn:hover { @@ -524,109 +377,55 @@ .tc-card__chevron { display: inline-block; - font-size: 10px; - transition: transform 0.15s ease; + font-size: var(--font-size-xs); + transition: transform var(--motion-base); } .tc-card__chevron--open { transform: rotate(90deg); } -/* Detail preview */ .tc-card__detail { - margin: 6px 0 2px; - padding: 6px 8px; + margin: 0; + padding: var(--space-3); background: var(--bg-sunken); border: 1px solid var(--border-subtle); - border-radius: 4px; - font-family: 'SF Mono', Menlo, Monaco, 'Courier New', monospace; - font-size: 11px; + border-radius: var(--radius-sm); + font-family: var(--font-mono); + font-size: var(--font-size-sm); color: var(--text-secondary); white-space: pre-wrap; overflow-wrap: break-word; - max-height: 140px; + max-height: 160px; overflow-y: auto; line-height: 1.5; } -/* Approval actions */ .tc-card__actions { display: flex; - gap: 6px; - margin-top: 8px; -} - -.tc-card__btn { - display: inline-flex; - align-items: center; - gap: 4px; - border-radius: 4px; - padding: 4px 12px; - font-family: inherit; - font-size: 11px; - font-weight: 600; - cursor: pointer; - transition: background 0.15s, transform 0.1s; -} - -.tc-card__btn:active { - transform: scale(0.97); -} - -.tc-card__btn-icon { - font-size: 11px; - line-height: 1; -} - -.tc-card__btn--allow { - border: 1px solid var(--text-success); - color: var(--text-success); - background: transparent; -} - -.tc-card__btn--allow:hover { - background: color-mix(in srgb, var(--text-success) 12%, transparent); -} - -.tc-card__btn--deny { - border: 1px solid var(--text-error); - color: var(--text-error); - background: transparent; -} - -.tc-card__btn--deny:hover { - background: color-mix(in srgb, var(--text-error) 12%, transparent); + gap: var(--space-2); } -/* Resolved state */ .tc-card__resolved { display: flex; align-items: center; - gap: 4px; - margin-top: 4px; - font-size: 10px; + gap: var(--space-2); + font-size: var(--font-size-xs); color: var(--text-muted); } .tc-card__resolved-icon { color: var(--text-success); - font-size: 11px; -} - -/* Awaiting state */ -.tc-card--awaiting { - border-color: color-mix(in srgb, var(--text-warning) 40%, var(--border-base)); } /* --- Status Line ------------------------------------------------ */ .chat-status { - padding: 6px 12px; - font-size: 11px; + padding: var(--space-2) var(--space-4); + font-size: var(--font-size-sm); font-style: italic; color: var(--text-muted); border-left: 2px solid var(--accent); - margin: 4px 12px; } .chat-status--warning { @@ -636,26 +435,41 @@ /* --- Chat Input ------------------------------------------------- */ +.chat-pane__footer { + flex-shrink: 0; + padding: var(--space-3) var(--space-4); + border-top: 1px solid var(--border-base); + background: var(--bg-elevated); +} + .chat-input { display: flex; align-items: flex-end; - gap: 6px; + gap: var(--space-3); + background: var(--bg-sunken); + border: 1px solid var(--border-subtle); + border-radius: var(--radius-lg); + padding: var(--space-2) var(--space-2) var(--space-2) var(--space-3); + transition: border-color var(--motion-base); +} + +.chat-input:focus-within { + border-color: var(--accent); } .chat-input__textarea { flex: 1; - background: var(--bg-sunken); - border: 1px solid var(--border-base); - border-radius: 4px; - padding: 6px 10px; - font-family: -apple-system, BlinkMacSystemFont, 'SF Pro Text', 'Helvetica Neue', sans-serif; - font-size: 13px; - line-height: 1.4; + background: transparent; + border: none; + padding: var(--space-2) 0; + font-family: var(--font-ui); + font-size: var(--font-size-lg); + line-height: 1.5; color: var(--text-primary); resize: none; outline: none; - min-height: 20px; - max-height: 146px; /* ~8 lines */ + min-height: 22px; + max-height: 160px; overflow-y: auto; } @@ -663,58 +477,6 @@ color: var(--text-muted); } -.chat-input__textarea:focus { - border-color: var(--accent); -} - -.chat-input__btn-send { - border: none; - background: none; - color: var(--accent); - font-family: inherit; - font-size: 11px; - font-weight: 600; - cursor: pointer; - padding: 4px 6px; - flex-shrink: 0; - line-height: 1; -} - -.chat-input__btn-send:disabled { - color: var(--text-muted); - cursor: default; -} - -.chat-input__btn-stop { - border: none; - background: none; - cursor: pointer; - padding: 4px 6px; - flex-shrink: 0; - display: flex; - align-items: center; - justify-content: center; -} - -.chat-input__btn-stop__icon { - width: 10px; - height: 10px; - background: var(--text-error); - border-radius: 2px; -} - -/* --- Animations ------------------------------------------------- */ - -@keyframes chat-cursor-blink { - 0%, 100% { opacity: 1; } - 50% { opacity: 0; } -} - -@keyframes chat-pulse { - 0%, 100% { opacity: 1; } - 50% { opacity: 0.4; } -} - /* --- Syntax highlight tokens (inherit from themes) -------------- */ .chat-msg--assistant .chat-msg__body .hljs-keyword { color: var(--syntax-keyword); } @@ -727,21 +489,3 @@ .chat-msg--assistant .chat-msg__body .hljs-built_in { color: var(--syntax-fn); } .chat-msg--assistant .chat-msg__body .hljs-literal { color: var(--syntax-number); } .chat-msg--assistant .chat-msg__body .hljs-type { color: var(--syntax-keyword); } - -/* Scrollbar styling */ -.chat-messages::-webkit-scrollbar { - width: 8px; -} - -.chat-messages::-webkit-scrollbar-track { - background: transparent; -} - -.chat-messages::-webkit-scrollbar-thumb { - background: var(--border-subtle); - border-radius: 4px; -} - -.chat-messages::-webkit-scrollbar-thumb:hover { - background: var(--text-muted); -} diff --git a/packages/renderer/src/styles/cli-agent-pane.css b/packages/renderer/src/styles/cli-agent-pane.css index 95a6fb4..c156ff3 100644 --- a/packages/renderer/src/styles/cli-agent-pane.css +++ b/packages/renderer/src/styles/cli-agent-pane.css @@ -51,8 +51,8 @@ } .cli-agent-pane__btn--start { - background: var(--accent-blue, #3b82f6); - color: #fff; + background: var(--accent); + color: var(--text-on-accent); } .cli-agent-pane__btn--start:hover { @@ -60,8 +60,8 @@ } .cli-agent-pane__btn--stop { - background: var(--accent-red, #ef4444); - color: #fff; + background: var(--text-error); + color: var(--text-on-accent); } .cli-agent-pane__btn--stop:hover { @@ -75,10 +75,10 @@ align-items: flex-start; gap: 6px; padding: 6px 8px; - background: color-mix(in srgb, var(--accent-red, #ef4444) 12%, transparent); + background: color-mix(in srgb, var(--text-error) 12%, transparent); border-bottom: 1px solid var(--border-base); font-size: 12px; - color: var(--accent-red, #ef4444); + color: var(--text-error); } .cli-agent-pane__error-icon { @@ -133,7 +133,7 @@ } .cli-agent-msg__prefix { - color: var(--accent-blue, #3b82f6); + color: var(--accent); font-weight: 700; flex-shrink: 0; } @@ -148,23 +148,26 @@ } .cli-agent-msg--assistant code { - background: var(--bg-elevated); + background: var(--bg-sunken); padding: 1px 4px; - border-radius: 3px; - font-size: 12px; + border-radius: var(--radius-sm); + font-size: var(--font-size-md); + font-family: var(--font-mono); } .cli-agent-msg--assistant pre { - background: var(--bg-elevated); - padding: 8px; - border-radius: 4px; + background: var(--bg-sunken); + border: 1px solid var(--border-subtle); + padding: var(--space-3); + border-radius: var(--radius-md); overflow-x: auto; - margin: 4px 0; + margin: var(--space-2) 0; + font-family: var(--font-mono); } .cli-agent-msg__cursor { animation: cli-agent-cursor-blink 0.8s step-end infinite; - color: var(--accent-blue, #3b82f6); + color: var(--accent); } @keyframes cli-agent-cursor-blink { @@ -186,7 +189,7 @@ .cli-agent-msg__tool-icon { font-size: 8px; - color: var(--accent-blue, #3b82f6); + color: var(--accent); } .cli-agent-msg__tool-name { @@ -217,13 +220,13 @@ } .cli-agent-msg--result { - color: var(--accent-green, #22c55e); + color: var(--text-success); font-style: normal; font-weight: 500; } .cli-agent-msg--error { font-size: 12px; - color: var(--accent-red, #ef4444); + color: var(--text-error); padding: 3px 0; } diff --git a/packages/renderer/src/styles/global.css b/packages/renderer/src/styles/global.css index 2d98df5..0f4cfdd 100644 --- a/packages/renderer/src/styles/global.css +++ b/packages/renderer/src/styles/global.css @@ -1,3 +1,34 @@ +:root { + /* Fonts */ + --font-ui: -apple-system, BlinkMacSystemFont, 'SF Pro Text', 'Helvetica Neue', sans-serif; + --font-mono: 'SF Mono', Menlo, Monaco, 'Courier New', monospace; + + /* Type scale */ + --font-size-xs: 10px; + --font-size-sm: 11px; + --font-size-md: 12px; + --font-size-lg: 13px; + + /* Spacing scale */ + --space-1: 2px; + --space-2: 4px; + --space-3: 8px; + --space-4: 12px; + --space-5: 16px; + --space-6: 24px; + + /* Radius */ + --radius-sm: 3px; + --radius-md: 4px; + --radius-lg: 6px; + --radius-pill: 999px; + + /* Motion */ + --motion-fast: 80ms ease; + --motion-base: 120ms ease; + --motion-slow: 200ms ease; +} + *, *::before, *::after { diff --git a/packages/renderer/src/styles/ui.css b/packages/renderer/src/styles/ui.css new file mode 100644 index 0000000..8cf35fc --- /dev/null +++ b/packages/renderer/src/styles/ui.css @@ -0,0 +1,172 @@ +/* --- Shared UI primitives --------------------------------------- */ + +/* Button */ + +.ui-btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: var(--space-2); + border: 1px solid transparent; + background: transparent; + color: var(--text-secondary); + font-family: var(--font-ui); + font-weight: 500; + border-radius: var(--radius-md); + cursor: pointer; + transition: background var(--motion-base), color var(--motion-base), + border-color var(--motion-base); + white-space: nowrap; + user-select: none; +} + +.ui-btn:disabled { + cursor: default; + opacity: 0.5; +} + +.ui-btn--sm { + height: 22px; + padding: 0 var(--space-3); + font-size: var(--font-size-sm); +} + +.ui-btn--md { + height: 28px; + padding: 0 var(--space-4); + font-size: var(--font-size-sm); +} + +/* ghost — default */ +.ui-btn--ghost { + background: transparent; + color: var(--text-secondary); +} +.ui-btn--ghost:hover:not(:disabled) { + background: var(--bg-hover); + color: var(--text-primary); +} + +/* outline */ +.ui-btn--outline { + border-color: var(--border-subtle); + color: var(--text-primary); +} +.ui-btn--outline:hover:not(:disabled) { + background: var(--bg-hover); + border-color: var(--text-muted); +} + +/* accent */ +.ui-btn--accent { + color: var(--accent); + border-color: color-mix(in srgb, var(--accent) 35%, transparent); + background: color-mix(in srgb, var(--accent) 8%, transparent); + font-weight: 600; +} +.ui-btn--accent:hover:not(:disabled) { + background: color-mix(in srgb, var(--accent) 16%, transparent); + border-color: var(--accent); +} + +/* danger */ +.ui-btn--danger { + color: var(--text-error); + border-color: color-mix(in srgb, var(--text-error) 35%, transparent); + background: transparent; + font-weight: 600; +} +.ui-btn--danger:hover:not(:disabled) { + background: color-mix(in srgb, var(--text-error) 12%, transparent); + border-color: var(--text-error); +} + +/* Badge */ + +.ui-badge { + display: inline-flex; + align-items: center; + gap: var(--space-2); + padding: 1px var(--space-3); + border-radius: var(--radius-sm); + border: 1px solid var(--border-subtle); + background: var(--bg-sunken); + color: var(--text-muted); + font-family: var(--font-ui); + font-size: var(--font-size-xs); + font-weight: 600; + letter-spacing: 0.05em; + text-transform: uppercase; + line-height: 1.6; + white-space: nowrap; + flex-shrink: 0; +} + +.ui-badge__icon { + font-size: var(--font-size-sm); + line-height: 1; +} + +.ui-badge--neutral { + color: var(--text-muted); +} +.ui-badge--success { + color: var(--text-success); + border-color: color-mix(in srgb, var(--text-success) 30%, transparent); +} +.ui-badge--warning { + color: var(--text-warning); + border-color: color-mix(in srgb, var(--text-warning) 30%, transparent); +} +.ui-badge--error { + color: var(--text-error); + border-color: color-mix(in srgb, var(--text-error) 30%, transparent); +} +.ui-badge--info { + color: var(--text-info); + border-color: color-mix(in srgb, var(--text-info) 30%, transparent); +} + +/* Segmented control */ + +.ui-segmented { + display: inline-flex; + border: 1px solid var(--border-subtle); + border-radius: var(--radius-md); + overflow: hidden; + height: 22px; + background: var(--bg-sunken); +} + +.ui-segmented--disabled { + opacity: 0.5; + pointer-events: none; +} + +.ui-segmented__btn { + border: none; + background: transparent; + color: var(--text-muted); + font-family: var(--font-ui); + font-size: var(--font-size-xs); + font-weight: 600; + letter-spacing: 0.06em; + text-transform: uppercase; + cursor: pointer; + padding: 0 var(--space-3); + transition: background var(--motion-base), color var(--motion-base); +} + +.ui-segmented__btn + .ui-segmented__btn { + border-left: 1px solid var(--border-subtle); +} + +.ui-segmented__btn:hover:not([aria-selected='true']) { + background: var(--bg-hover); + color: var(--text-secondary); +} + +.ui-segmented__btn--active { + background: color-mix(in srgb, var(--accent) 14%, transparent); + color: var(--text-primary); +} From 2ce01dfafbcd8e9c142a8b8fbab80592522bb9e1 Mon Sep 17 00:00:00 2001 From: BT Odoy Date: Wed, 8 Apr 2026 16:03:18 -0400 Subject: [PATCH 3/9] feat: prep CLI agent UI for opencode + backend hot-swap Adds opencode as a first-class CLI backend across the renderer, introduces a backend switcher in CliAgentPane, and renders per-message backend badges for mixed-backend transcripts. Centralizes backend label/CLI-detection helpers in lib/agentBackend.ts so routing, history, and settings stop hardcoding claude-code/codex checks. Forward-compatible with the upcoming cliAgentSwitchBackend IPC: falls back to start() when unavailable. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../renderer/src/commands/domains/agent.ts | 13 ++-- .../src/components/layout/AppShell.tsx | 11 +-- .../src/components/panes/ChatHistoryPane.tsx | 63 +++++++++------ .../src/components/panes/CliAgentPane.tsx | 73 ++++++++++++++++-- packages/renderer/src/hooks/useCliAgent.ts | 35 +++++++++ packages/renderer/src/lib/agentBackend.ts | 42 ++++++++++ packages/renderer/src/lib/settingsSchema.ts | 11 ++- .../src/lib/workspace/workspaceSwitcher.ts | 5 +- .../renderer/src/styles/chat-history-pane.css | 69 +++++++++++++++++ .../renderer/src/styles/cli-agent-pane.css | 76 +++++++++++++++++++ packages/shared/src/cliAgentTypes.ts | 11 ++- packages/shared/src/index.ts | 5 +- 12 files changed, 371 insertions(+), 43 deletions(-) create mode 100644 packages/renderer/src/lib/agentBackend.ts diff --git a/packages/renderer/src/commands/domains/agent.ts b/packages/renderer/src/commands/domains/agent.ts index 5dbe1f6..c05a17d 100644 --- a/packages/renderer/src/commands/domains/agent.ts +++ b/packages/renderer/src/commands/domains/agent.ts @@ -4,10 +4,11 @@ * Backend choice comes from resolved settings (`agent.backend`). Built-in chat creates a conversation via IPC first when possible. */ -import type { ConversationMeta } from '@aide/shared' +import type { ConversationMeta, ExternalCliBackend } from '@aide/shared' import type { DockviewApi, IDockviewPanel } from 'dockview-react' import type { CommandContext, GetCommandContext } from '../context' import type { CommandSpec } from './types' +import { backendLabel, isCliBackend } from '../../lib/agentBackend' /** * Creates a `chatPane` with a server-issued `conversationId`, or falls back to a panel without id if create fails. @@ -61,7 +62,7 @@ function addCliAgentPanel( workspaceId: string, workspaceRoot: string | undefined, editorPanel: IDockviewPanel | undefined, - backend: Extract, + backend: ExternalCliBackend, ): void { void window.api.conversationCreate({ workspaceId, @@ -71,7 +72,7 @@ function addCliAgentPanel( id: `agent-${Date.now()}`, component: 'cliAgentPane', tabComponent: 'agentTab', - title: backend === 'claude-code' ? 'Claude Code' : 'Codex', + title: backendLabel(backend), params: { workspaceId, workspaceRoot, @@ -89,7 +90,7 @@ function addCliAgentPanel( id: `agent-${Date.now()}`, component: 'cliAgentPane', tabComponent: 'agentTab', - title: backend === 'claude-code' ? 'Claude Code' : 'Codex', + title: backendLabel(backend), params: { workspaceId, workspaceRoot, @@ -124,7 +125,7 @@ export function collectAgentCommands(getCtx: GetCommandContext): CommandSpec[] { void window.api.getResolvedSettings(workspaceId).then((resolved) => { const backend = resolved['agent.backend'] ?? 'built-in' - if (backend === 'claude-code' || backend === 'codex') { + if (isCliBackend(backend)) { addCliAgentPanel(ctx, api, workspaceId, workspaceRoot, editorPanel, backend) } else { addBuiltInAgentPanel(ctx, api, workspaceId, workspaceRoot, editorPanel) @@ -148,7 +149,7 @@ export function collectAgentCommands(getCtx: GetCommandContext): CommandSpec[] { api.addPanel({ id: `agent-${Date.now()}`, component: - conv.source === 'claude-native' || conv.backend === 'claude-code' || conv.backend === 'codex' + conv.source === 'claude-native' || isCliBackend(conv.backend) ? 'cliAgentPane' : 'chatPane', tabComponent: 'agentTab', diff --git a/packages/renderer/src/components/layout/AppShell.tsx b/packages/renderer/src/components/layout/AppShell.tsx index d277d4a..a850ee1 100644 --- a/packages/renderer/src/components/layout/AppShell.tsx +++ b/packages/renderer/src/components/layout/AppShell.tsx @@ -28,6 +28,7 @@ import { autoSave, switchWorkspace as doSwitchWorkspace } from '../../lib/worksp import { createTerminalPanelParams, getTerminalParams } from '../../lib/terminal/terminalState' import { createBrowserPanelParams, getBrowserParams } from '../../lib/browserState' import { getPanelZoomFactor, updatePanelZoomParams } from '../../lib/panelZoom' +import { backendLabel, isCliBackend } from '../../lib/agentBackend' import { DockviewNavigation } from '../../lib/dockviewNavigation' import { captureWorkspaceRuntimeSnapshot, @@ -920,7 +921,7 @@ export function AppShell() { if (!isStillCurrent()) return const backend = resolved['agent.backend'] ?? 'built-in' - if (backend === 'claude-code' || backend === 'codex') { + if (isCliBackend(backend)) { void window.api.conversationCreate({ workspaceId: activeWorkspaceId, backend, @@ -933,8 +934,8 @@ export function AppShell() { component: 'cliAgentPane', tabComponent: 'agentTab', title: branch - ? `${backend === 'claude-code' ? 'Claude Code' : 'Codex'} (${branch})` - : backend === 'claude-code' ? 'Claude Code' : 'Codex', + ? `${backendLabel(backend)} (${branch})` + : backendLabel(backend), params: { workspaceId: activeWorkspaceId, workspaceRoot: workspaceRoot ?? undefined, @@ -956,8 +957,8 @@ export function AppShell() { component: 'cliAgentPane', tabComponent: 'agentTab', title: branch - ? `${backend === 'claude-code' ? 'Claude Code' : 'Codex'} (${branch})` - : backend === 'claude-code' ? 'Claude Code' : 'Codex', + ? `${backendLabel(backend)} (${branch})` + : backendLabel(backend), params: { workspaceId: activeWorkspaceId, workspaceRoot: workspaceRoot ?? undefined, diff --git a/packages/renderer/src/components/panes/ChatHistoryPane.tsx b/packages/renderer/src/components/panes/ChatHistoryPane.tsx index e10bf74..04a9082 100644 --- a/packages/renderer/src/components/panes/ChatHistoryPane.tsx +++ b/packages/renderer/src/components/panes/ChatHistoryPane.tsx @@ -1,7 +1,8 @@ import { useState, useRef, useEffect, useCallback } from 'react' import type { IDockviewPanelProps } from 'dockview-react' -import type { ConversationMeta, AgentBackend } from '@aide/shared' +import type { AgentBackend, ConversationMeta } from '@aide/shared' import { useConversationHistory } from '../../hooks/useConversationHistory' +import { CLI_BACKENDS, backendBadgeLabel, backendLabel, isCliBackend } from '../../lib/agentBackend' import '../../styles/chat-history-pane.css' interface ChatHistoryPanelParams { @@ -18,6 +19,7 @@ export function ChatHistoryPane({ params }: IDockviewPanelProps(null) const [renaming, setRenaming] = useState(null) const [renameValue, setRenameValue] = useState('') + const [newMenuOpen, setNewMenuOpen] = useState(false) const filterRef = useRef(null) /** Dedupe double-click (two click events + dblclick) opening multiple tabs. */ const lastActivateRef = useRef<{ id: string; t: number } | null>(null) @@ -78,13 +80,43 @@ export function ChatHistoryPane({ params }: IDockviewPanelProps Conversations - +
+ + + {newMenuOpen && ( +
e.stopPropagation()}> + +
+ {CLI_BACKENDS.map((b) => ( + + ))} +
+ )} +
{/* Filter */} @@ -147,7 +179,7 @@ export function ChatHistoryPane({ params }: IDockviewPanelPropsCLI )} - {backendLabel(meta.backend)} + {backendBadgeLabel(meta.backend)}
@@ -183,19 +215,6 @@ export function ChatHistoryPane({ params }: IDockviewPanelProps { + const set = new Set() + for (const m of agent.messages) { + if (m.backend) set.add(m.backend) + } + return set + }, [agent.messages]) + const showBackendBadges = backendsSeen.size > 1 + + const handleBackendChange = async (next: AgentBackend) => { + if (next === headerBackend) return + if (isActive) return + const ok = await agent.switchBackend(next) + // Fall back to a fresh start() if the hot-swap IPC isn't wired in this build. + if (!ok) await agent.start(next) + } return (
{/* Header */}
- - {backend === 'claude-code' ? 'Claude Code' : 'Codex'} - + {agent.model && ( {agent.model} )} @@ -122,7 +159,11 @@ export function CliAgentPane({ params, api }: IDockviewPanelProps ( - + ))} {agent.streamingContent && ( @@ -150,7 +191,23 @@ export function CliAgentPane({ params, api }: IDockviewPanelProps + {backendBadgeLabel(message.backend)} + + ) : null + if (message.type === 'user') { return (
@@ -166,6 +223,7 @@ function CliAgentMessageBubble({ message }: { message: CliAgentMessage }) { {message.toolName ?? 'tool'} {message.content} + {badge}
) } @@ -174,6 +232,7 @@ function CliAgentMessageBubble({ message }: { message: CliAgentMessage }) { return (
{message.content} + {badge}
) } @@ -182,6 +241,7 @@ function CliAgentMessageBubble({ message }: { message: CliAgentMessage }) { return (
{message.content} + {badge}
) } @@ -190,6 +250,7 @@ function CliAgentMessageBubble({ message }: { message: CliAgentMessage }) { return (
{message.content} + {badge}
) } @@ -198,6 +259,7 @@ function CliAgentMessageBubble({ message }: { message: CliAgentMessage }) { return (
{message.content} + {badge}
) } @@ -206,6 +268,7 @@ function CliAgentMessageBubble({ message }: { message: CliAgentMessage }) { return (
+ {badge}
) } diff --git a/packages/renderer/src/hooks/useCliAgent.ts b/packages/renderer/src/hooks/useCliAgent.ts index a486013..9b4c1e3 100644 --- a/packages/renderer/src/hooks/useCliAgent.ts +++ b/packages/renderer/src/hooks/useCliAgent.ts @@ -20,9 +20,13 @@ export interface UseCliAgentReturn { model: string | null lastError: string | null conversationTitle: string + /** Currently active external backend for this session (source of truth for the pane header). */ + activeBackend: AgentBackend | null start: (backend: AgentBackend) => Promise send: (content: string) => Promise stop: () => void + /** Hot-swap the underlying CLI backend for this conversation. Returns true on success. */ + switchBackend: (backend: AgentBackend) => Promise } export interface UseCliAgentOptions { @@ -34,6 +38,7 @@ export interface UseCliAgentOptions { export function useCliAgent(options: UseCliAgentOptions): UseCliAgentReturn { const { workspaceId, conversationId, worktreePath } = options + const [activeBackend, setActiveBackend] = useState(options.backend ?? null) const sessionIdRef = useRef(null) const messagesRef = useRef([]) const [renderTick, setRenderTick] = useState(0) @@ -82,6 +87,7 @@ export function useCliAgent(options: UseCliAgentOptions): UseCliAgentReturn { setProcessStatus(session.processStatus) setModel(session.model ?? null) setLastError(session.lastError ?? null) + setActiveBackend(session.activeBackend ?? session.backend ?? options.backend ?? null) setHistoryHydrated(true) tick() return @@ -216,6 +222,7 @@ export function useCliAgent(options: UseCliAgentOptions): UseCliAgentReturn { setProcessStatus('error') } else { sessionIdRef.current = result.sessionId + setActiveBackend(backend) const session = await window.api.cliAgentGetSession(workspaceId, result.sessionId) if (session) { // Main starts native / external sessions with [] until first send; keep hydrated history. @@ -225,11 +232,37 @@ export function useCliAgent(options: UseCliAgentOptions): UseCliAgentReturn { setProcessStatus(session.processStatus) setModel(session.model ?? null) setLastError(session.lastError ?? null) + setActiveBackend(session.activeBackend ?? session.backend ?? backend) } tick() } }, [workspaceId, conversationId, worktreePath, tick]) + const switchBackend = useCallback(async (backend: AgentBackend): Promise => { + const sid = sessionIdRef.current + if (!sid) return false + // The hot-swap IPC ships with the backend rework. Until then, gracefully no-op + // and let the caller fall back to the start() path. + const api = window.api as typeof window.api & { + cliAgentSwitchBackend?: ( + sessionId: string, + backend: AgentBackend, + ) => Promise<{ success: true } | { error: string }> + } + if (typeof api.cliAgentSwitchBackend !== 'function') { + setLastError('Backend hot-swap is not available in this build yet.') + return false + } + const result = await api.cliAgentSwitchBackend(sid, backend) + if ('error' in result) { + setLastError(result.error) + return false + } + setActiveBackend(backend) + setLastError(null) + return true + }, []) + const send = useCallback(async (content: string) => { const sid = sessionIdRef.current if (!sid || !content.trim()) return @@ -264,8 +297,10 @@ export function useCliAgent(options: UseCliAgentOptions): UseCliAgentReturn { model, lastError, conversationTitle, + activeBackend, start, send, stop, + switchBackend, } } diff --git a/packages/renderer/src/lib/agentBackend.ts b/packages/renderer/src/lib/agentBackend.ts new file mode 100644 index 0000000..5f18243 --- /dev/null +++ b/packages/renderer/src/lib/agentBackend.ts @@ -0,0 +1,42 @@ +/** + * Frontend helpers for the agent-backend taxonomy. + * + * Single source of truth for which backends count as "CLI" (external), + * how to label them in the UI, and a stable color slug for badges. Keep + * the renderer's switch statements out of feature files — they belong here. + */ + +import type { AgentBackend, ExternalCliBackend } from '@aide/shared' + +/** External CLI backends, in the order we render them in pickers. */ +export const CLI_BACKENDS: readonly ExternalCliBackend[] = [ + 'claude-code', + 'opencode', + 'codex', +] as const + +export function isCliBackend(backend: AgentBackend): backend is ExternalCliBackend { + return backend === 'claude-code' || backend === 'opencode' || backend === 'codex' +} + +/** Human-facing label for tabs, headers, and command palette entries. */ +export function backendLabel(backend: AgentBackend): string { + switch (backend) { + case 'claude-code': return 'Claude Code' + case 'opencode': return 'OpenCode' + case 'codex': return 'Codex' + case 'built-in': return 'Built-in' + default: return backend + } +} + +/** Compact lowercase label for inline badges in history/transcript rows. */ +export function backendBadgeLabel(backend: AgentBackend): string { + switch (backend) { + case 'claude-code': return 'claude' + case 'opencode': return 'opencode' + case 'codex': return 'codex' + case 'built-in': return 'built-in' + default: return backend + } +} diff --git a/packages/renderer/src/lib/settingsSchema.ts b/packages/renderer/src/lib/settingsSchema.ts index 2053f56..4a46178 100644 --- a/packages/renderer/src/lib/settingsSchema.ts +++ b/packages/renderer/src/lib/settingsSchema.ts @@ -129,11 +129,12 @@ export const SETTINGS_DESCRIPTORS: SettingDescriptor[] = [ { key: 'agent.backend', label: 'Agent Backend', - description: 'Which agent backend to use. Built-in uses the integrated LLM agent. Claude Code and Codex wrap external CLI tools.', + description: 'Default agent backend for new chats. Built-in uses the integrated LLM agent. Claude Code, OpenCode, and Codex wrap external CLI tools — these can be hot-swapped per conversation from the agent pane.', type: 'enum', enumValues: [ { value: 'built-in', label: 'Built-in' }, { value: 'claude-code', label: 'Claude Code' }, + { value: 'opencode', label: 'OpenCode' }, { value: 'codex', label: 'Codex' }, ], category: 'agent.backend', @@ -147,6 +148,14 @@ export const SETTINGS_DESCRIPTORS: SettingDescriptor[] = [ category: 'agent.backend', scope: 'user', }, + { + key: 'agent.opencodePath', + label: 'OpenCode Path', + description: 'Path to the OpenCode CLI binary, or leave empty to use the bundled @opencode-ai/sdk and let aIDE manage the OpenCode server process.', + type: 'string', + category: 'agent.backend', + scope: 'user', + }, { key: 'agent.codexPath', label: 'Codex Path', diff --git a/packages/renderer/src/lib/workspace/workspaceSwitcher.ts b/packages/renderer/src/lib/workspace/workspaceSwitcher.ts index e794c8a..805ee70 100644 --- a/packages/renderer/src/lib/workspace/workspaceSwitcher.ts +++ b/packages/renderer/src/lib/workspace/workspaceSwitcher.ts @@ -7,6 +7,7 @@ import type { DockviewApi } from 'dockview-react' import type { AgentBackend, AideLocalState, AideLocalTerminals } from '@aide/shared' +import { backendLabel, isCliBackend } from '../agentBackend' import { clearCache } from '../editor/editorStateCache' import { hydrateDocumentStoreFromOpenTabs } from '../editor/documentStore' import { createRestoredTerminalPanelParams, createTerminalPanelParams } from '../terminal/terminalState' @@ -222,7 +223,7 @@ async function createDefaultLayout( const backend: AgentBackend = resolvedSettings?.['agent.backend'] ?? 'built-in' - if (backend === 'claude-code' || backend === 'codex') { + if (isCliBackend(backend)) { const conversationId = await window.api.conversationCreate({ workspaceId, backend, @@ -233,7 +234,7 @@ async function createDefaultLayout( id: 'agent', component: 'cliAgentPane', tabComponent: 'agentTab', - title: backend === 'claude-code' ? 'Claude Code' : 'Codex', + title: backendLabel(backend), params: { workspaceId, workspaceRoot: workspaceRoot ?? undefined, diff --git a/packages/renderer/src/styles/chat-history-pane.css b/packages/renderer/src/styles/chat-history-pane.css index 4fea232..aa12f2d 100644 --- a/packages/renderer/src/styles/chat-history-pane.css +++ b/packages/renderer/src/styles/chat-history-pane.css @@ -52,6 +52,70 @@ filter: brightness(1.15); } +.chat-history-pane__new-wrap { + position: relative; + display: flex; + align-items: center; + gap: 2px; +} + +.chat-history-pane__new-caret { + border: none; + background: transparent; + color: var(--text-secondary); + width: 14px; + height: 20px; + font-size: 9px; + line-height: 1; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + border-radius: 3px; +} + +.chat-history-pane__new-caret:hover { + background: var(--bg-hover); + color: var(--text-primary); +} + +.chat-history-pane__new-menu { + position: absolute; + top: calc(100% + 4px); + right: 0; + z-index: 1000; + min-width: 140px; + padding: 4px 0; + background: var(--bg-overlay); + border: 1px solid var(--border-subtle); + border-radius: 6px; + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.3); +} + +.chat-history-pane__new-menu-item { + display: block; + width: 100%; + border: none; + background: transparent; + color: var(--text-primary); + font-family: inherit; + font-size: 12px; + padding: 4px 12px; + text-align: left; + cursor: pointer; +} + +.chat-history-pane__new-menu-item:hover { + background: var(--accent); + color: var(--text-on-accent); +} + +.chat-history-pane__new-menu-sep { + height: 1px; + background: var(--border-subtle); + margin: 4px 8px; +} + /* --- Filter ----------------------------------------------------- */ .chat-history-pane__filter-row { @@ -185,6 +249,11 @@ background: color-mix(in srgb, var(--text-success) 12%, transparent); } +.chat-history-pane__item-badge--opencode { + color: var(--text-info); + background: color-mix(in srgb, var(--text-info) 14%, transparent); +} + .chat-history-pane__item-badge--codex { color: var(--text-warning); background: color-mix(in srgb, var(--text-warning) 12%, transparent); diff --git a/packages/renderer/src/styles/cli-agent-pane.css b/packages/renderer/src/styles/cli-agent-pane.css index c156ff3..9283a32 100644 --- a/packages/renderer/src/styles/cli-agent-pane.css +++ b/packages/renderer/src/styles/cli-agent-pane.css @@ -23,6 +23,38 @@ background: var(--bg-elevated); } +.cli-agent-pane__backend-switcher { + position: relative; + display: inline-flex; + align-items: center; + gap: 4px; + padding: 2px 6px; + border-radius: 3px; + cursor: pointer; + border: 1px solid transparent; + transition: background 100ms, border-color 100ms; +} + +.cli-agent-pane__backend-switcher:hover { + background: var(--bg-hover); + border-color: var(--border-subtle); +} + +.cli-agent-pane__backend-switcher:has(select:disabled) { + cursor: not-allowed; + opacity: 0.7; +} + +.cli-agent-pane__backend-switcher--claude-code .cli-agent-pane__backend-label { + color: var(--text-success); +} +.cli-agent-pane__backend-switcher--opencode .cli-agent-pane__backend-label { + color: var(--text-info); +} +.cli-agent-pane__backend-switcher--codex .cli-agent-pane__backend-label { + color: var(--text-warning); +} + .cli-agent-pane__backend-label { font-weight: 600; font-size: 11px; @@ -31,6 +63,22 @@ color: var(--text-secondary); } +.cli-agent-pane__backend-caret { + font-size: 8px; + color: var(--text-muted); + line-height: 1; +} + +.cli-agent-pane__backend-select { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + opacity: 0; + cursor: inherit; + font: inherit; +} + .cli-agent-pane__model { font-size: 11px; color: var(--text-muted); @@ -230,3 +278,31 @@ color: var(--text-error); padding: 3px 0; } + +/* --- Per-message backend badge (mixed-backend transcripts) -------- */ + +.cli-agent-msg__backend-badge { + display: inline-block; + margin-left: 6px; + padding: 0 4px; + font-size: 9px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.04em; + border-radius: 2px; + vertical-align: middle; + white-space: nowrap; +} + +.cli-agent-msg__backend-badge--claude-code { + color: var(--text-success); + background: color-mix(in srgb, var(--text-success) 14%, transparent); +} +.cli-agent-msg__backend-badge--opencode { + color: var(--text-info); + background: color-mix(in srgb, var(--text-info) 14%, transparent); +} +.cli-agent-msg__backend-badge--codex { + color: var(--text-warning); + background: color-mix(in srgb, var(--text-warning) 14%, transparent); +} diff --git a/packages/shared/src/cliAgentTypes.ts b/packages/shared/src/cliAgentTypes.ts index b64bb43..1c2b861 100644 --- a/packages/shared/src/cliAgentTypes.ts +++ b/packages/shared/src/cliAgentTypes.ts @@ -9,7 +9,10 @@ // Agent backend selection // --------------------------------------------------------------------------- -export type AgentBackend = 'built-in' | 'claude-code' | 'codex' +export type AgentBackend = 'built-in' | 'claude-code' | 'opencode' | 'codex' + +/** External (non-built-in) CLI agent backends. */ +export type ExternalCliBackend = Exclude // --------------------------------------------------------------------------- // Process lifecycle @@ -44,6 +47,9 @@ export interface CliAgentMessage { durationMs?: number totalCostUsd?: number isSuccess?: boolean + + /** Which backend produced this message (set on external-agent messages). */ + backend?: AgentBackend } // --------------------------------------------------------------------------- @@ -66,6 +72,9 @@ export interface CliAgentSession { id: string workspaceId: string backend: AgentBackend + /** Currently active backend for this session. Source of truth for the pane header + * once hot-swap is wired up; falls back to `backend` for legacy sessions. */ + activeBackend?: AgentBackend processStatus: CliAgentProcessStatus messages: CliAgentMessage[] model?: string diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index d7a9b50..8ac96c8 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -14,7 +14,7 @@ export type { LlmProviderConfig, } from './agentTypes' export type { - AgentBackend, CliAgentProcessStatus, + AgentBackend, ExternalCliBackend, CliAgentProcessStatus, CliAgentMessage, CliAgentStreamDelta, CliAgentSession, CliAgentStatusPayload, CliAgentResultPayload, CliAgentMessagePayload, } from './cliAgentTypes' @@ -399,6 +399,7 @@ export interface AideProjectSettings { // Agent / Backend settings 'agent.backend'?: AgentBackend 'agent.claudeCodePath'?: string + 'agent.opencodePath'?: string 'agent.codexPath'?: string } @@ -429,6 +430,7 @@ export interface ResolvedSettings { // Agent / Backend settings 'agent.backend': AgentBackend 'agent.claudeCodePath': string + 'agent.opencodePath': string 'agent.codexPath': string } @@ -443,6 +445,7 @@ export const SENSITIVE_AGENT_KEYS: ReadonlySet = new Set([ 'agent.baseUrl', 'agent.backend', 'agent.claudeCodePath', + 'agent.opencodePath', 'agent.codexPath', 'agent.permissionTier', 'agent.autoApprove', From 5c2520d4e6bfd3fd658a22e8270a38899b88426f Mon Sep 17 00:00:00 2001 From: BT Odoy Date: Wed, 8 Apr 2026 16:15:50 -0400 Subject: [PATCH 4/9] feat: backend multi --- docs/IDE_BUILD_PLAN.md | 2 +- docs/cli-agent-backend-hotswap-report.md | 331 ++++++ package.json | 1 + packages/main/package.json | 1 + .../src/chat/cliAdapters/claudeCodeAdapter.ts | 282 +++++ .../main/src/chat/cliAdapters/codexAdapter.ts | 162 +++ .../src/chat/cliAdapters/openCodeAdapter.ts | 338 ++++++ packages/main/src/chat/cliAdapters/types.ts | 25 + packages/main/src/chat/cliAgentManager.ts | 873 +++++++--------- packages/main/src/chat/conversationStore.ts | 28 +- packages/main/src/index.ts | 959 +++++++++++------- packages/main/src/preload.ts | 283 ++++-- .../main/src/workspace/settingsResolver.ts | 23 +- packages/shared/src/cliAgentTypes.ts | 25 +- packages/shared/src/index.ts | 199 +++- pnpm-lock.yaml | 13 + tests/unit/cliAgentManager.test.ts | 33 + 17 files changed, 2515 insertions(+), 1063 deletions(-) create mode 100644 docs/cli-agent-backend-hotswap-report.md create mode 100644 packages/main/src/chat/cliAdapters/claudeCodeAdapter.ts create mode 100644 packages/main/src/chat/cliAdapters/codexAdapter.ts create mode 100644 packages/main/src/chat/cliAdapters/openCodeAdapter.ts create mode 100644 packages/main/src/chat/cliAdapters/types.ts diff --git a/docs/IDE_BUILD_PLAN.md b/docs/IDE_BUILD_PLAN.md index 5804572..3474aab 100644 --- a/docs/IDE_BUILD_PLAN.md +++ b/docs/IDE_BUILD_PLAN.md @@ -1365,7 +1365,7 @@ Track milestone completion here. Update as you go. | 5.1c CLI native session hydration | ✅ Complete | `ClaudeNativeSessionWatcher.loadMessages(sessionId)` reads `~/.claude/projects//.jsonl`, skips sidechains / thinking / file-history blocks, and maps rows to `CliAgentMessage[]`. IPC `cli-agent:load-messages` takes `(workspaceId, conversationId)`, returns `[]` when that workspace is not active (avoids native-prefix reads against the wrong project dir), and `useCliAgent` uses a hydrate generation guard, avoids clobbering a non-empty transcript with a stale empty IPC result, clears transcript only when the workspace+conversation key changes, and re-fetches native history once when `CONVERSATION_LIST_CHANGED` reports a `claude-native` row with messages but the pane is still empty. `CliAgentPane` waits on `historyHydrated`. Aide-managed sessions use `ConversationStore.loadMessages`. `CliAgentManager.start` sets `claudeSessionId` from `claude-native:` for `--resume`. | | 5.1d CLI multi-tab isolation | ✅ Complete | New `cliAgentPane` instances receive a provisional `conversationId` (`crypto.randomUUID()` from `agent.open` and default workspace layout in `workspaceSwitcher`). `useCliAgent` stops calling `cliAgentGetSession(workspaceId)` without a session id so tabs are not hydrated from `CliAgentManager.getSession`'s first in-memory match for the workspace. Command `agent.open` (e.g. Cmd+K Cmd+A) always adds a new agent panel for parallel work instead of focusing an existing tab. | | 5.1e Multi-worktree agent orchestration | ✅ Complete | Per-panel worktree isolation: `worktreePath` threaded through `CliAgentManager.start()` → spawn `cwd`, `CliAgentSession`, IPC, `useCliAgent` hook, `CliAgentPane` params. `AgentTab` custom tab component with branch badge pill (registered in `DockviewContainer.tabComponents`). "Start Agent in Worktree" button + context menu entry in `WorktreePanel`/`WorktreeItem`. Built-in agent isolation via `ToolContext.effectiveRoot` in `agentTools.ts` (terminal_exec, search_files, git_status, git_diff). `ChatSession.worktreePath` loaded from `ConversationMeta`. Worktree removal confirmation guard. Terminal panels from worktrees also get branch badges. | -| 5.2 Claude Agent SDK integration | ✅ Complete | `CliAgentManager` streams Claude responses via `@anthropic-ai/claude-agent-sdk` `query()`, persists/resumes sessions, and now resolves the packaged executable from the SDK's own unpacked `cli.js` before falling back to the legacy `@anthropic-ai/claude-code` path. This fixes packaged-app startup failures caused by stale Claude Code path resolution. OpenCode/Codex SDK integration remains future work. | +| 5.2 Claude Agent SDK integration | ✅ Complete | `CliAgentManager` now owns a generic external-agent session layer with backend adapters for Claude Code, OpenCode, and Codex. Claude still streams via `@anthropic-ai/claude-agent-sdk` and resolves the packaged executable from the SDK's own unpacked `cli.js`; OpenCode is wired through the official SDK/client flow against an `opencode serve` process; Codex is wired through `codex exec --json`. Sessions persist per-backend resume state so a conversation can hot-swap external backends, while `ConversationMeta.backend` tracks the active backend. Frontend hot-swap controls and richer mixed-backend transcript UI are still pending. | | 5.3 Diff preview before apply | ⬜ Not started | | | 5.4 Agent edit highlighting in editor | ⬜ Not started | | | 5.5 Crash recovery | ⬜ Not started | | diff --git a/docs/cli-agent-backend-hotswap-report.md b/docs/cli-agent-backend-hotswap-report.md new file mode 100644 index 0000000..d481dcd --- /dev/null +++ b/docs/cli-agent-backend-hotswap-report.md @@ -0,0 +1,331 @@ +# CLI Agent Backend Hotswap Report + +## Goal + +Support `claude-code`, `opencode`, and `codex` as first-class agent-chat backends, with backend hot swapping inside the same CLI agent chat pane. + +This report covers: + +- what backend work will be done +- what data and IPC contracts will change +- what requires frontend changes from the separate frontend agent + +## Current state + +The current implementation is not a generic CLI-agent backend. It is a Claude-specific manager with a Codex stub. + +### Current backend limitations + +- `packages/shared/src/cliAgentTypes.ts` + - `AgentBackend` only includes `built-in | claude-code | codex` + - there is no `opencode` +- `packages/main/src/chat/cliAgentManager.ts` + - hard-coded to `@anthropic-ai/claude-agent-sdk` + - stores a single `claudeSessionId` + - normalizes only Claude SDK message shapes + - returns `Codex integration coming soon.` for `codex` +- `packages/shared/src/conversationTypes.ts` + - `ConversationMeta` stores `claudeSessionId`, not generic per-backend session state + - `source` only models `claude-native` +- `packages/main/src/index.ts` + - settings updates only push `agent.claudeCodePath` and `agent.codexPath` +- renderer code is hard-coded around only two external backends + - `CliAgentPane.tsx` + - `ChatHistoryPane.tsx` + - `AppShell.tsx` + - `workspaceSwitcher.ts` + - `commands/domains/agent.ts` + +### Hot swapping is not possible today + +The current `cliAgentStart()` path assumes a single fixed backend per pane/session. If the same conversation is reopened with a different backend, the backend-specific session semantics are wrong: + +- Claude resume uses `claudeSessionId` +- OpenCode and Codex will need their own resume/session state +- mixed-backend transcripts have no per-message backend attribution + +## Backend work I will do + +### 1. Introduce a real external-agent backend abstraction + +I will split the current Claude-specific manager into: + +- `CliAgentManager` + - session lifecycle + - persistence + - IPC emission + - backend switching +- backend adapters + - `ClaudeCodeAdapter` + - `OpenCodeAdapter` + - `CodexAdapter` + +Each adapter will be responsible for: + +- executable or SDK resolution +- creating a run for a prompt +- streaming normalized events back to the manager +- stop/cancel behavior +- per-backend resume/session token handling + +### 2. Make session persistence generic instead of Claude-only + +I will replace the single Claude-only resume field with generic per-backend external session state. + +Planned shape: + +```ts +type ExternalCliBackend = 'claude-code' | 'opencode' | 'codex' + +interface ExternalBackendState { + sessionId?: string + model?: string +} + +type ExternalBackendStateMap = Partial> +``` + +This will be stored with the conversation transcript so a single conversation can switch between backends and still resume correctly when switched back later. + +### 3. Add OpenCode as a first-class backend + +I will wire `opencode` into the same external-agent pipeline as Claude and Codex. + +OpenCode has a solid direct SDK story, so this backend will use the official `@opencode-ai/sdk` route instead of treating OpenCode as a raw terminal/TUI integration. + +Planned integration shape: + +- use `createOpencode()` when aIDE needs to spin up and own the OpenCode server process +- use `createOpencodeClient()` when connecting to an already-running OpenCode instance +- talk to OpenCode over the SDK's HTTP client surface rather than scraping terminal output +- normalize OpenCode session/message events into the same `CliAgentMessage` stream used by the pane + +Backend additions: + +- add `opencode` to `AgentBackend` +- add `agent.opencodePath` setting support alongside existing path settings +- add backend label helpers and path update plumbing +- add OpenCode adapter resolution and startup logic + +What I will not use for the initial backend pass: + +- `@opencode-ai/plugin` + - useful for extending OpenCode with custom tools/hooks, but not required just to embed OpenCode as a selectable backend in aIDE +- ACP + - useful for editor-native embedding over stdio/JSON-RPC, but the direct SDK client route is the cleaner fit for the current Electron main-process architecture + +### 4. Add Codex as a real backend instead of a stub + +I will replace the current stub with a real adapter. + +Important constraint: + +- if Codex exposes a stable machine-readable stream/protocol, I will normalize that into the existing message model +- if Codex only exposes an interactive TUI path, I will need to bridge it through a dedicated process transport instead of the current Claude-style message flow + +Either way, the backend manager will be refactored so Codex is no longer hard-coded as unsupported. + +### 5. Add backend hot swap support to the manager and IPC + +I will add explicit backend switching support rather than overloading `start()`. + +Planned behavior: + +- one CLI conversation can keep a single transcript +- each turn runs on the currently selected backend +- switching backends preserves prior transcript and per-backend external session state +- if a backend has never seen the conversation before, the manager will seed it from the existing conversation transcript instead of using a native resume token + +Backend/API work: + +- add a new `cliAgentSwitchBackend(sessionId, backend)` IPC path +- expose the active backend in `CliAgentSession` +- preserve per-backend state in persisted conversation data +- prevent or explicitly stop active runs before switching + +### 6. Attribute messages to the backend that produced them + +I will add backend attribution to normalized CLI messages so mixed-backend chats remain understandable. + +Planned shape: + +```ts +interface CliAgentMessage { + ... + backend?: 'claude-code' | 'opencode' | 'codex' +} +``` + +This is required once a single conversation can contain output from multiple external backends. + +### 7. Migrate existing stored conversations safely + +I will add a backward-compatible lazy migration path so existing Claude conversations continue to work. + +Migration plan: + +- existing `claudeSessionId` will be read and copied into generic backend state for `claude-code` +- existing conversation metadata with `backend: 'claude-code'` remains valid +- `claude-native` mirrored sessions remain Claude-only and stay separate from generic external session state + +## Backend files I expect to change + +Shared types / contracts: + +- `packages/shared/src/cliAgentTypes.ts` +- `packages/shared/src/conversationTypes.ts` +- `packages/shared/src/index.ts` + +Main process: + +- `packages/main/src/chat/cliAgentManager.ts` +- `packages/main/src/chat/conversationStore.ts` +- `packages/main/src/index.ts` +- `packages/main/src/preload.ts` +- `packages/main/src/workspace/settingsResolver.ts` + +Likely new backend adapter files: + +- `packages/main/src/chat/cliAdapters/claudeCodeAdapter.ts` +- `packages/main/src/chat/cliAdapters/openCodeAdapter.ts` +- `packages/main/src/chat/cliAdapters/codexAdapter.ts` +- `packages/main/src/chat/cliAdapters/types.ts` + +Tests: + +- `tests/unit/cliAgentManager.test.ts` +- new adapter-focused unit tests + +## Frontend changes required + +These changes are required for the backend work to be fully usable in the UI. + +### 1. Add `opencode` everywhere backend choices are rendered + +Required files: + +- `packages/renderer/src/lib/settingsSchema.ts` +- `packages/renderer/src/components/panes/CliAgentPane.tsx` +- `packages/renderer/src/components/panes/ChatHistoryPane.tsx` +- `packages/renderer/src/components/layout/AppShell.tsx` +- `packages/renderer/src/lib/workspace/workspaceSwitcher.ts` +- `packages/renderer/src/commands/domains/agent.ts` + +The current renderer hard-codes only Claude and Codex labels/branches. + +### 2. Add a backend switcher UI in the CLI agent pane + +Required UI behavior: + +- show current backend in the pane header +- allow switching between `claude-code`, `opencode`, and `codex` +- disable the switcher while a turn is actively running, or require stop-first behavior +- call the new backend switch IPC instead of reopening a new pane + +This is the key frontend requirement for hot swapping. + +### 3. Render mixed-backend transcripts clearly + +Once one conversation can contain responses from multiple backends, the pane should show that clearly. + +Minimum required UI change: + +- display a backend badge on assistant/tool/result/error messages when the conversation contains more than one backend + +Without this, hot-swapped conversations will be confusing. + +### 4. Update history and new-chat affordances + +Current limitations: + +- `ChatHistoryPane` only exposes `+` for built-in conversations +- renderer routing logic assumes exact backend values instead of generic external-agent semantics + +Required frontend changes: + +- optionally allow creating a new CLI conversation from history UI with backend selection +- treat `opencode` as a CLI backend +- route any external CLI conversation to `cliAgentPane` + +### 5. Update settings UI for the new backend path + +Required UI/settings additions: + +- add `agent.opencodePath` +- adjust descriptions so backend settings no longer imply only Claude/Codex exist + +## Frontend contract changes to expect + +The frontend agent should expect these shared contract changes. + +### Shared type changes + +- `AgentBackend` will include `opencode` +- `CliAgentMessage` will likely gain `backend?: AgentBackend` +- `CliAgentSession` will likely gain `activeBackend` and generic backend-state data +- `ConversationMeta.backend` should be treated as the last-used or primary backend, not as the only backend that has ever touched the conversation + +### New or changed preload/window API calls + +Expected additions: + +```ts +cliAgentSwitchBackend: (sessionId: string, backend: AgentBackend) => + Promise<{ success: true } | { error: string }> +``` + +Possible session shape update: + +```ts +interface CliAgentSession { + ... + activeBackend: 'claude-code' | 'opencode' | 'codex' +} +``` + +## Risks and open questions + +### Codex transport risk + +Codex appears to be primarily exposed as a CLI package. If it does not expose a stable non-interactive structured stream, it will require a different adapter strategy than Claude/OpenCode. + +This does not block the manager refactor, but it may affect how deep Codex parity can go in the first backend pass. + +### Conversation semantics after a switch + +The backend implementation will preserve one transcript across all external backends. That means: + +- native resume is backend-specific +- cross-backend continuity is transcript-based, not native-session-based + +This is the correct model for hot swapping, but the frontend should present it as "switch backend for future turns" rather than "move the same vendor session between providers". + +## Recommended work split + +### Backend work owned here + +- adapter abstraction +- Claude adapter extraction +- OpenCode adapter wiring +- Codex adapter wiring +- generic persistence/session-state migration +- switch-backend IPC and session behavior +- tests and migrations + +### Frontend work for the separate agent + +- backend selector UI in `CliAgentPane` +- `opencode` labels and routing everywhere a backend is rendered +- message badges for mixed-backend transcripts +- settings UI for `agent.opencodePath` +- any create-new-chat UX that should expose CLI backend choice + +## Suggested frontend handoff summary + +Frontend should prepare for: + +1. `opencode` becoming a valid `AgentBackend` +2. a new `window.api.cliAgentSwitchBackend()` call +3. `CliAgentMessage.backend` being present on external-agent messages +4. `CliAgentSession.activeBackend` becoming the source of truth for the pane header +5. a conversation transcript containing output from more than one external backend diff --git a/package.json b/package.json index 0e57ca6..e51d4a1 100644 --- a/package.json +++ b/package.json @@ -29,6 +29,7 @@ "dependencies": { "@anthropic-ai/claude-agent-sdk": "^0.2.91", "@anthropic-ai/claude-code": "*", + "@opencode-ai/sdk": "^1.4.0", "@vscode/ripgrep": "^1.17.1", "node-pty": "^1.1.0" }, diff --git a/packages/main/package.json b/packages/main/package.json index a32dc2a..b71e286 100644 --- a/packages/main/package.json +++ b/packages/main/package.json @@ -6,6 +6,7 @@ "dependencies": { "@aide/shared": "workspace:*", "@anthropic-ai/claude-agent-sdk": "^0.2.91", + "@opencode-ai/sdk": "^1.4.0", "electron-store": "^6.0.1", "node-pty": "^1.1.0", "simple-git": "^3.33.0" diff --git a/packages/main/src/chat/cliAdapters/claudeCodeAdapter.ts b/packages/main/src/chat/cliAdapters/claudeCodeAdapter.ts new file mode 100644 index 0000000..11e1897 --- /dev/null +++ b/packages/main/src/chat/cliAdapters/claudeCodeAdapter.ts @@ -0,0 +1,282 @@ +import { randomUUID } from 'crypto' +import { query } from '@anthropic-ai/claude-agent-sdk' +import type { Query } from '@anthropic-ai/claude-agent-sdk' +import type { + CliBackendAdapter, + CliBackendEvent, + CliBackendRun, + CliBackendTurnContext, +} from './types' + +interface ClaudeCodeAdapterOptions { + executablePath: string +} + +export function createClaudeCodeAdapter(options: ClaudeCodeAdapterOptions): CliBackendAdapter { + return { + backend: 'claude-code', + startTurn(context, emit) { + const abortController = new AbortController() + const stderrChunks: string[] = [] + let queryInstance: Query | null = null + + const completed = (async () => { + const startedAt = Date.now() + const queryOptions: Record = { + cwd: context.cwd, + abortController, + includePartialMessages: true, + pathToClaudeCodeExecutable: options.executablePath, + permissionMode: 'default' as const, + settingSources: ['user', 'project', 'local'], + systemPrompt: { type: 'preset', preset: 'claude_code' }, + stderr: (data: string) => { + stderrChunks.push(data) + }, + } + + if (context.backendState.sessionId) { + queryOptions.resume = context.backendState.sessionId + } + + try { + queryInstance = query({ + prompt: context.prompt, + options: queryOptions as Parameters[0]['options'], + }) + } catch (error) { + throw new Error(renderClaudeError(error, stderrChunks)) + } + + let totalCostUsd = 0 + let sawResult = false + for await (const message of queryInstance) { + const events = normalizeClaudeMessage(message) + for (const event of events) { + if (event.type === 'result') { + totalCostUsd += event.totalCostUsd + sawResult = true + } + emit(event) + } + } + + if (!sawResult) { + emit({ + type: 'result', + durationMs: Date.now() - startedAt, + totalCostUsd, + isSuccess: true, + }) + } + })() + + return { + close() { + abortController.abort() + queryInstance?.close() + }, + completed, + } satisfies CliBackendRun + }, + } +} + +function renderClaudeError(error: unknown, stderrChunks: string[]): string { + const message = error instanceof Error ? error.message : String(error) + const stderrText = stderrChunks.join('').trim() + if (!stderrText) return message + return `${message}\n\nstderr output:\n${stderrText.slice(-2000)}` +} + +function normalizeClaudeMessage(message: any): CliBackendEvent[] { + const type = message.type as string + const subtype = message.subtype as string | undefined + + if (type === 'system') { + if (subtype === 'init') { + const sessionId = message.session_id as string | undefined + const model = (message.model as string) ?? undefined + const tools = Array.isArray(message.tools) ? (message.tools as string[]) : undefined + if (sessionId) { + return [ + { + type: 'backend-state', + patch: { sessionId, model }, + }, + { + type: 'session-meta', + model, + tools, + }, + ] + } + return [ + { + type: 'session-meta', + model, + tools, + }, + ] + } + + if (subtype === 'status') { + return [ + { + type: 'message', + message: { + id: (message.uuid as string) ?? randomUUID(), + type: 'status', + content: String(message.status ?? message.message ?? 'status update'), + timestamp: Date.now(), + }, + }, + ] + } + + return [] + } + + if (type === 'assistant') { + const betaMessage = message.message + if (!betaMessage || !Array.isArray(betaMessage.content)) return [] + + let text = '' + const events: CliBackendEvent[] = [] + for (const block of betaMessage.content) { + if (block.type === 'text') { + text += block.text ?? '' + } + if (block.type === 'tool_use') { + events.push({ + type: 'message', + message: { + id: (block.id as string) ?? randomUUID(), + type: 'tool_use', + content: `Running ${block.name ?? 'tool'}...`, + timestamp: Date.now(), + toolName: block.name as string, + toolUseId: block.id as string, + }, + }) + } + } + + if (text) { + events.push({ + type: 'message', + message: { + id: (message.uuid as string) ?? randomUUID(), + type: 'assistant', + content: text, + timestamp: Date.now(), + raw: message, + }, + }) + } + + return events + } + + if (type === 'stream_event') { + const event = message.event + if (event?.type !== 'content_block_delta' || event.delta?.type !== 'text_delta') return [] + const text = (event.delta.text as string) ?? '' + if (!text) return [] + return [ + { + type: 'stream-delta', + messageId: (message.uuid as string) ?? randomUUID(), + delta: text, + }, + ] + } + + if (type === 'tool_progress') { + return [ + { + type: 'message', + message: { + id: (message.uuid as string) ?? randomUUID(), + type: 'tool_use', + content: `Running ${message.tool_name ?? 'tool'}...`, + timestamp: Date.now(), + toolName: (message.tool_name as string) ?? undefined, + toolUseId: (message.tool_use_id as string) ?? undefined, + }, + }, + ] + } + + if (type === 'tool_use_summary') { + return [ + { + type: 'message', + message: { + id: (message.uuid as string) ?? randomUUID(), + type: 'tool_result', + content: (message.summary as string) ?? 'Tool completed', + timestamp: Date.now(), + }, + }, + ] + } + + if (type === 'rate_limit_event') { + return [ + { + type: 'message', + message: { + id: randomUUID(), + type: 'status', + content: 'Rate limited', + timestamp: Date.now(), + }, + }, + ] + } + + if (type === 'result') { + const isSuccess = subtype === 'success' + const durationMs = (message.duration_ms as number) ?? 0 + const totalCostUsd = (message.total_cost_usd as number) ?? 0 + const sessionId = message.session_id as string | undefined + const errors = Array.isArray(message.errors) ? (message.errors as string[]) : [] + const errorDetail = errors.length > 0 ? errors.join('\n') : '' + const events: CliBackendEvent[] = [] + + if (sessionId) { + events.push({ + type: 'backend-state', + patch: { sessionId }, + }) + } + + events.push({ + type: 'message', + message: { + id: (message.uuid as string) ?? randomUUID(), + type: isSuccess ? 'result' : 'error', + content: isSuccess + ? `Completed in ${(durationMs / 1000).toFixed(1)}s` + : `Failed: ${subtype ?? 'unknown error'}${errorDetail ? `\n\n${errorDetail}` : ''}`, + timestamp: Date.now(), + durationMs, + totalCostUsd, + isSuccess, + raw: message, + }, + }) + + events.push({ + type: 'result', + durationMs, + totalCostUsd, + isSuccess, + }) + + return events + } + + return [] +} diff --git a/packages/main/src/chat/cliAdapters/codexAdapter.ts b/packages/main/src/chat/cliAdapters/codexAdapter.ts new file mode 100644 index 0000000..9932b4f --- /dev/null +++ b/packages/main/src/chat/cliAdapters/codexAdapter.ts @@ -0,0 +1,162 @@ +import { randomUUID } from 'crypto' +import { spawn } from 'child_process' +import { createInterface } from 'readline' +import type { CliBackendAdapter, CliBackendRun, CliBackendTurnContext } from './types' + +interface CodexAdapterOptions { + executablePath: string +} + +export function createCodexAdapter(options: CodexAdapterOptions): CliBackendAdapter { + return { + backend: 'codex', + startTurn(context, emit) { + const args = context.backendState.sessionId + ? [ + 'exec', + 'resume', + '--json', + '--skip-git-repo-check', + context.backendState.sessionId, + context.prompt, + ] + : ['exec', '--json', '--skip-git-repo-check', context.prompt] + + const proc = spawn(options.executablePath, args, { + cwd: context.cwd, + env: process.env, + stdio: ['ignore', 'pipe', 'pipe'], + }) + + const completed = (async () => { + const startedAt = Date.now() + let stderr = '' + let sawResult = false + + proc.stderr?.on('data', (chunk) => { + stderr += chunk.toString() + }) + + if (proc.stdout) { + const rl = createInterface({ input: proc.stdout, crlfDelay: Infinity }) + for await (const line of rl) { + const trimmed = line.trim() + if (!trimmed) continue + let event: Record + try { + event = JSON.parse(trimmed) as Record + } catch { + continue + } + + const type = typeof event.type === 'string' ? event.type : '' + if (type === 'thread.started' && typeof event.thread_id === 'string') { + emit({ type: 'backend-state', patch: { sessionId: event.thread_id } }) + continue + } + + if (type === 'item.started') { + const item = asRecord(event.item) + if (item?.type === 'command_execution') { + emit({ + type: 'message', + message: { + id: asString(item.id) ?? randomUUID(), + type: 'tool_use', + content: `Running command: ${asString(item.command) ?? 'shell command'}`, + timestamp: Date.now(), + toolName: 'shell', + }, + }) + } + continue + } + + if (type === 'item.completed') { + const item = asRecord(event.item) + if (!item) continue + if (item.type === 'agent_message') { + emit({ + type: 'message', + message: { + id: asString(item.id) ?? randomUUID(), + type: 'assistant', + content: asString(item.text) ?? '', + timestamp: Date.now(), + raw: event, + }, + }) + continue + } + + if (item.type === 'command_execution') { + const output = asString(item.aggregated_output)?.trim() + const command = asString(item.command) ?? 'shell command' + const exitCode = typeof item.exit_code === 'number' ? item.exit_code : null + emit({ + type: 'message', + message: { + id: asString(item.id) ?? randomUUID(), + type: 'tool_result', + content: + output || `${command}${exitCode === null ? '' : `\n(exit ${exitCode})`}`, + timestamp: Date.now(), + toolName: 'shell', + }, + }) + } + continue + } + + if (type === 'turn.completed') { + sawResult = true + const usage = asRecord(event.usage) + const outputTokens = + typeof usage?.output_tokens === 'number' ? usage.output_tokens : 0 + emit({ + type: 'message', + message: { + id: randomUUID(), + type: 'result', + content: `Completed in ${((Date.now() - startedAt) / 1000).toFixed(1)}s${outputTokens > 0 ? ` (${outputTokens} output tokens)` : ''}`, + timestamp: Date.now(), + isSuccess: true, + }, + }) + emit({ + type: 'result', + durationMs: Date.now() - startedAt, + totalCostUsd: 0, + isSuccess: true, + }) + } + } + } + + const exitCode = await new Promise((resolve, reject) => { + proc.once('error', reject) + proc.once('close', resolve) + }) + + if ((exitCode ?? 0) !== 0 && !sawResult) { + throw new Error(stderr.trim() || `Codex exited with code ${exitCode}`) + } + })() + + return { + close() { + proc.kill('SIGTERM') + }, + completed, + } satisfies CliBackendRun + }, + } +} + +function asRecord(value: unknown): Record | null { + return value && typeof value === 'object' ? (value as Record) : null +} + +function asString(value: unknown): string | undefined { + return typeof value === 'string' ? value : undefined +} diff --git a/packages/main/src/chat/cliAdapters/openCodeAdapter.ts b/packages/main/src/chat/cliAdapters/openCodeAdapter.ts new file mode 100644 index 0000000..baa3e3b --- /dev/null +++ b/packages/main/src/chat/cliAdapters/openCodeAdapter.ts @@ -0,0 +1,338 @@ +import { randomUUID } from 'crypto' +import { spawn, type ChildProcess } from 'child_process' +import { createServer } from 'net' +import { createOpencodeClient } from '@opencode-ai/sdk/client' +import type { CliBackendAdapter, CliBackendRun, CliBackendTurnContext } from './types' + +interface OpenCodeAdapterOptions { + executablePath: string +} + +export function createOpenCodeAdapter(options: OpenCodeAdapterOptions): CliBackendAdapter { + return { + backend: 'opencode', + startTurn(context, emit) { + let serverProc: ChildProcess | null = null + let currentSessionId = context.backendState.sessionId + let closed = false + + const completed = (async () => { + const startedAt = Date.now() + const port = await reservePort() + const url = `http://127.0.0.1:${port}` + serverProc = spawn( + options.executablePath, + ['serve', '--hostname=127.0.0.1', `--port=${port}`], + { + env: { + ...process.env, + OPENCODE_CONFIG_CONTENT: JSON.stringify({}), + }, + stdio: ['ignore', 'pipe', 'pipe'], + }, + ) + + if (!serverProc) throw new Error('Failed to start OpenCode server process') + await waitForOpenCodeServer(serverProc, url) + + const client = createOpencodeClient({ + baseUrl: url, + directory: context.cwd, + responseStyle: 'data', + throwOnError: true, + }) + + if (!currentSessionId) { + const created = await client.session.create({ responseStyle: 'data', throwOnError: true }) + currentSessionId = created.data.id + emit({ type: 'backend-state', patch: { sessionId: created.data.id } }) + } + + if (!currentSessionId) { + throw new Error('Failed to initialize OpenCode session') + } + + const sse = await client.global.event({ signal: undefined }) + const textByMessageId = new Map() + const timestampByMessageId = new Map() + const emittedAssistantIds = new Set() + const seenToolStates = new Map() + const costByMessageId = new Map() + let totalCostUsd = 0 + let failedError: string | null = null + let promptSubmitted = false + + const streamTask = (async () => { + for await (const rawEvent of sse.stream) { + const event = rawEvent as Record + const type = typeof event.type === 'string' ? event.type : '' + const props = asRecord(event.properties) + + if (type === 'message.updated' && props?.info) { + const info = props.info as Record + const sessionId = asString(info.sessionID) + if (!sessionId || sessionId !== currentSessionId) continue + + const messageId = asString(info.id) ?? randomUUID() + if (info.role === 'assistant') { + const model = + [asString(info.providerID), asString(info.modelID)].filter(Boolean).join('/') || + undefined + const createdAt = + typeof info.time?.created === 'number' ? info.time.created : Date.now() + timestampByMessageId.set(messageId, createdAt) + emit({ type: 'session-meta', model }) + emit({ type: 'backend-state', patch: { sessionId, model } }) + + const nextCost = typeof info.cost === 'number' ? info.cost : 0 + const prevCost = costByMessageId.get(messageId) ?? 0 + totalCostUsd += Math.max(0, nextCost - prevCost) + costByMessageId.set(messageId, nextCost) + + const errorText = renderOpenCodeError(info.error) + if (errorText) { + failedError = errorText + } + } + continue + } + + if (type === 'message.part.updated' && props?.part) { + const part = props.part as Record + if (asString(part.sessionID) !== currentSessionId) continue + const partType = asString(part.type) + + if (partType === 'text') { + const messageId = asString(part.messageID) ?? randomUUID() + const delta = asString(props.delta) + if (delta) { + const prior = textByMessageId.get(messageId) ?? '' + textByMessageId.set(messageId, prior + delta) + emit({ type: 'stream-delta', messageId, delta }) + } else { + textByMessageId.set(messageId, asString(part.text) ?? '') + } + continue + } + + if (partType === 'tool') { + const partId = asString(part.id) ?? randomUUID() + const state = asRecord(part.state) + const status = asString(state?.status) ?? 'pending' + const priorStatus = seenToolStates.get(partId) + if (priorStatus === status) continue + seenToolStates.set(partId, status) + + const toolName = asString(part.tool) ?? 'tool' + if (status === 'pending' || status === 'running') { + emit({ + type: 'message', + message: { + id: partId, + type: 'tool_use', + content: `Running ${toolName}...`, + timestamp: Date.now(), + toolName, + toolUseId: asString(part.callID), + }, + }) + } else if (status === 'completed') { + emit({ + type: 'message', + message: { + id: partId, + type: 'tool_result', + content: + asString(state?.output) ?? + asString(state?.title) ?? + `${toolName} completed`, + timestamp: Date.now(), + toolName, + toolUseId: asString(part.callID), + }, + }) + } else if (status === 'error') { + emit({ + type: 'message', + message: { + id: partId, + type: 'error', + content: asString(state?.error) ?? `${toolName} failed`, + timestamp: Date.now(), + toolName, + toolUseId: asString(part.callID), + }, + }) + } + } + continue + } + + if (type === 'session.error' && props) { + if ( + currentSessionId && + asString(props.sessionID) && + asString(props.sessionID) !== currentSessionId + ) { + continue + } + failedError = renderOpenCodeError(props.error) ?? 'OpenCode session failed' + continue + } + + if ( + type === 'session.idle' && + asString(props?.sessionID) === currentSessionId && + promptSubmitted + ) { + break + } + } + })() + + await client.session.promptAsync({ + responseStyle: 'data', + throwOnError: true, + path: { id: currentSessionId }, + body: { + parts: [{ type: 'text', text: context.prompt }], + }, + }) + promptSubmitted = true + + await streamTask + + for (const [messageId, text] of textByMessageId) { + if (!text || emittedAssistantIds.has(messageId)) continue + emittedAssistantIds.add(messageId) + emit({ + type: 'message', + message: { + id: messageId, + type: 'assistant', + content: text, + timestamp: timestampByMessageId.get(messageId) ?? Date.now(), + }, + }) + } + + if (failedError) { + emit({ + type: 'message', + message: { + id: randomUUID(), + type: 'error', + content: failedError, + timestamp: Date.now(), + }, + }) + emit({ + type: 'result', + durationMs: Date.now() - startedAt, + totalCostUsd, + isSuccess: false, + }) + return + } + + emit({ + type: 'message', + message: { + id: randomUUID(), + type: 'result', + content: `Completed in ${((Date.now() - startedAt) / 1000).toFixed(1)}s`, + timestamp: Date.now(), + totalCostUsd, + isSuccess: true, + }, + }) + emit({ + type: 'result', + durationMs: Date.now() - startedAt, + totalCostUsd, + isSuccess: true, + }) + })().finally(() => { + if (!closed) { + serverProc?.kill('SIGTERM') + } + }) + + return { + close() { + closed = true + serverProc?.kill('SIGTERM') + }, + completed, + } satisfies CliBackendRun + }, + } +} + +async function reservePort(): Promise { + return await new Promise((resolve, reject) => { + const server = createServer() + server.once('error', reject) + server.listen(0, '127.0.0.1', () => { + const address = server.address() + const port = typeof address === 'object' && address ? address.port : null + server.close((error) => { + if (error) reject(error) + else if (typeof port === 'number') resolve(port) + else reject(new Error('Failed to reserve OpenCode port')) + }) + }) + }) +} + +async function waitForOpenCodeServer(proc: ChildProcess, url: string): Promise { + await new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + reject(new Error('Timeout waiting for OpenCode server to start')) + }, 5000) + + let output = '' + const onData = (chunk: Buffer) => { + output += chunk.toString() + if (output.includes(url) || output.includes('opencode server listening')) { + cleanup() + resolve() + } + } + const onExit = (code: number | null) => { + cleanup() + reject(new Error(`OpenCode server exited with code ${code}${output ? `\n${output}` : ''}`)) + } + const onError = (error: Error) => { + cleanup() + reject(error) + } + const cleanup = () => { + clearTimeout(timeout) + proc.stdout?.off('data', onData) + proc.stderr?.off('data', onData) + proc.off('exit', onExit) + proc.off('error', onError) + } + + proc.stdout?.on('data', onData) + proc.stderr?.on('data', onData) + proc.once('exit', onExit) + proc.once('error', onError) + }) +} + +function asRecord(value: unknown): Record | null { + return value && typeof value === 'object' ? (value as Record) : null +} + +function asString(value: unknown): string | undefined { + return typeof value === 'string' ? value : undefined +} + +function renderOpenCodeError(value: unknown): string | null { + const error = asRecord(value) + const data = asRecord(error?.data) + const message = asString(data?.message) + return message ?? null +} diff --git a/packages/main/src/chat/cliAdapters/types.ts b/packages/main/src/chat/cliAdapters/types.ts new file mode 100644 index 0000000..04bb59d --- /dev/null +++ b/packages/main/src/chat/cliAdapters/types.ts @@ -0,0 +1,25 @@ +import type { CliAgentBackendState, CliAgentMessage, ExternalCliBackend } from '@aide/shared' + +export interface CliBackendTurnContext { + conversationId: string + cwd: string + prompt: string + backendState: CliAgentBackendState +} + +export type CliBackendEvent = + | { type: 'stream-delta'; messageId: string; delta: string } + | { type: 'message'; message: Omit } + | { type: 'backend-state'; patch: Partial } + | { type: 'session-meta'; model?: string; tools?: string[] } + | { type: 'result'; durationMs: number; totalCostUsd: number; isSuccess: boolean } + +export interface CliBackendRun { + close(): void + completed: Promise +} + +export interface CliBackendAdapter { + backend: ExternalCliBackend + startTurn(context: CliBackendTurnContext, emit: (event: CliBackendEvent) => void): CliBackendRun +} diff --git a/packages/main/src/chat/cliAgentManager.ts b/packages/main/src/chat/cliAgentManager.ts index 21d8b63..df53558 100644 --- a/packages/main/src/chat/cliAgentManager.ts +++ b/packages/main/src/chat/cliAgentManager.ts @@ -1,19 +1,9 @@ /** * CLI Agent Manager — manages external CLI agent sessions. * - * Uses the Claude Agent SDK (`@anthropic-ai/claude-agent-sdk`) for the - * `claude-code` backend. The SDK spawns a Claude Code subprocess and - * provides a typed async generator of messages. Codex remains a stub. - * - * Architecture: Each `send()` calls `query()` which returns an - * `AsyncGenerator`. The generator is consumed in a background - * loop that normalizes SDK messages into CliAgentMessage and emits them - * via IPC. Session continuity uses the SDK's `resume` option. - * - * The SDK ships its own `cli.js`, but packaged Electron apps need that file - * unpacked onto the real filesystem so a child Node process can execute it. - * Resolution order: explicit setting -> bundled Agent SDK in app -> legacy - * bundled Claude Code package -> workspace node_modules -> global `claude`. + * Unlike the original Claude-only implementation, this manager now owns the + * generic session lifecycle for all external backends and delegates transport + * details to backend adapters. */ import { randomUUID } from 'crypto' @@ -21,52 +11,53 @@ import { execFileSync } from 'child_process' import { existsSync } from 'fs' import { join } from 'path' import { app, type WebContents } from 'electron' -import { query } from '@anthropic-ai/claude-agent-sdk' -import type { Query } from '@anthropic-ai/claude-agent-sdk' import { IpcChannels, deriveTitle } from '@aide/shared' import type { AgentBackend, - CliAgentProcessStatus, + CliAgentBackendStateMap, CliAgentMessage, + CliAgentMessagePayload, + CliAgentProcessStatus, + CliAgentResultPayload, CliAgentSession, - CliAgentStreamDelta, CliAgentStatusPayload, - CliAgentResultPayload, - CliAgentMessagePayload, + CliAgentStreamDelta, ConversationListChangedPayload, + ExternalCliBackend, } from '@aide/shared' import type { ConversationStore } from './conversationStore' - -// --------------------------------------------------------------------------- -// Internal session state -// --------------------------------------------------------------------------- +import { createClaudeCodeAdapter } from './cliAdapters/claudeCodeAdapter' +import { createCodexAdapter } from './cliAdapters/codexAdapter' +import { createOpenCodeAdapter } from './cliAdapters/openCodeAdapter' +import type { CliBackendAdapter, CliBackendEvent, CliBackendRun } from './cliAdapters/types' + +interface PersistedCliConversation { + messages?: CliAgentMessage[] + activeBackend?: ExternalCliBackend + backendStates?: CliAgentBackendStateMap + claudeSessionId?: string +} interface CliAgentSessionInternal { id: string workspaceId: string - backend: AgentBackend - queryInstance: Query | null - abortController: AbortController | null + backend: ExternalCliBackend + activeRun: CliBackendRun | null processStatus: CliAgentProcessStatus messages: CliAgentMessage[] model?: string sessionToolNames?: string[] lastError?: string totalCostUsd: number - /** Claude Code session ID for resume across sends */ - claudeSessionId?: string - /** Worktree path this session operates in (if any). */ worktreePath?: string + backendStates: CliAgentBackendStateMap } -// --------------------------------------------------------------------------- -// Options -// --------------------------------------------------------------------------- - export interface CliAgentManagerOpts { workspaceRoot: string getWebContents: () => WebContents | null claudeCodePath?: string + opencodePath?: string codexPath?: string conversationStore?: ConversationStore loadClaudeHistory?: (claudeSessionId: string) => Promise @@ -82,81 +73,87 @@ function comparableHistoryCount(messages: CliAgentMessage[]): number { ).length } -// --------------------------------------------------------------------------- -// Manager -// --------------------------------------------------------------------------- +function isExternalBackend(backend: AgentBackend): backend is ExternalCliBackend { + return backend === 'claude-code' || backend === 'opencode' || backend === 'codex' +} + +function parsePersistedConversation(raw: unknown): PersistedCliConversation { + if (!raw || typeof raw !== 'object') return {} + const persisted = raw as PersistedCliConversation + const backendStates: CliAgentBackendStateMap = { ...(persisted.backendStates ?? {}) } + if (!backendStates['claude-code']?.sessionId && typeof persisted.claudeSessionId === 'string') { + backendStates['claude-code'] = { + ...(backendStates['claude-code'] ?? {}), + sessionId: persisted.claudeSessionId, + } + } + return { + messages: Array.isArray(persisted.messages) ? persisted.messages : [], + activeBackend: persisted.activeBackend, + backendStates, + claudeSessionId: persisted.claudeSessionId, + } +} export class CliAgentManager { private sessions = new Map() - private workspaceRoot: string - private getWebContents: () => WebContents | null + private readonly workspaceRoot: string + private readonly getWebContents: () => WebContents | null private claudeCodePath: string + private opencodePath: string private codexPath: string - private conversationStore: ConversationStore | null - private loadClaudeHistory: ((claudeSessionId: string) => Promise) | null - /** Resolved path to the Claude Code CLI, cached after first lookup */ + private readonly conversationStore: ConversationStore | null + private readonly loadClaudeHistory: + | ((claudeSessionId: string) => Promise) + | null private resolvedClaudeCodePath: string | null = null + private resolvedOpenCodePath: string | null = null + private resolvedCodexPath: string | null = null constructor(opts: CliAgentManagerOpts) { this.workspaceRoot = opts.workspaceRoot this.getWebContents = opts.getWebContents this.claudeCodePath = opts.claudeCodePath ?? '' + this.opencodePath = opts.opencodePath ?? '' this.codexPath = opts.codexPath ?? '' this.conversationStore = opts.conversationStore ?? null this.loadClaudeHistory = opts.loadClaudeHistory ?? null } - // ─── Public API ────────────────────────────── - - /** - * Initialize a CLI agent session. Does not start a query yet — that - * happens on the first `send()`. - * - * If `conversationId` is provided, resumes an existing conversation - * (loads claudeSessionId from the store for SDK resume). - */ async start( workspaceId: string, backend: AgentBackend, conversationId?: string, worktreePath?: string, ): Promise<{ sessionId: string } | { error: string }> { - if (backend === 'codex') { - return { error: 'Codex integration coming soon.' } - } - if (backend === 'built-in') { return { error: 'Use the built-in agent chat panel instead.' } } - // If resuming an existing conversation, load from store - let existingMessages: CliAgentMessage[] = [] - let existingClaudeSessionId: string | undefined - - if (conversationId && this.conversationStore) { - const meta = await this.conversationStore.get(conversationId) - if (meta?.claudeSessionId) { - existingClaudeSessionId = meta.claudeSessionId - } - const saved = (await this.conversationStore.loadMessages(conversationId)) as { - messages?: CliAgentMessage[] - claudeSessionId?: string - } | null - if (saved?.messages) { - existingMessages = saved.messages - } - if (saved?.claudeSessionId) { - existingClaudeSessionId = saved.claudeSessionId - } + if (conversationId?.startsWith('claude-native:') && backend !== 'claude-code') { + return { error: 'Native Claude conversations cannot switch to a different backend.' } } - if (!existingClaudeSessionId && conversationId?.startsWith('claude-native:')) { + const sessionId = conversationId ?? randomUUID() + const persisted = + conversationId && this.conversationStore + ? parsePersistedConversation(await this.conversationStore.loadMessages(conversationId)) + : parsePersistedConversation(null) + + let existingMessages = persisted.messages ?? [] + const backendStates = persisted.backendStates ?? {} + + if (!backendStates['claude-code']?.sessionId && conversationId?.startsWith('claude-native:')) { const raw = conversationId.slice('claude-native:'.length) if (/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(raw)) { - existingClaudeSessionId = raw + backendStates['claude-code'] = { + ...(backendStates['claude-code'] ?? {}), + sessionId: raw, + } } } + const existingClaudeSessionId = backendStates['claude-code']?.sessionId if (existingClaudeSessionId && this.loadClaudeHistory) { try { const nativeMessages = await this.loadClaudeHistory(existingClaudeSessionId) @@ -167,12 +164,10 @@ export class CliAgentManager { existingMessages = nativeMessages } } catch { - // Fall back to persisted shadow copy when native Claude history is unavailable. + // Fall back to the shadow copy when native Claude history is unavailable. } } - const sessionId = conversationId ?? randomUUID() - if (this.conversationStore && !sessionId.startsWith('claude-native:')) { await this.conversationStore.ensure(sessionId, { workspaceId, @@ -181,8 +176,18 @@ export class CliAgentManager { }) } - // If session already in memory, return it - if (this.sessions.has(sessionId)) { + const existing = this.sessions.get(sessionId) + if (existing) { + if (existing.activeRun) { + return { error: 'Agent is already processing a request. Stop it first or wait.' } + } + existing.backend = backend + existing.model = existing.backendStates[backend]?.model + existing.sessionToolNames = undefined + existing.worktreePath = worktreePath ?? existing.worktreePath + await this.persistSession(existing) + await this.broadcastConversationList(existing.workspaceId) + this.emitStatus(existing) return { sessionId } } @@ -190,35 +195,54 @@ export class CliAgentManager { id: sessionId, workspaceId, backend, - queryInstance: null, - abortController: null, + activeRun: null, processStatus: 'stopped', messages: existingMessages, totalCostUsd: 0, - claudeSessionId: existingClaudeSessionId, + model: backendStates[backend]?.model, worktreePath, + backendStates, } this.sessions.set(sessionId, session) this.emitStatus(session) - return { sessionId } } - /** - * Send a prompt to the CLI agent. Starts an SDK query that streams - * messages back via IPC. Uses `resume` for conversation continuity. - */ - async send(sessionId: string, content: string): Promise<{ success: true } | { error: string }> { + async switchBackend( + sessionId: string, + backend: AgentBackend, + ): Promise<{ success: true } | { error: string }> { + if (!isExternalBackend(backend)) { + return { error: 'Only external CLI backends can be selected here.' } + } + const session = this.sessions.get(sessionId) if (!session) return { error: 'Session not found' } + if (session.activeRun) { + return { error: 'Stop the active run before switching backends.' } + } + if (session.id.startsWith('claude-native:') && backend !== 'claude-code') { + return { error: 'Native Claude conversations cannot switch to a different backend.' } + } - // If a query is already running, reject (one at a time) - if (session.queryInstance) { + session.backend = backend + session.model = session.backendStates[backend]?.model + session.sessionToolNames = undefined + session.lastError = undefined + await this.persistSession(session) + await this.broadcastConversationList(session.workspaceId) + this.emitStatus(session) + return { success: true } + } + + async send(sessionId: string, content: string): Promise<{ success: true } | { error: string }> { + const session = this.sessions.get(sessionId) + if (!session) return { error: 'Session not found' } + if (session.activeRun) { return { error: 'Agent is already processing a request. Stop it first or wait.' } } - // Add user message to history const userMsg: CliAgentMessage = { id: randomUUID(), type: 'user', @@ -227,53 +251,69 @@ export class CliAgentManager { } session.messages.push(userMsg) this.emitMessage(session, userMsg) - - // Auto-title on first user message await this.maybeAutoTitle(session, content) - // Reset error state session.lastError = undefined + const prompt = this.buildTurnPrompt(session, content) - // Fire and forget the consumption loop - this.consumeQuery(session, content).catch((err) => { - const errMsg = err instanceof Error ? err.message : String(err) - console.error( - `[CliAgentManager] Unhandled consumeQuery error for session ${session.id}:`, - errMsg, - ) - if (err instanceof Error && err.stack) console.error(`[CliAgentManager] Stack:`, err.stack) - session.lastError = errMsg - this.setStatus(session, 'error') - }) + let adapter: CliBackendAdapter + try { + adapter = this.createAdapter(session.backend) + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + this.handleRunError(session, message) + return { error: message } + } + + const run = adapter.startTurn( + { + conversationId: session.id, + cwd: session.worktreePath ?? this.workspaceRoot, + prompt, + backendState: { ...(session.backendStates[session.backend] ?? {}) }, + }, + (event) => this.applyBackendEvent(session, event), + ) + + session.activeRun = run + this.setStatus(session, 'running') + + run.completed + .catch((error) => { + if (session.processStatus === 'stopping') return + const message = error instanceof Error ? error.message : String(error) + this.handleRunError(session, message) + }) + .finally(async () => { + if (session.activeRun === run) { + session.activeRun = null + } + if (session.processStatus === 'stopping' || session.processStatus === 'running') { + this.setStatus(session, 'stopped') + } + await this.persistSession(session) + }) return { success: true } } stop(sessionId: string): void { const session = this.sessions.get(sessionId) - if (!session) return - - if (session.queryInstance || session.abortController) { - this.setStatus(session, 'stopping') - session.abortController?.abort() - session.queryInstance?.close() - } + if (!session || !session.activeRun) return + this.setStatus(session, 'stopping') + session.activeRun.close() } getSession(workspaceId: string): CliAgentSession | null { - // Find the most recent session for this workspace for (const session of this.sessions.values()) { - if (session.workspaceId === workspaceId) { - return this.toPublicSession(session) - } + if (session.workspaceId === workspaceId) return this.toPublicSession(session) } return null } - /** Check if any active (running/starting) session uses the given worktree path. */ hasActiveSessionsForWorktree(worktreePath: string): boolean { for (const session of this.sessions.values()) { - if (session.worktreePath === worktreePath && session.queryInstance) { + if (session.worktreePath === worktreePath && session.activeRun) { return true } } @@ -282,14 +322,38 @@ export class CliAgentManager { getSessionById(sessionId: string): CliAgentSession | null { const session = this.sessions.get(sessionId) - if (!session) return null - return this.toPublicSession(session) + return session ? this.toPublicSession(session) : null } ownsSession(sessionId: string): boolean { return this.sessions.has(sessionId) } + updatePaths(claudeCodePath: string, opencodePath: string, codexPath: string): void { + this.claudeCodePath = claudeCodePath + this.opencodePath = opencodePath + this.codexPath = codexPath + this.resolvedClaudeCodePath = null + this.resolvedOpenCodePath = null + this.resolvedCodexPath = null + } + + getRunningSessionCount(): number { + let count = 0 + for (const session of this.sessions.values()) { + if (session.activeRun) count += 1 + } + return count + } + + async destroy(): Promise { + for (const session of this.sessions.values()) { + session.activeRun?.close() + await this.persistSession(session).catch(() => {}) + } + this.sessions.clear() + } + private toPublicSession(session: CliAgentSessionInternal): CliAgentSession { return { id: session.id, @@ -305,48 +369,54 @@ export class CliAgentManager { } } - updatePaths(claudeCodePath: string, codexPath: string): void { - this.claudeCodePath = claudeCodePath - this.codexPath = codexPath - // Invalidate cache so next query re-resolves - this.resolvedClaudeCodePath = null - } + private createAdapter(backend: ExternalCliBackend): CliBackendAdapter { + if (backend === 'claude-code') { + const executablePath = this.resolveClaudeCodeExecutable() + if (!executablePath) { + throw new Error( + 'Claude Code CLI not found. Install @anthropic-ai/claude-code globally or set agent.claudeCodePath in settings.', + ) + } + return createClaudeCodeAdapter({ executablePath }) + } - getRunningSessionCount(): number { - let count = 0 - for (const session of this.sessions.values()) { - if (session.queryInstance) count += 1 + if (backend === 'opencode') { + const executablePath = this.resolveGenericExecutable( + 'opencode', + this.opencodePath, + this.resolvedOpenCodePath, + ) + if (!executablePath) { + throw new Error( + 'OpenCode CLI not found. Install opencode or set agent.opencodePath in settings.', + ) + } + this.resolvedOpenCodePath = executablePath + return createOpenCodeAdapter({ executablePath }) } - return count - } - async destroy(): Promise { - for (const session of this.sessions.values()) { - session.abortController?.abort() - session.queryInstance?.close() - await this.persistSession(session).catch(() => {}) + const executablePath = this.resolveGenericExecutable( + 'codex', + this.codexPath, + this.resolvedCodexPath, + ) + if (!executablePath) { + throw new Error( + 'Codex CLI not found. Install @openai/codex or set agent.codexPath in settings.', + ) } - this.sessions.clear() + this.resolvedCodexPath = executablePath + return createCodexAdapter({ executablePath }) } - // ─── Claude Code CLI Resolution ───────────── - - /** - * Resolve the path to the Claude Code CLI executable. - * The SDK spawns Claude Code as a subprocess, so we need to tell it - * where the actual binary lives via `pathToClaudeCodeExecutable`. - */ private resolveClaudeCodeExecutable(): string | null { if (this.resolvedClaudeCodePath) return this.resolvedClaudeCodePath - // 1. Explicit path from settings if (this.claudeCodePath && existsSync(this.claudeCodePath)) { - console.log(`[CliAgentManager] Using explicit Claude Code path: ${this.claudeCodePath}`) this.resolvedClaudeCodePath = this.claudeCodePath return this.claudeCodePath } - // 2. Bundled in Electron app const bundledCandidates: string[] = [] if (app.isPackaged) { bundledCandidates.push( @@ -371,402 +441,174 @@ export class CliAgentManager { bundledCandidates.push( join(app.getAppPath(), 'node_modules', '@anthropic-ai', 'claude-agent-sdk', 'cli.js'), join(app.getAppPath(), 'node_modules', '@anthropic-ai', 'claude-code', 'cli.js'), - ) - for (const candidate of bundledCandidates) { - if (existsSync(candidate)) { - console.log(`[CliAgentManager] Using bundled Claude Code: ${candidate}`) - this.resolvedClaudeCodePath = candidate - return candidate - } - } - - // 3. Workspace-local installation - const workspaceCandidates = [ join(this.workspaceRoot, 'node_modules', '@anthropic-ai', 'claude-agent-sdk', 'cli.js'), join(this.workspaceRoot, 'node_modules', '@anthropic-ai', 'claude-code', 'cli.js'), - ] - for (const candidate of workspaceCandidates) { + ) + + for (const candidate of bundledCandidates) { if (existsSync(candidate)) { - console.log(`[CliAgentManager] Using workspace Claude Code: ${candidate}`) this.resolvedClaudeCodePath = candidate return candidate } } - // 4. Global `claude` in PATH try { const result = execFileSync('which', ['claude'], { encoding: 'utf-8' }).trim() if (result) { - console.log(`[CliAgentManager] Using global Claude Code: ${result}`) this.resolvedClaudeCodePath = result return result } } catch { - // Not found in PATH + // Not found in PATH. } - console.warn('[CliAgentManager] Claude Code CLI not found in any location') return null } - // ─── SDK Query Consumption ────────────────── + private resolveGenericExecutable( + command: string, + explicitPath: string, + cachedPath: string | null, + ): string | null { + if (cachedPath) return cachedPath + if (explicitPath && existsSync(explicitPath)) return explicitPath - private async consumeQuery(session: CliAgentSessionInternal, prompt: string): Promise { - const abortController = new AbortController() - session.abortController = abortController - - const cwd = session.worktreePath ?? this.workspaceRoot - console.log(`[CliAgentManager] Starting SDK query for session ${session.id}`) - console.log(`[CliAgentManager] cwd: ${cwd}`) - console.log(`[CliAgentManager] resume: ${session.claudeSessionId ?? '(new session)'}`) - console.log( - `[CliAgentManager] prompt: ${prompt.slice(0, 200)}${prompt.length > 200 ? '...' : ''}`, - ) - - // Collect stderr output for diagnostics - const stderrChunks: string[] = [] - - // Resolve the Claude Code executable path for the SDK - const executablePath = this.resolveClaudeCodeExecutable() - if (!executablePath) { - session.lastError = - 'Claude Code CLI not found. Install @anthropic-ai/claude-code globally or set agent.claudeCodePath in settings.' - const errorMsg: CliAgentMessage = { - id: randomUUID(), - type: 'error', - content: session.lastError, - timestamp: Date.now(), - } - session.messages.push(errorMsg) - this.emitMessage(session, errorMsg) - this.setStatus(session, 'error') - return - } - console.log(`[CliAgentManager] executable: ${executablePath}`) - - const options: Record = { - cwd, - abortController, - pathToClaudeCodeExecutable: executablePath, - includePartialMessages: true, - permissionMode: 'default' as const, - settingSources: ['user', 'project', 'local'], - systemPrompt: { type: 'preset', preset: 'claude_code' }, - stderr: (data: string) => { - stderrChunks.push(data) - console.warn(`[CliAgentManager] stderr: ${data.trimEnd()}`) - }, - } - - if (session.claudeSessionId) { - options.resume = session.claudeSessionId + const candidates: string[] = [] + if (app.isPackaged) { + candidates.push( + join(process.resourcesPath, 'app.asar.unpacked', 'node_modules', '.bin', command), + ) } + candidates.push( + join(app.getAppPath(), 'node_modules', '.bin', command), + join(this.workspaceRoot, 'node_modules', '.bin', command), + ) - let queryInstance: ReturnType - try { - queryInstance = query({ prompt, options: options as Parameters[0]['options'] }) - } catch (err) { - const errMsg = err instanceof Error ? err.message : String(err) - const errStack = err instanceof Error ? err.stack : undefined - console.error(`[CliAgentManager] Failed to create query:`, errMsg) - if (errStack) console.error(`[CliAgentManager] Stack:`, errStack) - session.lastError = `Failed to start agent: ${errMsg}` - this.setStatus(session, 'error') - - // Surface the error in the chat as a message - const errorMsg: CliAgentMessage = { - id: randomUUID(), - type: 'error', - content: `Failed to start agent: ${errMsg}${stderrChunks.length ? '\n\nstderr:\n' + stderrChunks.join('') : ''}`, - timestamp: Date.now(), - } - session.messages.push(errorMsg) - this.emitMessage(session, errorMsg) - return + for (const candidate of candidates) { + if (existsSync(candidate)) return candidate } - session.queryInstance = queryInstance - this.setStatus(session, 'running') - - let messageCount = 0 try { - for await (const message of queryInstance) { - if (abortController.signal.aborted) break - messageCount++ - this.handleSDKMessage(session, message) - } - console.log( - `[CliAgentManager] Query completed for session ${session.id} — ${messageCount} messages received`, - ) - } catch (err) { - if (!abortController.signal.aborted) { - const errMsg = err instanceof Error ? err.message : String(err) - const errStack = err instanceof Error ? err.stack : undefined - console.error(`[CliAgentManager] Query error for session ${session.id}:`, errMsg) - if (errStack) console.error(`[CliAgentManager] Stack:`, errStack) - if (stderrChunks.length) { - console.error(`[CliAgentManager] Captured stderr:\n${stderrChunks.join('')}`) - } - console.error(`[CliAgentManager] Messages received before error: ${messageCount}`) - - // Build a detailed error message for the UI - const stderrText = stderrChunks.join('').trim() - const detailedError = stderrText - ? `${errMsg}\n\nstderr output:\n${stderrText.slice(-2000)}` - : errMsg - session.lastError = detailedError - - // Surface the error in the chat - const errorMsg: CliAgentMessage = { - id: randomUUID(), - type: 'error', - content: detailedError, - timestamp: Date.now(), - } - session.messages.push(errorMsg) - this.emitMessage(session, errorMsg) - - this.setStatus(session, 'error') - } - } finally { - session.queryInstance = null - session.abortController = null - if (session.processStatus === 'stopping') { - this.setStatus(session, 'stopped') - } else if (session.processStatus !== 'error') { - this.setStatus(session, 'stopped') - } - await this.persistSession(session).catch(() => {}) - } - } - - // ─── SDK Message Handling ─────────────────── - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - private handleSDKMessage(session: CliAgentSessionInternal, message: any): void { - const type = message.type as string - const subtype = message.subtype as string | undefined - - // Log all non-streaming messages (stream_event is too noisy) - if (type !== 'stream_event') { - console.log( - `[CliAgentManager] SDK message: type=${type}${subtype ? ` subtype=${subtype}` : ''} session=${session.id.slice(0, 8)}`, - ) + const result = execFileSync('which', [command], { encoding: 'utf-8' }).trim() + if (result) return result + } catch { + // Not found in PATH. } - if (type === 'system') { - this.handleSystemMessage(session, message) - } else if (type === 'assistant') { - this.handleAssistantMessage(session, message) - } else if (type === 'stream_event') { - this.handleStreamEvent(session, message) - } else if (type === 'tool_progress') { - this.handleToolProgress(session, message) - } else if (type === 'tool_use_summary') { - this.handleToolUseSummary(session, message) - } else if (type === 'result') { - this.handleResultMessage(session, message) - } else if (type === 'rate_limit_event') { - console.warn(`[CliAgentManager] Rate limited — session ${session.id.slice(0, 8)}`) - this.setStatus(session, 'rate_limited') - } else { - // Log unknown message types so we can add handling later - console.log( - `[CliAgentManager] Unhandled SDK message type: ${type}`, - JSON.stringify(message).slice(0, 500), - ) - } + return null } - // eslint-disable-next-line @typescript-eslint/no-explicit-any - private handleSystemMessage(session: CliAgentSessionInternal, message: any): void { - const subtype = message.subtype as string | undefined - - if (subtype === 'init') { - // Capture session ID for resume - const sdkSessionId = message.session_id as string | undefined - if (sdkSessionId) { - session.claudeSessionId = sdkSessionId - this.conversationStore - ?.updateMeta(session.id, { claudeSessionId: sdkSessionId }) - .catch(() => {}) + private applyBackendEvent(session: CliAgentSessionInternal, event: CliBackendEvent): void { + if (event.type === 'stream-delta') { + const payload: CliAgentStreamDelta = { + workspaceId: session.workspaceId, + sessionId: session.id, + messageId: event.messageId, + delta: event.delta, } + this.getWebContents()?.send(IpcChannels.CLI_AGENT_STREAM_DELTA, payload) + return + } - session.model = (message.model as string) ?? undefined - const tools = message.tools as string[] | undefined - session.sessionToolNames = tools - - this.setStatus(session, 'running') - - const msg: CliAgentMessage = { - id: (message.uuid as string) ?? randomUUID(), - type: 'system', - content: `Session initialized — model: ${session.model ?? 'unknown'}`, - timestamp: Date.now(), - raw: message, - } - session.messages.push(msg) - this.emitMessage(session, msg) - } else if (subtype === 'status') { - const msg: CliAgentMessage = { - id: (message.uuid as string) ?? randomUUID(), - type: 'status', - content: String(message.status ?? message.message ?? 'status update'), - timestamp: Date.now(), - } - session.messages.push(msg) - this.emitMessage(session, msg) + if (event.type === 'backend-state') { + const current = session.backendStates[session.backend] ?? {} + session.backendStates[session.backend] = { ...current, ...event.patch } + if (event.patch.model) session.model = event.patch.model + return } - } - // eslint-disable-next-line @typescript-eslint/no-explicit-any - private handleAssistantMessage(session: CliAgentSessionInternal, message: any): void { - const betaMessage = message.message - if (!betaMessage || !Array.isArray(betaMessage.content)) return - - let text = '' - for (const block of betaMessage.content) { - if (block.type === 'text') { - text += block.text ?? '' - } else if (block.type === 'tool_use') { - // Emit tool use as separate message - const toolMsg: CliAgentMessage = { - id: (block.id as string) ?? randomUUID(), - type: 'tool_use', - content: `Running ${block.name ?? 'tool'}...`, - timestamp: Date.now(), - toolName: block.name as string, - toolUseId: block.id as string, + if (event.type === 'session-meta') { + if (event.model) { + session.model = event.model + session.backendStates[session.backend] = { + ...(session.backendStates[session.backend] ?? {}), + model: event.model, } - session.messages.push(toolMsg) - this.emitMessage(session, toolMsg) } + if (event.tools) session.sessionToolNames = event.tools + return } - if (text) { - const msg: CliAgentMessage = { - id: (message.uuid as string) ?? randomUUID(), - type: 'assistant', - content: text, - timestamp: Date.now(), - raw: message, + if (event.type === 'result') { + session.totalCostUsd += event.totalCostUsd + const payload: CliAgentResultPayload = { + workspaceId: session.workspaceId, + sessionId: session.id, + durationMs: event.durationMs, + totalCostUsd: session.totalCostUsd, + isSuccess: event.isSuccess, } - session.messages.push(msg) - this.emitMessage(session, msg) + this.getWebContents()?.send(IpcChannels.CLI_AGENT_RESULT, payload) + return } - } - // eslint-disable-next-line @typescript-eslint/no-explicit-any - private handleStreamEvent(session: CliAgentSessionInternal, message: any): void { - const event = message.event - if (!event) return - - const eventType = event.type as string | undefined - - if (eventType === 'content_block_delta') { - const delta = event.delta - if (delta?.type === 'text_delta') { - const text = (delta.text as string) ?? '' - if (text) { - const streamDelta: CliAgentStreamDelta = { - workspaceId: session.workspaceId, - sessionId: session.id, - messageId: (message.uuid as string) ?? session.id, - delta: text, - } - this.getWebContents()?.send(IpcChannels.CLI_AGENT_STREAM_DELTA, streamDelta) - } - } + const message: CliAgentMessage = { + ...event.message, + backend: event.message.type === 'user' ? undefined : session.backend, } - } + session.messages.push(message) + this.emitMessage(session, message) - // eslint-disable-next-line @typescript-eslint/no-explicit-any - private handleToolProgress(session: CliAgentSessionInternal, message: any): void { - const msg: CliAgentMessage = { - id: (message.uuid as string) ?? randomUUID(), - type: 'tool_use', - content: `Running ${message.tool_name ?? 'tool'}...`, - timestamp: Date.now(), - toolName: (message.tool_name as string) ?? undefined, - toolUseId: (message.tool_use_id as string) ?? undefined, + if (message.type === 'status' && message.content.toLowerCase().includes('rate limited')) { + this.setStatus(session, 'rate_limited') + } else if (session.processStatus === 'rate_limited' && message.type !== 'status') { + this.setStatus(session, 'running') } - session.messages.push(msg) - this.emitMessage(session, msg) } - // eslint-disable-next-line @typescript-eslint/no-explicit-any - private handleToolUseSummary(session: CliAgentSessionInternal, message: any): void { - const msg: CliAgentMessage = { - id: (message.uuid as string) ?? randomUUID(), - type: 'tool_result', - content: (message.summary as string) ?? 'Tool completed', - timestamp: Date.now(), + private buildTurnPrompt(session: CliAgentSessionInternal, content: string): string { + const backendState = session.backendStates[session.backend] + if (backendState?.sessionId) { + return content } - session.messages.push(msg) - this.emitMessage(session, msg) - } - // eslint-disable-next-line @typescript-eslint/no-explicit-any - private handleResultMessage(session: CliAgentSessionInternal, message: any): void { - const subtype = message.subtype as string | undefined - const isSuccess = subtype === 'success' - const durationMs = (message.duration_ms as number) ?? 0 - const totalCostUsd = (message.total_cost_usd as number) ?? 0 - - session.totalCostUsd += totalCostUsd - - // Capture session ID from result as well - const resultSessionId = message.session_id as string | undefined - if (resultSessionId && !session.claudeSessionId) { - session.claudeSessionId = resultSessionId - this.conversationStore - ?.updateMeta(session.id, { claudeSessionId: resultSessionId }) - .catch(() => {}) - } - - // Build detailed error content for non-success results - let errorDetail = '' - if (!isSuccess) { - const errors = message.errors as string[] | undefined - if (errors?.length) { - errorDetail = errors.join('\n') - } - console.error( - `[CliAgentManager] Result error for session ${session.id.slice(0, 8)}: subtype=${subtype}`, + const priorMessages = session.messages + .slice(0, -1) + .filter( + (message) => + message.type === 'user' || + message.type === 'assistant' || + message.type === 'tool_use' || + message.type === 'tool_result', ) - if (errorDetail) console.error(`[CliAgentManager] Error details:\n${errorDetail}`) - console.error( - `[CliAgentManager] Full result message:`, - JSON.stringify(message).slice(0, 2000), - ) - } - const msg: CliAgentMessage = { - id: (message.uuid as string) ?? randomUUID(), - type: isSuccess ? 'result' : 'error', - content: isSuccess - ? `Completed in ${(durationMs / 1000).toFixed(1)}s` - : `Failed: ${subtype ?? 'unknown error'}${errorDetail ? '\n\n' + errorDetail : ''}`, - timestamp: Date.now(), - durationMs, - totalCostUsd, - isSuccess, - raw: message, + if (priorMessages.length === 0) { + return content } - session.messages.push(msg) - this.emitMessage(session, msg) - const resultPayload: CliAgentResultPayload = { - workspaceId: session.workspaceId, - sessionId: session.id, - durationMs, - totalCostUsd: session.totalCostUsd, - isSuccess, - } - this.getWebContents()?.send(IpcChannels.CLI_AGENT_RESULT, resultPayload) + const transcript = priorMessages + .slice(-40) + .map((message) => { + const source = message.backend ? ` ${message.backend}` : '' + return `[${message.type}${source}]\n${message.content}` + }) + .join('\n\n') + + return [ + `You are taking over an existing IDE agent conversation using the ${session.backend} backend.`, + 'Continue from the prior transcript below and answer the latest user request directly.', + '', + 'Conversation transcript:', + transcript, + '', + 'Latest user request:', + content, + ].join('\n') } - // ─── IPC Emission Helpers ──────────────────── + private handleRunError(session: CliAgentSessionInternal, error: string): void { + session.lastError = error + const message: CliAgentMessage = { + id: randomUUID(), + type: 'error', + content: error, + timestamp: Date.now(), + backend: session.backend, + } + session.messages.push(message) + this.emitMessage(session, message) + this.setStatus(session, 'error') + } private setStatus(session: CliAgentSessionInternal, status: CliAgentProcessStatus): void { session.processStatus = status @@ -783,51 +625,60 @@ export class CliAgentManager { this.getWebContents()?.send(IpcChannels.CLI_AGENT_STATUS, payload) } - private emitMessage(session: CliAgentSessionInternal, msg: CliAgentMessage): void { - const ipcMsg: CliAgentMessagePayload = { - ...msg, + private emitMessage(session: CliAgentSessionInternal, message: CliAgentMessage): void { + const payload: CliAgentMessagePayload = { + ...message, workspaceId: session.workspaceId, sessionId: session.id, } - this.getWebContents()?.send(IpcChannels.CLI_AGENT_MESSAGE, ipcMsg) - - if (session.processStatus === 'rate_limited' && msg.type !== 'status') { - this.setStatus(session, 'running') - } + this.getWebContents()?.send(IpcChannels.CLI_AGENT_MESSAGE, payload) } - // ─── Persistence Helpers ───────────────────── - private async persistSession(session: CliAgentSessionInternal): Promise { - if (!this.conversationStore) return + if (!this.conversationStore || session.id.startsWith('claude-native:')) return + + const claudeSessionId = session.backendStates['claude-code']?.sessionId await this.conversationStore.saveMessages(session.id, { messages: session.messages, - claudeSessionId: session.claudeSessionId, - }) + activeBackend: session.backend, + backendStates: session.backendStates, + claudeSessionId, + } satisfies PersistedCliConversation) + await this.conversationStore.updateMeta(session.id, { + backend: session.backend, updatedAt: Date.now(), messageCount: session.messages.length, - firstMessage: session.messages.find((m) => m.type === 'user')?.content.slice(0, 100), - claudeSessionId: session.claudeSessionId, + firstMessage: session.messages + .find((message) => message.type === 'user') + ?.content.slice(0, 100), + claudeSessionId, worktreePath: session.worktreePath, }) } private async maybeAutoTitle(session: CliAgentSessionInternal, content: string): Promise { - if (!this.conversationStore) return + if (!this.conversationStore || session.id.startsWith('claude-native:')) return - const userMessages = session.messages.filter((m) => m.type === 'user') + const userMessages = session.messages.filter((message) => message.type === 'user') if (userMessages.length !== 1) return const meta = await this.conversationStore.get(session.id) if (!meta || !meta.autoTitled) return - const title = deriveTitle(content) - await this.conversationStore.updateMeta(session.id, { title, updatedAt: Date.now() }) + await this.conversationStore.updateMeta(session.id, { + title: deriveTitle(content), + updatedAt: Date.now(), + }) + + await this.broadcastConversationList(session.workspaceId) + } + private async broadcastConversationList(workspaceId: string): Promise { + if (!this.conversationStore) return const index = await this.conversationStore.loadIndex() this.getWebContents()?.send(IpcChannels.CONVERSATION_LIST_CHANGED, { - workspaceId: session.workspaceId, + workspaceId, conversations: index, } satisfies ConversationListChangedPayload) } diff --git a/packages/main/src/chat/conversationStore.ts b/packages/main/src/chat/conversationStore.ts index 1a873cb..c5d2844 100644 --- a/packages/main/src/chat/conversationStore.ts +++ b/packages/main/src/chat/conversationStore.ts @@ -124,7 +124,7 @@ export class ConversationStore { async ensure(conversationId: string, opts: ConversationCreateOpts): Promise { return this.withIndexLock(async () => { const index = await this.loadIndex() - const existing = index.find(c => c.id === conversationId) + const existing = index.find((c) => c.id === conversationId) if (existing) return existing const now = Date.now() @@ -150,7 +150,7 @@ export class ConversationStore { async delete(conversationId: string): Promise { await this.withIndexLock(async () => { const index = await this.loadIndex() - const filtered = index.filter(c => c.id !== conversationId) + const filtered = index.filter((c) => c.id !== conversationId) await this.saveIndex(filtered) }) @@ -165,16 +165,28 @@ export class ConversationStore { async get(conversationId: string): Promise { const index = await this.loadIndex() - return index.find(c => c.id === conversationId) ?? null + return index.find((c) => c.id === conversationId) ?? null } async updateMeta( conversationId: string, - patch: Partial>, + patch: Partial< + Pick< + ConversationMeta, + | 'backend' + | 'title' + | 'autoTitled' + | 'updatedAt' + | 'messageCount' + | 'firstMessage' + | 'claudeSessionId' + | 'worktreePath' + > + >, ): Promise { await this.withIndexLock(async () => { const index = await this.loadIndex() - const entry = index.find(c => c.id === conversationId) + const entry = index.find((c) => c.id === conversationId) if (!entry) return Object.assign(entry, patch) @@ -190,9 +202,9 @@ export class ConversationStore { const index = await this.loadIndex() // Index is sorted newest-first if (backend) { - return index.find(c => c.workspaceId === workspaceId && c.backend === backend) ?? null + return index.find((c) => c.workspaceId === workspaceId && c.backend === backend) ?? null } - return index.find(c => c.workspaceId === workspaceId) ?? null + return index.find((c) => c.workspaceId === workspaceId) ?? null } // ─── Message Data ──────────────────────────── @@ -239,7 +251,7 @@ export class ConversationStore { } const messages = oldSession.messages ?? [] - const firstUserMsg = messages.find(m => m.role === 'user') + const firstUserMsg = messages.find((m) => m.role === 'user') const meta: ConversationMeta = { id: oldSession.id, diff --git a/packages/main/src/index.ts b/packages/main/src/index.ts index 20f3caf..4ec9fac 100644 --- a/packages/main/src/index.ts +++ b/packages/main/src/index.ts @@ -38,8 +38,17 @@ import { } from './workspace/worktreeManager' import { startSearch, cancelSearch } from './search/ripgrepSearch' import { ensureAideFolder } from './workspace/aideInit' -import { resolveAppDefaults, resolveSettings, BUILT_IN_DEFAULTS } from './workspace/settingsResolver' -import { auditGitignore, appendToGitignore, isAuditDismissed, dismissAudit } from './git/gitignoreAudit' +import { + resolveAppDefaults, + resolveSettings, + BUILT_IN_DEFAULTS, +} from './workspace/settingsResolver' +import { + auditGitignore, + appendToGitignore, + isAuditDismissed, + dismissAudit, +} from './git/gitignoreAudit' import { TaskRunner } from './tasks/taskRunner' import { detectTasks, generateTasksFile, hasTasksFile } from './tasks/taskAutoDetect' import { WorkspaceRegistry } from './workspace/workspaceRegistry' @@ -51,7 +60,12 @@ import { resolveRepoRootForWorkspace, resolveWorkspaceIdForIpc, } from './workspace/workspaceRootResolution' -import { saveWorkspaceState, loadWorkspaceState, saveTerminalState, loadTerminalState } from './workspace/stateSerializer' +import { + saveWorkspaceState, + loadWorkspaceState, + saveTerminalState, + loadTerminalState, +} from './workspace/stateSerializer' import { BrowserPaneManager } from './browserPaneManager' import { registerGitDiffHandlers } from './git/gitDiff' import { AgentManager } from './chat/agentManager' @@ -223,9 +237,7 @@ function createWindow(): void { if (process.env.ELECTRON_RENDERER_URL) { contentView.webContents.loadURL(process.env.ELECTRON_RENDERER_URL) } else { - contentView.webContents.loadFile( - join(__dirname, '../../renderer/dist/index.html'), - ) + contentView.webContents.loadFile(join(__dirname, '../../renderer/dist/index.html')) } // Forward fullscreen state to renderer @@ -268,7 +280,9 @@ ipcMain.handle(IpcChannels.SIDEBAR_WIDTH_SET, (_event, width: number) => { store.set('sidebarWidth', width) }) -ipcMain.handle(IpcChannels.BROWSER_ZOOM_GET, (_event, paneId: string) => browserPaneManager.getZoom(paneId)) +ipcMain.handle(IpcChannels.BROWSER_ZOOM_GET, (_event, paneId: string) => + browserPaneManager.getZoom(paneId), +) ipcMain.handle(IpcChannels.BROWSER_ZOOM_SET, (_event, paneId: string, zoomFactor: number) => browserPaneManager.setZoom(paneId, zoomFactor), ) @@ -324,7 +338,9 @@ function getConversationStore(runtime: WorkspaceRuntime | null): ConversationSto return (runtime?.services.conversationStore as ConversationStore | null) ?? null } -function getNativeSessionWatcher(runtime: WorkspaceRuntime | null): ClaudeNativeSessionWatcher | null { +function getNativeSessionWatcher( + runtime: WorkspaceRuntime | null, +): ClaudeNativeSessionWatcher | null { return (runtime?.services.nativeSessionWatcher as ClaudeNativeSessionWatcher | null) ?? null } @@ -334,36 +350,39 @@ async function loadPreferredClaudeMessages( conversationId: string, storedData?: unknown, ): Promise { - const stored = storedData ?? await conversationStore?.loadMessages(conversationId) + const stored = storedData ?? (await conversationStore?.loadMessages(conversationId)) const storedMessagesRaw = - stored && typeof stored === 'object' - ? (stored as { messages?: unknown }).messages - : undefined - const storedMessages = Array.isArray(storedMessagesRaw) ? (storedMessagesRaw as CliAgentMessage[]) : [] - const storedComparable = storedMessages.filter((message) => - message.type === 'user' || - message.type === 'assistant' || - message.type === 'tool_use' || - message.type === 'tool_result' + stored && typeof stored === 'object' ? (stored as { messages?: unknown }).messages : undefined + const storedMessages = Array.isArray(storedMessagesRaw) + ? (storedMessagesRaw as CliAgentMessage[]) + : [] + const storedComparable = storedMessages.filter( + (message) => + message.type === 'user' || + message.type === 'assistant' || + message.type === 'tool_use' || + message.type === 'tool_result', ).length const meta = await conversationStore?.get(conversationId) const claudeSessionId = meta?.claudeSessionId || - ( - stored && typeof stored === 'object' - ? (stored as { claudeSessionId?: unknown }).claudeSessionId - : undefined - ) + (stored && typeof stored === 'object' + ? ((stored as { claudeSessionId?: unknown }).claudeSessionId ?? + (stored as { backendStates?: { 'claude-code'?: { sessionId?: unknown } } }).backendStates?.[ + 'claude-code' + ]?.sessionId) + : undefined) if (typeof claudeSessionId === 'string' && nativeSessionWatcher) { try { const nativeMessages = await nativeSessionWatcher.loadMessages(claudeSessionId) - const nativeComparable = nativeMessages.filter((message) => - message.type === 'user' || - message.type === 'assistant' || - message.type === 'tool_use' || - message.type === 'tool_result' + const nativeComparable = nativeMessages.filter( + (message) => + message.type === 'user' || + message.type === 'assistant' || + message.type === 'tool_use' || + message.type === 'tool_result', ).length if (nativeMessages.length > 0 && nativeComparable > storedComparable) { return nativeMessages @@ -430,16 +449,20 @@ function loadLlmConfig(): LlmProviderConfig { apiKeyIsEnvRef: config.apiKey.includes('${env:'), baseUrl: config.baseUrl || '(default)', storeHasEditorDefaults: !!userDefaults, - storeKeys: Object.keys(userDefaults).filter(k => k.startsWith('agent.')), + storeKeys: Object.keys(userDefaults).filter((k) => k.startsWith('agent.')), }) return config } -function loadPermissionConfig(): { permissionTier: PermissionTier; autoApprove: Record } { +function loadPermissionConfig(): { + permissionTier: PermissionTier + autoApprove: Record +} { const userDefaults = (store.get('editorDefaults') ?? {}) as Record return { permissionTier: (userDefaults['agent.permissionTier'] as PermissionTier) || 'confirm', - autoApprove: (userDefaults['agent.autoApprove'] as Record) || {}, + autoApprove: + (userDefaults['agent.autoApprove'] as Record) || {}, } } @@ -469,21 +492,24 @@ async function startRuntimeServices(runtime: WorkspaceRuntime): Promise { nativeSessionCache = sessions runtime.setServices({ nativeSessionCache }) runtime.refreshWorkload() - void conversationStore.loadIndex().then((index) => { - const seen = new Set(index.map(c => c.id)) - const uniqueSessions = sessions.filter(s => !seen.has(s.id)) - contentView?.webContents.send(IpcChannels.CONVERSATION_LIST_CHANGED, { - workspaceId: runtime.workspaceId, - conversations: [...index, ...uniqueSessions], - source: 'claude-native', + void conversationStore + .loadIndex() + .then((index) => { + const seen = new Set(index.map((c) => c.id)) + const uniqueSessions = sessions.filter((s) => !seen.has(s.id)) + contentView?.webContents.send(IpcChannels.CONVERSATION_LIST_CHANGED, { + workspaceId: runtime.workspaceId, + conversations: [...index, ...uniqueSessions], + source: 'claude-native', + }) }) - }).catch(() => { - contentView?.webContents.send(IpcChannels.CONVERSATION_LIST_CHANGED, { - workspaceId: runtime.workspaceId, - conversations: sessions, - source: 'claude-native', + .catch(() => { + contentView?.webContents.send(IpcChannels.CONVERSATION_LIST_CHANGED, { + workspaceId: runtime.workspaceId, + conversations: sessions, + source: 'claude-native', + }) }) - }) }, }) await nativeSessionWatcher.start() @@ -511,9 +537,11 @@ async function startRuntimeServices(runtime: WorkspaceRuntime): Promise { workspaceRoot: rootPath, getWebContents: () => contentView?.webContents ?? null, claudeCodePath: resolved['agent.claudeCodePath'], + opencodePath: resolved['agent.opencodePath'], codexPath: resolved['agent.codexPath'], conversationStore, - loadClaudeHistory: async (claudeSessionId: string) => nativeSessionWatcher.loadMessages(claudeSessionId), + loadClaudeHistory: async (claudeSessionId: string) => + nativeSessionWatcher.loadMessages(claudeSessionId), }) runtime.setServices({ @@ -659,15 +687,18 @@ ipcMain.handle(IpcChannels.WORKSPACE_SWITCH, async (_event, id: string) => { await activateWorkspace(id) }) -ipcMain.handle(IpcChannels.WORKSPACE_UPDATE, (_event, id: string, patch: Partial<{ name: string; icon: string; color: string }>) => { - workspaceRegistry.update(id, patch) - const entry = workspaceRegistry.get(id) - if (entry) { - runtimeRegistry.get(id)?.syncEntry(entry) - } - broadcastWorkspaceRegistry() - broadcastRuntimeSnapshots() -}) +ipcMain.handle( + IpcChannels.WORKSPACE_UPDATE, + (_event, id: string, patch: Partial<{ name: string; icon: string; color: string }>) => { + workspaceRegistry.update(id, patch) + const entry = workspaceRegistry.get(id) + if (entry) { + runtimeRegistry.get(id)?.syncEntry(entry) + } + broadcastWorkspaceRegistry() + broadcastRuntimeSnapshots() + }, +) ipcMain.handle(IpcChannels.WORKSPACE_REORDER, (_event, ids: string[]) => { workspaceRegistry.reorder(ids) @@ -685,20 +716,26 @@ ipcMain.handle(IpcChannels.WORKSPACE_RUNTIME_SNAPSHOTS_GET, () => { // ─── Chat / Agent IPC handlers ───────────────────────────────────── -ipcMain.handle(IpcChannels.CHAT_SEND_MESSAGE, async (_event, sessionId: string, content: string) => { - const runtime = findRuntimeWithBuiltInSession(sessionId) - const agentManager = getAgentManager(runtime) - if (!agentManager) return { error: 'No workspace open' } - const result = await agentManager.sendMessage(sessionId, content) - runtime?.refreshWorkload() - return result -}) +ipcMain.handle( + IpcChannels.CHAT_SEND_MESSAGE, + async (_event, sessionId: string, content: string) => { + const runtime = findRuntimeWithBuiltInSession(sessionId) + const agentManager = getAgentManager(runtime) + if (!agentManager) return { error: 'No workspace open' } + const result = await agentManager.sendMessage(sessionId, content) + runtime?.refreshWorkload() + return result + }, +) -ipcMain.handle(IpcChannels.CHAT_GET_HISTORY, async (_event, workspaceId: string, conversationId?: string) => { - const agentManager = getAgentManager(runtimeRegistry.get(workspaceId)) - if (!agentManager) return null - return agentManager.getHistory(workspaceId, conversationId) -}) +ipcMain.handle( + IpcChannels.CHAT_GET_HISTORY, + async (_event, workspaceId: string, conversationId?: string) => { + const agentManager = getAgentManager(runtimeRegistry.get(workspaceId)) + if (!agentManager) return null + return agentManager.getHistory(workspaceId, conversationId) + }, +) ipcMain.handle(IpcChannels.CHAT_PENDING_TOOL_APPROVALS_LIST, (): PendingToolApprovalInfo[] => { const out: PendingToolApprovalInfo[] = [] @@ -716,25 +753,34 @@ ipcMain.handle(IpcChannels.CHAT_SET_MODE, async (_event, sessionId: string, mode agentManager?.setMode(sessionId, mode) }) -ipcMain.handle(IpcChannels.CHAT_SET_WORKING_SET, async (_event, sessionId: string, paths: string[]) => { - const runtime = findRuntimeWithBuiltInSession(sessionId) - const agentManager = getAgentManager(runtime) - agentManager?.setWorkingSet(sessionId, paths) -}) +ipcMain.handle( + IpcChannels.CHAT_SET_WORKING_SET, + async (_event, sessionId: string, paths: string[]) => { + const runtime = findRuntimeWithBuiltInSession(sessionId) + const agentManager = getAgentManager(runtime) + agentManager?.setWorkingSet(sessionId, paths) + }, +) -ipcMain.handle(IpcChannels.CHAT_TOOL_APPROVE, async (_event, sessionId: string, toolCallId: string) => { - const runtime = findRuntimeWithBuiltInSession(sessionId) - const agentManager = getAgentManager(runtime) - agentManager?.approveToolCall(sessionId, toolCallId) - runtime?.refreshWorkload() -}) +ipcMain.handle( + IpcChannels.CHAT_TOOL_APPROVE, + async (_event, sessionId: string, toolCallId: string) => { + const runtime = findRuntimeWithBuiltInSession(sessionId) + const agentManager = getAgentManager(runtime) + agentManager?.approveToolCall(sessionId, toolCallId) + runtime?.refreshWorkload() + }, +) -ipcMain.handle(IpcChannels.CHAT_TOOL_REJECT, async (_event, sessionId: string, toolCallId: string) => { - const runtime = findRuntimeWithBuiltInSession(sessionId) - const agentManager = getAgentManager(runtime) - agentManager?.rejectToolCall(sessionId, toolCallId) - runtime?.refreshWorkload() -}) +ipcMain.handle( + IpcChannels.CHAT_TOOL_REJECT, + async (_event, sessionId: string, toolCallId: string) => { + const runtime = findRuntimeWithBuiltInSession(sessionId) + const agentManager = getAgentManager(runtime) + agentManager?.rejectToolCall(sessionId, toolCallId) + runtime?.refreshWorkload() + }, +) ipcMain.on(IpcChannels.CHAT_STOP, (_event, sessionId: string) => { const runtime = findRuntimeWithBuiltInSession(sessionId) @@ -745,14 +791,35 @@ ipcMain.on(IpcChannels.CHAT_STOP, (_event, sessionId: string) => { // ─── CLI Agent IPC handlers ───────────────────────────────────── -ipcMain.handle(IpcChannels.CLI_AGENT_START, async (_event, workspaceId: string, backend: AgentBackend, conversationId?: string, worktreePath?: string) => { - const runtime = runtimeRegistry.get(workspaceId) - const cliAgentManager = getCliAgentManager(runtime) - if (!cliAgentManager) return { error: 'No workspace open' } - const result = await cliAgentManager.start(workspaceId, backend, conversationId, worktreePath) - runtime?.refreshWorkload() - return result -}) +ipcMain.handle( + IpcChannels.CLI_AGENT_START, + async ( + _event, + workspaceId: string, + backend: AgentBackend, + conversationId?: string, + worktreePath?: string, + ) => { + const runtime = runtimeRegistry.get(workspaceId) + const cliAgentManager = getCliAgentManager(runtime) + if (!cliAgentManager) return { error: 'No workspace open' } + const result = await cliAgentManager.start(workspaceId, backend, conversationId, worktreePath) + runtime?.refreshWorkload() + return result + }, +) + +ipcMain.handle( + IpcChannels.CLI_AGENT_SWITCH_BACKEND, + async (_event, sessionId: string, backend: AgentBackend) => { + const runtime = findRuntimeWithCliSession(sessionId) + const cliAgentManager = getCliAgentManager(runtime) + if (!cliAgentManager) return { error: 'No workspace open' } + const result = await cliAgentManager.switchBackend(sessionId, backend) + runtime?.refreshWorkload() + return result + }, +) ipcMain.handle(IpcChannels.CLI_AGENT_SEND, async (_event, sessionId: string, content: string) => { const runtime = findRuntimeWithCliSession(sessionId) @@ -763,16 +830,19 @@ ipcMain.handle(IpcChannels.CLI_AGENT_SEND, async (_event, sessionId: string, con return result }) -ipcMain.handle(IpcChannels.CLI_AGENT_GET_SESSION, async (_event, workspaceId: string, sessionId?: string) => { - const cliAgentManager = getCliAgentManager(runtimeRegistry.get(workspaceId)) - if (!cliAgentManager) return null - if (sessionId) { - const s = cliAgentManager.getSessionById(sessionId) - if (!s || s.workspaceId !== workspaceId) return null - return s - } - return cliAgentManager.getSession(workspaceId) ?? null -}) +ipcMain.handle( + IpcChannels.CLI_AGENT_GET_SESSION, + async (_event, workspaceId: string, sessionId?: string) => { + const cliAgentManager = getCliAgentManager(runtimeRegistry.get(workspaceId)) + if (!cliAgentManager) return null + if (sessionId) { + const s = cliAgentManager.getSessionById(sessionId) + if (!s || s.workspaceId !== workspaceId) return null + return s + } + return cliAgentManager.getSession(workspaceId) ?? null + }, +) ipcMain.handle( IpcChannels.CLI_AGENT_LOAD_MESSAGES, @@ -784,15 +854,17 @@ ipcMain.handle( const nativeMeta = nativeSessionCache.find((c) => c.id === conversationId) ?? nativeSessionCache.find((c) => c.claudeSessionId === conversationId) - if (nativeMeta?.source === 'claude-native' && nativeMeta.claudeSessionId && nativeSessionWatcher) { + if ( + nativeMeta?.source === 'claude-native' && + nativeMeta.claudeSessionId && + nativeSessionWatcher + ) { return nativeSessionWatcher.loadMessages(nativeMeta.claudeSessionId) } const nativePrefix = 'claude-native:' if (conversationId.startsWith(nativePrefix) && nativeSessionWatcher) { const rawId = conversationId.slice(nativePrefix.length) - if ( - /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(rawId) - ) { + if (/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(rawId)) { return nativeSessionWatcher.loadMessages(rawId) } } @@ -813,7 +885,7 @@ ipcMain.handle(IpcChannels.CONVERSATION_LIST, async (_event, workspaceId: string const runtime = runtimeRegistry.get(workspaceId) const conversationStore = getConversationStore(runtime) const nativeSessionCache = getNativeSessionCache(runtime) - const aideConvos = await conversationStore?.loadIndex() ?? [] + const aideConvos = (await conversationStore?.loadIndex()) ?? [] return [...aideConvos, ...nativeSessionCache] }) @@ -831,74 +903,101 @@ ipcMain.handle(IpcChannels.CONVERSATION_CREATE, async (_event, opts: Conversatio return meta }) -ipcMain.handle(IpcChannels.CONVERSATION_DELETE, async (_event, workspaceId: string, conversationId: string) => { - const runtime = runtimeRegistry.get(workspaceId) - const conversationStore = getConversationStore(runtime) - const agentManager = getAgentManager(runtime) - const cliAgentManager = getCliAgentManager(runtime) - if (!conversationStore) return - const meta = await conversationStore.get(conversationId) - if (meta && meta.workspaceId !== workspaceId) return - await conversationStore.delete(conversationId) - agentManager?.stop(conversationId) - cliAgentManager?.stop(conversationId) - const nativeSessionCache = getNativeSessionCache(runtime) - if (meta) { - const index = await conversationStore.loadIndex() - contentView?.webContents.send(IpcChannels.CONVERSATION_LIST_CHANGED, { - workspaceId, - conversations: [...index, ...nativeSessionCache], - }) - } -}) - -ipcMain.handle(IpcChannels.CONVERSATION_RENAME, async (_event, workspaceId: string, conversationId: string, title: string) => { - const runtime = runtimeRegistry.get(workspaceId) - const conversationStore = getConversationStore(runtime) - if (!conversationStore) return - const existing = await conversationStore.get(conversationId) - if (!existing || existing.workspaceId !== workspaceId) return - await conversationStore.updateMeta(conversationId, { title, autoTitled: false, updatedAt: Date.now() }) - const meta = await conversationStore.get(conversationId) - if (meta) { - const index = await conversationStore.loadIndex() +ipcMain.handle( + IpcChannels.CONVERSATION_DELETE, + async (_event, workspaceId: string, conversationId: string) => { + const runtime = runtimeRegistry.get(workspaceId) + const conversationStore = getConversationStore(runtime) + const agentManager = getAgentManager(runtime) + const cliAgentManager = getCliAgentManager(runtime) + if (!conversationStore) return + const meta = await conversationStore.get(conversationId) + if (meta && meta.workspaceId !== workspaceId) return + await conversationStore.delete(conversationId) + agentManager?.stop(conversationId) + cliAgentManager?.stop(conversationId) const nativeSessionCache = getNativeSessionCache(runtime) - contentView?.webContents.send(IpcChannels.CONVERSATION_LIST_CHANGED, { - workspaceId, - conversations: [...index, ...nativeSessionCache], + if (meta) { + const index = await conversationStore.loadIndex() + contentView?.webContents.send(IpcChannels.CONVERSATION_LIST_CHANGED, { + workspaceId, + conversations: [...index, ...nativeSessionCache], + }) + } + }, +) + +ipcMain.handle( + IpcChannels.CONVERSATION_RENAME, + async (_event, workspaceId: string, conversationId: string, title: string) => { + const runtime = runtimeRegistry.get(workspaceId) + const conversationStore = getConversationStore(runtime) + if (!conversationStore) return + const existing = await conversationStore.get(conversationId) + if (!existing || existing.workspaceId !== workspaceId) return + await conversationStore.updateMeta(conversationId, { + title, + autoTitled: false, + updatedAt: Date.now(), }) - } -}) + const meta = await conversationStore.get(conversationId) + if (meta) { + const index = await conversationStore.loadIndex() + const nativeSessionCache = getNativeSessionCache(runtime) + contentView?.webContents.send(IpcChannels.CONVERSATION_LIST_CHANGED, { + workspaceId, + conversations: [...index, ...nativeSessionCache], + }) + } + }, +) -ipcMain.handle(IpcChannels.CONVERSATION_GET, async (_event, workspaceId: string, conversationId: string) => { - const runtime = runtimeRegistry.get(workspaceId) - const conversationStore = getConversationStore(runtime) - const meta = await conversationStore?.get(conversationId) - if (meta && meta.workspaceId !== workspaceId) return null - return meta ?? null -}) +ipcMain.handle( + IpcChannels.CONVERSATION_GET, + async (_event, workspaceId: string, conversationId: string) => { + const runtime = runtimeRegistry.get(workspaceId) + const conversationStore = getConversationStore(runtime) + const meta = await conversationStore?.get(conversationId) + if (meta && meta.workspaceId !== workspaceId) return null + return meta ?? null + }, +) // State persistence IPC handlers -ipcMain.handle(IpcChannels.STATE_SAVE, async (_event, rootPath: string, state: import('@aide/shared').AideLocalState) => { - await saveWorkspaceState(rootPath, state) -}) +ipcMain.handle( + IpcChannels.STATE_SAVE, + async (_event, rootPath: string, state: import('@aide/shared').AideLocalState) => { + await saveWorkspaceState(rootPath, state) + }, +) ipcMain.handle(IpcChannels.STATE_LOAD, async (_event, rootPath: string) => { return loadWorkspaceState(rootPath) }) -ipcMain.handle(IpcChannels.STATE_SAVE_TERMINALS, async (_event, rootPath: string, state: import('@aide/shared').AideLocalTerminals) => { - await saveTerminalState(rootPath, state) -}) +ipcMain.handle( + IpcChannels.STATE_SAVE_TERMINALS, + async (_event, rootPath: string, state: import('@aide/shared').AideLocalTerminals) => { + await saveTerminalState(rootPath, state) + }, +) ipcMain.handle(IpcChannels.STATE_LOAD_TERMINALS, async (_event, rootPath: string) => { return loadTerminalState(rootPath) }) // Browser pane IPC handlers -ipcMain.handle(IpcChannels.BROWSER_CREATE, (_event, paneId: string, workspaceId: string, sessionMode: import('@aide/shared').BrowserSessionMode) => { - return browserPaneManager.create(paneId, workspaceId, sessionMode) -}) +ipcMain.handle( + IpcChannels.BROWSER_CREATE, + ( + _event, + paneId: string, + workspaceId: string, + sessionMode: import('@aide/shared').BrowserSessionMode, + ) => { + return browserPaneManager.create(paneId, workspaceId, sessionMode) + }, +) ipcMain.on(IpcChannels.BROWSER_DESTROY, (_event, paneId: string) => { browserPaneManager.destroy(paneId) @@ -924,9 +1023,12 @@ ipcMain.on(IpcChannels.BROWSER_RELOAD, (_event, paneId: string) => { browserPaneManager.reload(paneId) }) -ipcMain.on(IpcChannels.BROWSER_HOST_UPDATE, (_event, update: import('@aide/shared').BrowserHostUpdate) => { - browserPaneManager.handleHostUpdate(update) -}) +ipcMain.on( + IpcChannels.BROWSER_HOST_UPDATE, + (_event, update: import('@aide/shared').BrowserHostUpdate) => { + browserPaneManager.handleHostUpdate(update) + }, +) ipcMain.on(IpcChannels.BROWSER_SUPPRESS_OVERLAYS, () => { browserPaneManager.suppressOverlays() @@ -966,11 +1068,14 @@ ipcMain.handle(IpcChannels.AIDE_INIT, async (_event, workspaceId?: string | null }) // .aide settings IPC handler -ipcMain.handle(IpcChannels.AIDE_GET_RESOLVED_SETTINGS, async (_event, workspaceId?: string | null) => { - const rootPath = resolveRepoRootForWorkspace(workspaceRegistry, workspaceId) - if (!rootPath) return resolveAppDefaults(store) - return resolveSettings(rootPath, store) -}) +ipcMain.handle( + IpcChannels.AIDE_GET_RESOLVED_SETTINGS, + async (_event, workspaceId?: string | null) => { + const rootPath = resolveRepoRootForWorkspace(workspaceRegistry, workspaceId) + if (!rootPath) return resolveAppDefaults(store) + return resolveSettings(rootPath, store) + }, +) // Settings IPC handlers ipcMain.handle(IpcChannels.SETTINGS_GET_DEFAULTS, () => BUILT_IN_DEFAULTS) @@ -982,9 +1087,7 @@ ipcMain.handle(IpcChannels.SETTINGS_GET_USER, () => { ipcMain.handle(IpcChannels.SETTINGS_SET_USER, async (_event, key: string, value: unknown) => { let current = (store.get('editorDefaults') ?? {}) as Record if (value === undefined || value === null) { - current = Object.fromEntries( - Object.entries(current).filter(([entryKey]) => entryKey !== key), - ) + current = Object.fromEntries(Object.entries(current).filter(([entryKey]) => entryKey !== key)) } else { current[key] = value } @@ -1002,18 +1105,20 @@ ipcMain.handle(IpcChannels.SETTINGS_SET_USER, async (_event, key: string, value: } // Push CLI agent path updates - if (key === 'agent.claudeCodePath' || key === 'agent.codexPath') { + if (key === 'agent.claudeCodePath' || key === 'agent.opencodePath' || key === 'agent.codexPath') { const appDefs = resolveAppDefaults(store) for (const runtime of runtimeRegistry.list()) { - getCliAgentManager(runtime)?.updatePaths(appDefs['agent.claudeCodePath'], appDefs['agent.codexPath']) + getCliAgentManager(runtime)?.updatePaths( + appDefs['agent.claudeCodePath'], + appDefs['agent.opencodePath'], + appDefs['agent.codexPath'], + ) } } // Phase 8: only allowlisted implicit-active use — merged project settings for the focused workspace const rootPath = resolveRepoRootForWorkspace(workspaceRegistry, undefined) - const resolved = rootPath - ? await resolveSettings(rootPath, store) - : resolveAppDefaults(store) + const resolved = rootPath ? await resolveSettings(rootPath, store) : resolveAppDefaults(store) contentView?.webContents.send(IpcChannels.SETTINGS_CHANGED, resolved) }) @@ -1030,51 +1135,57 @@ ipcMain.handle(IpcChannels.SETTINGS_GET_WORKSPACE, async (_event, workspaceId?: } }) -ipcMain.handle(IpcChannels.SETTINGS_SET_WORKSPACE, async (_event, key: string, value: unknown, workspaceId?: string | null) => { - // Block sensitive agent keys from being written to project-level settings - if (SENSITIVE_AGENT_KEYS.has(key)) return - - const rootPath = resolveRepoRootForWorkspace(workspaceRegistry, workspaceId) - if (!rootPath) return - - const settingsPath = join(rootPath, '.aide', 'settings.json') - - // Ensure .aide directory exists - const aideDir = join(rootPath, '.aide') - if (!existsSync(aideDir)) await mkdir(aideDir, { recursive: true }) - - // Read existing settings - let current: Record = {} - if (existsSync(settingsPath)) { - try { - const raw = await readFile(settingsPath, 'utf-8') - current = JSON.parse(raw) - } catch { - current = {} +ipcMain.handle( + IpcChannels.SETTINGS_SET_WORKSPACE, + async (_event, key: string, value: unknown, workspaceId?: string | null) => { + // Block sensitive agent keys from being written to project-level settings + if (SENSITIVE_AGENT_KEYS.has(key)) return + + const rootPath = resolveRepoRootForWorkspace(workspaceRegistry, workspaceId) + if (!rootPath) return + + const settingsPath = join(rootPath, '.aide', 'settings.json') + + // Ensure .aide directory exists + const aideDir = join(rootPath, '.aide') + if (!existsSync(aideDir)) await mkdir(aideDir, { recursive: true }) + + // Read existing settings + let current: Record = {} + if (existsSync(settingsPath)) { + try { + const raw = await readFile(settingsPath, 'utf-8') + current = JSON.parse(raw) + } catch { + current = {} + } } - } - if (value === undefined || value === null) { - current = Object.fromEntries( - Object.entries(current).filter(([entryKey]) => entryKey !== key), - ) - } else { - current[key] = value - } + if (value === undefined || value === null) { + current = Object.fromEntries(Object.entries(current).filter(([entryKey]) => entryKey !== key)) + } else { + current[key] = value + } - await fsWriteFile(settingsPath, JSON.stringify(current, null, 2) + '\n', 'utf-8') + await fsWriteFile(settingsPath, JSON.stringify(current, null, 2) + '\n', 'utf-8') - // Broadcast resolved settings - const resolved = await resolveSettings(rootPath, store) - contentView?.webContents.send(IpcChannels.SETTINGS_CHANGED, resolved) -}) + // Broadcast resolved settings + const resolved = await resolveSettings(rootPath, store) + contentView?.webContents.send(IpcChannels.SETTINGS_CHANGED, resolved) + }, +) // Keybinding overrides IPC handlers // Migrate old Record format to KeybindingRule[] on first read -function migrateKeybindingOverrides(stored: unknown): { key: string; command: string; when?: string }[] { +function migrateKeybindingOverrides( + stored: unknown, +): { key: string; command: string; when?: string }[] { if (Array.isArray(stored)) return stored if (stored && typeof stored === 'object' && !Array.isArray(stored)) { - const migrated = Object.entries(stored as Record).map(([command, key]) => ({ key, command })) + const migrated = Object.entries(stored as Record).map(([command, key]) => ({ + key, + command, + })) store.set('keybindingOverrides', migrated) return migrated } @@ -1085,10 +1196,13 @@ ipcMain.handle(IpcChannels.KEYBINDINGS_GET, () => { return migrateKeybindingOverrides(store.get('keybindingOverrides')) }) -ipcMain.handle(IpcChannels.KEYBINDINGS_SET, async (_event, rules: { key: string; command: string; when?: string }[]) => { - store.set('keybindingOverrides', rules) - contentView?.webContents.send(IpcChannels.KEYBINDINGS_CHANGED, rules) -}) +ipcMain.handle( + IpcChannels.KEYBINDINGS_SET, + async (_event, rules: { key: string; command: string; when?: string }[]) => { + store.set('keybindingOverrides', rules) + contentView?.webContents.send(IpcChannels.KEYBINDINGS_CHANGED, rules) + }, +) // Gitignore security audit IPC handlers ipcMain.handle(IpcChannels.GITIGNORE_AUDIT, async (_event, workspaceId?: string | null) => { @@ -1097,11 +1211,14 @@ ipcMain.handle(IpcChannels.GITIGNORE_AUDIT, async (_event, workspaceId?: string return auditGitignore(rootPath) }) -ipcMain.handle(IpcChannels.GITIGNORE_APPEND, async (_event, patterns: string[], workspaceId?: string | null) => { - const rootPath = resolveRepoRootForWorkspace(workspaceRegistry, workspaceId) - if (!rootPath) return - await appendToGitignore(rootPath, patterns) -}) +ipcMain.handle( + IpcChannels.GITIGNORE_APPEND, + async (_event, patterns: string[], workspaceId?: string | null) => { + const rootPath = resolveRepoRootForWorkspace(workspaceRegistry, workspaceId) + if (!rootPath) return + await appendToGitignore(rootPath, patterns) + }, +) ipcMain.handle(IpcChannels.GITIGNORE_DISMISS, async (_event, workspaceId?: string | null) => { const rootPath = resolveRepoRootForWorkspace(workspaceRegistry, workspaceId) @@ -1226,25 +1343,28 @@ ipcMain.handle(IpcChannels.TASK_LIST_RUNNING, async (_event, workspaceId: string return taskRunner?.getRunning() ?? [] }) -ipcMain.handle(IpcChannels.TASK_RUN, async (_event, workspaceId: string, taskId: string, context?: TaskRunContext) => { - const runtime = runtimeRegistry.get(workspaceId) - const taskRunner = getTaskRunner(runtime) - if (!taskRunner) return { error: 'No workspace open' } - const rootPath = runtime?.rootPath ?? null - if (!rootPath) return { error: 'No workspace open' } - - const eff = getEffectiveWorkspaceRoot(workspaceId, rootPath) ?? rootPath - const ctx = { - workspaceRoot: eff, - workspaceName: eff.split('/').pop() ?? eff, - activeFile: context?.activeFile, - selectedText: context?.selectedText, - lineNumber: context?.lineNumber, - } - const result = await taskRunner.run(taskId, ctx) - runtime?.refreshWorkload() - return result -}) +ipcMain.handle( + IpcChannels.TASK_RUN, + async (_event, workspaceId: string, taskId: string, context?: TaskRunContext) => { + const runtime = runtimeRegistry.get(workspaceId) + const taskRunner = getTaskRunner(runtime) + if (!taskRunner) return { error: 'No workspace open' } + const rootPath = runtime?.rootPath ?? null + if (!rootPath) return { error: 'No workspace open' } + + const eff = getEffectiveWorkspaceRoot(workspaceId, rootPath) ?? rootPath + const ctx = { + workspaceRoot: eff, + workspaceName: eff.split('/').pop() ?? eff, + activeFile: context?.activeFile, + selectedText: context?.selectedText, + lineNumber: context?.lineNumber, + } + const result = await taskRunner.run(taskId, ctx) + runtime?.refreshWorkload() + return result + }, +) ipcMain.on(IpcChannels.TASK_KILL, (_event, workspaceId: string, executionId: string) => { const runtime = runtimeRegistry.get(workspaceId) @@ -1258,12 +1378,15 @@ ipcMain.handle(IpcChannels.TASK_RELOAD, async (_event, workspaceId: string) => { await taskRunner?.loadTasks() }) -ipcMain.on(IpcChannels.TASK_PROVIDE_INPUT, (_event, workspaceId: string, requestId: string, value: string | null) => { - const runtime = runtimeRegistry.get(workspaceId) - const taskRunner = getTaskRunner(runtime) - taskRunner?.provideInput(requestId, value) - runtime?.refreshWorkload() -}) +ipcMain.on( + IpcChannels.TASK_PROVIDE_INPUT, + (_event, workspaceId: string, requestId: string, value: string | null) => { + const runtime = runtimeRegistry.get(workspaceId) + const taskRunner = getTaskRunner(runtime) + taskRunner?.provideInput(requestId, value) + runtime?.refreshWorkload() + }, +) ipcMain.handle(IpcChannels.TASK_GENERATE, async (_event, workspaceId: string) => { const entry = workspaceRegistry.get(workspaceId) @@ -1324,120 +1447,152 @@ ipcMain.on(IpcChannels.TASK_FILE_SAVED, (_event, filePath: string) => { // Filesystem IPC handlers const HIDDEN_FILES = new Set(['.DS_Store', 'Thumbs.db']) -ipcMain.handle(IpcChannels.FS_READ_DIR, async (_event, dirPath: string): Promise => { - try { - const entries = await readdir(dirPath, { withFileTypes: true }) - const mapped: DirEntry[] = entries - .filter((e) => !HIDDEN_FILES.has(e.name)) - .map((e) => ({ - name: e.name, - path: join(dirPath, e.name), - isDirectory: e.isDirectory(), - })) - // Sort: directories first, then alphabetical (case-insensitive) - mapped.sort((a, b) => { - if (a.isDirectory !== b.isDirectory) return a.isDirectory ? -1 : 1 - return a.name.localeCompare(b.name, undefined, { sensitivity: 'base' }) - }) - return mapped - } catch (err: unknown) { - const message = err instanceof Error ? err.message : 'Unknown error reading directory' - return { error: message } - } -}) +ipcMain.handle( + IpcChannels.FS_READ_DIR, + async (_event, dirPath: string): Promise => { + try { + const entries = await readdir(dirPath, { withFileTypes: true }) + const mapped: DirEntry[] = entries + .filter((e) => !HIDDEN_FILES.has(e.name)) + .map((e) => ({ + name: e.name, + path: join(dirPath, e.name), + isDirectory: e.isDirectory(), + })) + // Sort: directories first, then alphabetical (case-insensitive) + mapped.sort((a, b) => { + if (a.isDirectory !== b.isDirectory) return a.isDirectory ? -1 : 1 + return a.name.localeCompare(b.name, undefined, { sensitivity: 'base' }) + }) + return mapped + } catch (err: unknown) { + const message = err instanceof Error ? err.message : 'Unknown error reading directory' + return { error: message } + } + }, +) // Read file IPC handler — enforces 10 MB limit, rejects binary files const MAX_FILE_SIZE = 10 * 1024 * 1024 // 10 MB -ipcMain.handle(IpcChannels.FS_READ_FILE, async (_event, filePath: string): Promise<{ content: string } | { error: string }> => { - try { - const info = await stat(filePath) - if (!info.isFile()) return { error: 'Not a file' } - if (info.size > MAX_FILE_SIZE) return { error: `File too large (${(info.size / 1024 / 1024).toFixed(1)} MB). Maximum is 10 MB.` } +ipcMain.handle( + IpcChannels.FS_READ_FILE, + async (_event, filePath: string): Promise<{ content: string } | { error: string }> => { + try { + const info = await stat(filePath) + if (!info.isFile()) return { error: 'Not a file' } + if (info.size > MAX_FILE_SIZE) + return { + error: `File too large (${(info.size / 1024 / 1024).toFixed(1)} MB). Maximum is 10 MB.`, + } - const content = await readFile(filePath, 'utf-8') + const content = await readFile(filePath, 'utf-8') - // Check for binary content (null bytes in first 8 KB) - const sample = content.slice(0, 8192) - if (sample.includes('\0')) return { error: 'Binary file — cannot display' } + // Check for binary content (null bytes in first 8 KB) + const sample = content.slice(0, 8192) + if (sample.includes('\0')) return { error: 'Binary file — cannot display' } - return { content } - } catch (err: unknown) { - const message = err instanceof Error ? err.message : 'Unknown error reading file' - return { error: message } - } -}) + return { content } + } catch (err: unknown) { + const message = err instanceof Error ? err.message : 'Unknown error reading file' + return { error: message } + } + }, +) // Write file IPC handler -ipcMain.handle(IpcChannels.FS_WRITE_FILE, async (_event, filePath: string, content: string): Promise<{ success: true } | { error: string }> => { - try { - await fsWriteFile(filePath, content, 'utf-8') - return { success: true } - } catch (err: unknown) { - const message = err instanceof Error ? err.message : 'Unknown error writing file' - return { error: message } - } -}) +ipcMain.handle( + IpcChannels.FS_WRITE_FILE, + async ( + _event, + filePath: string, + content: string, + ): Promise<{ success: true } | { error: string }> => { + try { + await fsWriteFile(filePath, content, 'utf-8') + return { success: true } + } catch (err: unknown) { + const message = err instanceof Error ? err.message : 'Unknown error writing file' + return { error: message } + } + }, +) // Create file IPC handler -ipcMain.handle(IpcChannels.FS_CREATE_FILE, async (_event, filePath: string): Promise<{ success: true } | { error: string }> => { - try { - // Check if already exists +ipcMain.handle( + IpcChannels.FS_CREATE_FILE, + async (_event, filePath: string): Promise<{ success: true } | { error: string }> => { try { - await stat(filePath) - return { error: 'File already exists' } - } catch { - // Expected — file doesn't exist yet + // Check if already exists + try { + await stat(filePath) + return { error: 'File already exists' } + } catch { + // Expected — file doesn't exist yet + } + // Ensure parent directory exists + await mkdir(dirname(filePath), { recursive: true }) + await fsWriteFile(filePath, '', 'utf-8') + return { success: true } + } catch (err: unknown) { + const message = err instanceof Error ? err.message : 'Unknown error creating file' + return { error: message } } - // Ensure parent directory exists - await mkdir(dirname(filePath), { recursive: true }) - await fsWriteFile(filePath, '', 'utf-8') - return { success: true } - } catch (err: unknown) { - const message = err instanceof Error ? err.message : 'Unknown error creating file' - return { error: message } - } -}) + }, +) // Create directory IPC handler -ipcMain.handle(IpcChannels.FS_CREATE_DIR, async (_event, dirPath: string): Promise<{ success: true } | { error: string }> => { - try { - await mkdir(dirPath) - return { success: true } - } catch (err: unknown) { - const message = err instanceof Error ? err.message : 'Unknown error creating directory' - return { error: message } - } -}) +ipcMain.handle( + IpcChannels.FS_CREATE_DIR, + async (_event, dirPath: string): Promise<{ success: true } | { error: string }> => { + try { + await mkdir(dirPath) + return { success: true } + } catch (err: unknown) { + const message = err instanceof Error ? err.message : 'Unknown error creating directory' + return { error: message } + } + }, +) // Delete file or directory IPC handler -ipcMain.handle(IpcChannels.FS_DELETE, async (_event, entryPath: string): Promise<{ success: true } | { error: string }> => { - try { - await rm(entryPath, { recursive: true }) - return { success: true } - } catch (err: unknown) { - const message = err instanceof Error ? err.message : 'Unknown error deleting' - return { error: message } - } -}) +ipcMain.handle( + IpcChannels.FS_DELETE, + async (_event, entryPath: string): Promise<{ success: true } | { error: string }> => { + try { + await rm(entryPath, { recursive: true }) + return { success: true } + } catch (err: unknown) { + const message = err instanceof Error ? err.message : 'Unknown error deleting' + return { error: message } + } + }, +) // Rename file or directory IPC handler -ipcMain.handle(IpcChannels.FS_RENAME, async (_event, oldPath: string, newPath: string): Promise<{ success: true } | { error: string }> => { - try { - // Check if target already exists +ipcMain.handle( + IpcChannels.FS_RENAME, + async ( + _event, + oldPath: string, + newPath: string, + ): Promise<{ success: true } | { error: string }> => { try { - await stat(newPath) - return { error: 'A file or folder with that name already exists' } - } catch { - // Expected — target doesn't exist + // Check if target already exists + try { + await stat(newPath) + return { error: 'A file or folder with that name already exists' } + } catch { + // Expected — target doesn't exist + } + await rename(oldPath, newPath) + return { success: true } + } catch (err: unknown) { + const message = err instanceof Error ? err.message : 'Unknown error renaming' + return { error: message } } - await rename(oldPath, newPath) - return { success: true } - } catch (err: unknown) { - const message = err instanceof Error ? err.message : 'Unknown error renaming' - return { error: message } - } -}) + }, +) // Reveal in Finder / file manager ipcMain.on(IpcChannels.FS_REVEAL_IN_FINDER, (_event, filePath: string) => { @@ -1446,11 +1601,7 @@ ipcMain.on(IpcChannels.FS_REVEAL_IN_FINDER, (_event, filePath: string) => { ipcMain.handle( IpcChannels.OPEN_IN_VSCODE, - async ( - _event, - rootPath: string, - files?: Array<{ path: string; line: number; col: number }>, - ) => { + async (_event, rootPath: string, files?: Array<{ path: string; line: number; col: number }>) => { const runCode = (args: string[]) => new Promise((resolve, reject) => { execFile('code', args, (err) => { @@ -1474,50 +1625,62 @@ ipcMain.handle( ) // List all files (quick open) — uses `git ls-files` for speed, falls back to recursive readdir -ipcMain.handle(IpcChannels.FS_LIST_ALL_FILES, async (_event, rootPath: string): Promise => { - // Try git ls-files first (fast, respects .gitignore) - if (existsSync(join(rootPath, '.git'))) { - try { - const files = await new Promise((resolve, reject) => { - execFile('git', ['ls-files', '--cached', '--others', '--exclude-standard'], { cwd: rootPath, maxBuffer: 10 * 1024 * 1024 }, (err, stdout) => { - if (err) return reject(err) - resolve(stdout.trim().split('\n').filter(Boolean)) +ipcMain.handle( + IpcChannels.FS_LIST_ALL_FILES, + async (_event, rootPath: string): Promise => { + // Try git ls-files first (fast, respects .gitignore) + if (existsSync(join(rootPath, '.git'))) { + try { + const files = await new Promise((resolve, reject) => { + execFile( + 'git', + ['ls-files', '--cached', '--others', '--exclude-standard'], + { cwd: rootPath, maxBuffer: 10 * 1024 * 1024 }, + (err, stdout) => { + if (err) return reject(err) + resolve(stdout.trim().split('\n').filter(Boolean)) + }, + ) }) - }) - return files - } catch { - // fall through to readdir + return files + } catch { + // fall through to readdir + } } - } - // Fallback: recursive readdir - const SKIP = new Set(['.git', 'node_modules', 'dist', 'build', '.next', 'out', '__pycache__']) - const results: string[] = [] - - /** - * Recursively traverses `dir` and appends discovered file paths (relative to `rootPath`) to the module-level `results` array. - * - * The walk skips entries whose names are in `SKIP` or that start with a dot. If `dir` cannot be read, the function returns without side effects. - * - * @param dir - The directory path to traverse - */ - function walk(dir: string) { - let entries - try { entries = readdirSync(dir, { withFileTypes: true }) } catch { return } - for (const entry of entries) { - if (SKIP.has(entry.name) || entry.name.startsWith('.')) continue - const full = join(dir, entry.name) - if (entry.isDirectory()) { - walk(full) - } else { - results.push(relative(rootPath, full)) + // Fallback: recursive readdir + const SKIP = new Set(['.git', 'node_modules', 'dist', 'build', '.next', 'out', '__pycache__']) + const results: string[] = [] + + /** + * Recursively traverses `dir` and appends discovered file paths (relative to `rootPath`) to the module-level `results` array. + * + * The walk skips entries whose names are in `SKIP` or that start with a dot. If `dir` cannot be read, the function returns without side effects. + * + * @param dir - The directory path to traverse + */ + function walk(dir: string) { + let entries + try { + entries = readdirSync(dir, { withFileTypes: true }) + } catch { + return + } + for (const entry of entries) { + if (SKIP.has(entry.name) || entry.name.startsWith('.')) continue + const full = join(dir, entry.name) + if (entry.isDirectory()) { + walk(full) + } else { + results.push(relative(rootPath, full)) + } } } - } - walk(rootPath) - return results -}) + walk(rootPath) + return results + }, +) // Search (find in files) — ripgrep-backed ipcMain.handle(IpcChannels.SEARCH_START, async (_event, opts: SearchOpts) => { @@ -1554,12 +1717,21 @@ ipcMain.handle(IpcChannels.SEARCH_REPLACE, async (_event, opts: ReplaceOpts) => for (const rep of sorted) { const lineIdx = rep.line - 1 - if (lineIdx < 0 || lineIdx >= lines.length) { skipped++; continue } + if (lineIdx < 0 || lineIdx >= lines.length) { + skipped++ + continue + } const line = lines[lineIdx] const colIdx = rep.column - 1 - if (colIdx < 0 || colIdx > line.length) { skipped++; continue } + if (colIdx < 0 || colIdx > line.length) { + skipped++ + continue + } const actual = line.slice(colIdx, colIdx + rep.matchText.length) - if (actual !== rep.matchText) { skipped++; continue } + if (actual !== rep.matchText) { + skipped++ + continue + } const before = line.slice(0, colIdx) const after = line.slice(colIdx + rep.matchText.length) lines[lineIdx] = before + rep.replaceText + after @@ -1588,12 +1760,15 @@ app.whenReady().then(async () => { buildAppMenu() createWindow() - registerPtyHandlers(() => contentView?.webContents ?? null, (workspaceId) => { - const entry = workspaceRegistry.get(workspaceId) - const root = entry?.rootPath ?? null - if (!root) return null - return getEffectiveWorkspaceRoot(workspaceId, root) - }) + registerPtyHandlers( + () => contentView?.webContents ?? null, + (workspaceId) => { + const entry = workspaceRegistry.get(workspaceId) + const root = entry?.rootPath ?? null + if (!root) return null + return getEffectiveWorkspaceRoot(workspaceId, root) + }, + ) registerFileWatcherHandlers(() => contentView?.webContents ?? null) const getWebContents = () => contentView?.webContents ?? null @@ -1652,7 +1827,9 @@ app.on('before-quit', (event) => { wc.send(IpcChannels.LIFECYCLE_REQUEST_SAVE) // Wait for renderer to confirm save, or timeout after 2s - const saveTimeout = setTimeout(() => { void finishQuit() }, 2000) + const saveTimeout = setTimeout(() => { + void finishQuit() + }, 2000) ipcMain.once(IpcChannels.LIFECYCLE_SAVE_COMPLETE, () => { clearTimeout(saveTimeout) diff --git a/packages/main/src/preload.ts b/packages/main/src/preload.ts index 16f1a42..07a884e 100644 --- a/packages/main/src/preload.ts +++ b/packages/main/src/preload.ts @@ -1,17 +1,64 @@ import { contextBridge, ipcRenderer } from 'electron' import { IpcChannels } from '@aide/shared' import type { - ThemeName, FsWatchEvent, GitStatusResult, GitignoreAuditResult, WorktreeInfo, WorktreeCreateOpts, SearchOpts, - ReplaceOpts, ResolvedSettings, - AideProjectSettings, AideInitResult, AideTask, CompoundTask, TaskExecution, TaskInputRequest, TaskRunContext, - TaskTriggerResult, WorkspaceEntry, AideLocalState, AideLocalTerminals, WindowApi, BrowserSessionMode, - BrowserHostUpdate, BrowserDidNavigatePayload, BrowserPageTitlePayload, BrowserLoadingPayload, BrowserCanNavigatePayload, - BrowserFocusPayload, ZoomCommandPayload, KeybindingRule, ChatMode, ChatSession, ChatStreamChunk, ChatStreamEnd, - ChatToolCallPayload, PendingToolApprovalInfo, McpServerStatus, ToolDefinition, AgentBackend, CliAgentStreamDelta, CliAgentMessage, CliAgentSession, - CliAgentStatusPayload, CliAgentResultPayload, CliAgentMessagePayload, ConversationMeta, ConversationCreateOpts, - ConversationListChangedPayload, GitStatusChangedPayload, GitBranchChangedPayload, WorktreeListChangedPayload, - SearchResultsPayload, SearchCompletePayload, GitignoreAuditIpcPayload, TaskDiagnosticsPayload, TaskAutoDetectPayload, - PtyDataOutPayload, PtyExitPayload, + ThemeName, + FsWatchEvent, + GitStatusResult, + GitignoreAuditResult, + WorktreeInfo, + WorktreeCreateOpts, + SearchOpts, + ReplaceOpts, + ResolvedSettings, + AideProjectSettings, + AideInitResult, + AideTask, + CompoundTask, + TaskExecution, + TaskInputRequest, + TaskRunContext, + TaskTriggerResult, + WorkspaceEntry, + AideLocalState, + AideLocalTerminals, + WindowApi, + BrowserSessionMode, + BrowserHostUpdate, + BrowserDidNavigatePayload, + BrowserPageTitlePayload, + BrowserLoadingPayload, + BrowserCanNavigatePayload, + BrowserFocusPayload, + ZoomCommandPayload, + KeybindingRule, + ChatMode, + ChatSession, + ChatStreamChunk, + ChatStreamEnd, + ChatToolCallPayload, + PendingToolApprovalInfo, + McpServerStatus, + ToolDefinition, + AgentBackend, + CliAgentStreamDelta, + CliAgentMessage, + CliAgentSession, + CliAgentStatusPayload, + CliAgentResultPayload, + CliAgentMessagePayload, + ConversationMeta, + ConversationCreateOpts, + ConversationListChangedPayload, + GitStatusChangedPayload, + GitBranchChangedPayload, + WorktreeListChangedPayload, + SearchResultsPayload, + SearchCompletePayload, + GitignoreAuditIpcPayload, + TaskDiagnosticsPayload, + TaskAutoDetectPayload, + PtyDataOutPayload, + PtyExitPayload, } from '@aide/shared' const api: WindowApi = { @@ -31,17 +78,21 @@ const api: WindowApi = { // Fullscreen onFullscreenChanged: (callback: (isFullscreen: boolean) => void) => { - const handler = (_event: Electron.IpcRendererEvent, isFullscreen: boolean) => callback(isFullscreen) + const handler = (_event: Electron.IpcRendererEvent, isFullscreen: boolean) => + callback(isFullscreen) ipcRenderer.on(IpcChannels.FULLSCREEN_CHANGED, handler) return () => ipcRenderer.removeListener(IpcChannels.FULLSCREEN_CHANGED, handler) }, // Zoom getBrowserZoom: (paneId: string) => ipcRenderer.invoke(IpcChannels.BROWSER_ZOOM_GET, paneId), - setBrowserZoom: (paneId: string, zoomFactor: number) => ipcRenderer.invoke(IpcChannels.BROWSER_ZOOM_SET, paneId, zoomFactor), - adjustBrowserZoom: (paneId: string, delta: number) => ipcRenderer.invoke(IpcChannels.BROWSER_ZOOM_ADJUST, paneId, delta), + setBrowserZoom: (paneId: string, zoomFactor: number) => + ipcRenderer.invoke(IpcChannels.BROWSER_ZOOM_SET, paneId, zoomFactor), + adjustBrowserZoom: (paneId: string, delta: number) => + ipcRenderer.invoke(IpcChannels.BROWSER_ZOOM_ADJUST, paneId, delta), onZoomCommand: (callback: (payload: ZoomCommandPayload) => void) => { - const handler = (_event: Electron.IpcRendererEvent, payload: ZoomCommandPayload) => callback(payload) + const handler = (_event: Electron.IpcRendererEvent, payload: ZoomCommandPayload) => + callback(payload) ipcRenderer.on(IpcChannels.APP_ZOOM_COMMAND, handler) return () => ipcRenderer.removeListener(IpcChannels.APP_ZOOM_COMMAND, handler) }, @@ -58,11 +109,13 @@ const api: WindowApi = { // Filesystem readDir: (dirPath: string) => ipcRenderer.invoke(IpcChannels.FS_READ_DIR, dirPath), readFile: (filePath: string) => ipcRenderer.invoke(IpcChannels.FS_READ_FILE, filePath), - writeFile: (filePath: string, content: string) => ipcRenderer.invoke(IpcChannels.FS_WRITE_FILE, filePath, content), + writeFile: (filePath: string, content: string) => + ipcRenderer.invoke(IpcChannels.FS_WRITE_FILE, filePath, content), createFile: (filePath: string) => ipcRenderer.invoke(IpcChannels.FS_CREATE_FILE, filePath), createDir: (dirPath: string) => ipcRenderer.invoke(IpcChannels.FS_CREATE_DIR, dirPath), deleteEntry: (entryPath: string) => ipcRenderer.invoke(IpcChannels.FS_DELETE, entryPath), - renameEntry: (oldPath: string, newPath: string) => ipcRenderer.invoke(IpcChannels.FS_RENAME, oldPath, newPath), + renameEntry: (oldPath: string, newPath: string) => + ipcRenderer.invoke(IpcChannels.FS_RENAME, oldPath, newPath), revealInFinder: (filePath: string) => ipcRenderer.send(IpcChannels.FS_REVEAL_IN_FINDER, filePath), // File watcher @@ -75,57 +128,71 @@ const api: WindowApi = { // Git getGitStatus: (workspaceId: string) => ipcRenderer.invoke(IpcChannels.GIT_STATUS, workspaceId), onGitStatusChanged: (callback: (payload: GitStatusChangedPayload) => void) => { - const handler = (_event: Electron.IpcRendererEvent, payload: GitStatusChangedPayload) => callback(payload) + const handler = (_event: Electron.IpcRendererEvent, payload: GitStatusChangedPayload) => + callback(payload) ipcRenderer.on(IpcChannels.GIT_STATUS_CHANGED, handler) return () => ipcRenderer.removeListener(IpcChannels.GIT_STATUS_CHANGED, handler) }, onGitBranchChanged: (callback: (payload: GitBranchChangedPayload) => void) => { - const handler = (_event: Electron.IpcRendererEvent, payload: GitBranchChangedPayload) => callback(payload) + const handler = (_event: Electron.IpcRendererEvent, payload: GitBranchChangedPayload) => + callback(payload) ipcRenderer.on(IpcChannels.GIT_BRANCH_CHANGED, handler) return () => ipcRenderer.removeListener(IpcChannels.GIT_BRANCH_CHANGED, handler) }, // Git diff - getGitFileOriginal: (rootPath: string | null, filePath: string): Promise<{ content: string | null }> => + getGitFileOriginal: ( + rootPath: string | null, + filePath: string, + ): Promise<{ content: string | null }> => ipcRenderer.invoke(IpcChannels.GIT_DIFF_ORIGINAL, rootPath, filePath), // Terminal - ptyCreate: (opts?: { id?: string; workspaceId?: string; cwd?: string; shell?: string; title?: string }) => - ipcRenderer.invoke(IpcChannels.PTY_CREATE, opts), - ptyWrite: (id: string, data: string) => - ipcRenderer.send(IpcChannels.PTY_DATA_IN, id, data), + ptyCreate: (opts?: { + id?: string + workspaceId?: string + cwd?: string + shell?: string + title?: string + }) => ipcRenderer.invoke(IpcChannels.PTY_CREATE, opts), + ptyWrite: (id: string, data: string) => ipcRenderer.send(IpcChannels.PTY_DATA_IN, id, data), ptyResize: (id: string, cols: number, rows: number) => ipcRenderer.send(IpcChannels.PTY_RESIZE, id, cols, rows), - ptyKill: (id: string) => - ipcRenderer.send(IpcChannels.PTY_KILL, id), + ptyKill: (id: string) => ipcRenderer.send(IpcChannels.PTY_KILL, id), ptyKillWorkspace: (workspaceId: string) => ipcRenderer.send(IpcChannels.PTY_KILL_WORKSPACE, workspaceId), onPtyData: (callback: (payload: PtyDataOutPayload) => void) => { - const handler = (_event: Electron.IpcRendererEvent, payload: PtyDataOutPayload) => callback(payload) + const handler = (_event: Electron.IpcRendererEvent, payload: PtyDataOutPayload) => + callback(payload) ipcRenderer.on(IpcChannels.PTY_DATA_OUT, handler) return () => ipcRenderer.removeListener(IpcChannels.PTY_DATA_OUT, handler) }, onPtyExit: (callback: (payload: PtyExitPayload) => void) => { - const handler = (_event: Electron.IpcRendererEvent, payload: PtyExitPayload) => callback(payload) + const handler = (_event: Electron.IpcRendererEvent, payload: PtyExitPayload) => + callback(payload) ipcRenderer.on(IpcChannels.PTY_EXIT, handler) return () => ipcRenderer.removeListener(IpcChannels.PTY_EXIT, handler) }, // Worktrees - listWorktrees: (workspaceId: string) => ipcRenderer.invoke(IpcChannels.WORKTREE_LIST, workspaceId), + listWorktrees: (workspaceId: string) => + ipcRenderer.invoke(IpcChannels.WORKTREE_LIST, workspaceId), createWorktree: (workspaceId: string, opts: WorktreeCreateOpts) => ipcRenderer.invoke(IpcChannels.WORKTREE_CREATE, workspaceId, opts), removeWorktree: (workspaceId: string, worktreePath: string) => ipcRenderer.invoke(IpcChannels.WORKTREE_REMOVE, workspaceId, worktreePath), setActiveWorktree: (workspaceId: string, worktreePath: string | null) => ipcRenderer.invoke(IpcChannels.WORKTREE_SET_ACTIVE, workspaceId, worktreePath), - getActiveWorktree: (workspaceId: string) => ipcRenderer.invoke(IpcChannels.WORKTREE_GET_ACTIVE, workspaceId), + getActiveWorktree: (workspaceId: string) => + ipcRenderer.invoke(IpcChannels.WORKTREE_GET_ACTIVE, workspaceId), onWorktreeListChanged: (callback: (payload: WorktreeListChangedPayload) => void) => { - const handler = (_event: Electron.IpcRendererEvent, payload: WorktreeListChangedPayload) => callback(payload) + const handler = (_event: Electron.IpcRendererEvent, payload: WorktreeListChangedPayload) => + callback(payload) ipcRenderer.on(IpcChannels.WORKTREE_LIST_CHANGED, handler) return () => ipcRenderer.removeListener(IpcChannels.WORKTREE_LIST_CHANGED, handler) }, - listBranches: (workspaceId: string) => ipcRenderer.invoke(IpcChannels.WORKTREE_LIST_BRANCHES, workspaceId), + listBranches: (workspaceId: string) => + ipcRenderer.invoke(IpcChannels.WORKTREE_LIST_BRANCHES, workspaceId), // File listing (quick open) listAllFiles: (rootPath: string) => ipcRenderer.invoke(IpcChannels.FS_LIST_ALL_FILES, rootPath), @@ -133,12 +200,14 @@ const api: WindowApi = { // Search (find in files) searchStart: (opts: SearchOpts) => ipcRenderer.invoke(IpcChannels.SEARCH_START, opts), onSearchResults: (callback: (payload: SearchResultsPayload) => void) => { - const handler = (_event: Electron.IpcRendererEvent, payload: SearchResultsPayload) => callback(payload) + const handler = (_event: Electron.IpcRendererEvent, payload: SearchResultsPayload) => + callback(payload) ipcRenderer.on(IpcChannels.SEARCH_RESULTS, handler) return () => ipcRenderer.removeListener(IpcChannels.SEARCH_RESULTS, handler) }, onSearchComplete: (callback: (payload: SearchCompletePayload) => void) => { - const handler = (_event: Electron.IpcRendererEvent, payload: SearchCompletePayload) => callback(payload) + const handler = (_event: Electron.IpcRendererEvent, payload: SearchCompletePayload) => + callback(payload) ipcRenderer.on(IpcChannels.SEARCH_COMPLETE, handler) return () => ipcRenderer.removeListener(IpcChannels.SEARCH_COMPLETE, handler) }, @@ -162,12 +231,17 @@ const api: WindowApi = { ipcRenderer.invoke(IpcChannels.SETTINGS_SET_USER, key, value), getWorkspaceSettings: (workspaceId?: string | null): Promise => ipcRenderer.invoke(IpcChannels.SETTINGS_GET_WORKSPACE, workspaceId), - setWorkspaceSetting: (key: string, value: unknown | undefined, workspaceId?: string | null): Promise => + setWorkspaceSetting: ( + key: string, + value: unknown | undefined, + workspaceId?: string | null, + ): Promise => ipcRenderer.invoke(IpcChannels.SETTINGS_SET_WORKSPACE, key, value, workspaceId), getBuiltInDefaults: (): Promise => ipcRenderer.invoke(IpcChannels.SETTINGS_GET_DEFAULTS), onSettingsChanged: (callback: (resolved: ResolvedSettings) => void) => { - const handler = (_event: Electron.IpcRendererEvent, resolved: ResolvedSettings) => callback(resolved) + const handler = (_event: Electron.IpcRendererEvent, resolved: ResolvedSettings) => + callback(resolved) ipcRenderer.on(IpcChannels.SETTINGS_CHANGED, handler) return () => ipcRenderer.removeListener(IpcChannels.SETTINGS_CHANGED, handler) }, @@ -191,7 +265,8 @@ const api: WindowApi = { dismissGitignoreAudit: (workspaceId?: string | null): Promise => ipcRenderer.invoke(IpcChannels.GITIGNORE_DISMISS, workspaceId), onGitignoreAuditResult: (callback: (payload: GitignoreAuditIpcPayload) => void) => { - const handler = (_event: Electron.IpcRendererEvent, payload: GitignoreAuditIpcPayload) => callback(payload) + const handler = (_event: Electron.IpcRendererEvent, payload: GitignoreAuditIpcPayload) => + callback(payload) ipcRenderer.on(IpcChannels.GITIGNORE_AUDIT_RESULT, handler) return () => ipcRenderer.removeListener(IpcChannels.GITIGNORE_AUDIT_RESULT, handler) }, @@ -201,7 +276,11 @@ const api: WindowApi = { ipcRenderer.invoke(IpcChannels.TASK_LIST, workspaceId), listRunningTasks: (workspaceId: string): Promise => ipcRenderer.invoke(IpcChannels.TASK_LIST_RUNNING, workspaceId), - runTask: (workspaceId: string, taskId: string, context?: TaskRunContext): Promise<{ executionId: string } | { error: string }> => + runTask: ( + workspaceId: string, + taskId: string, + context?: TaskRunContext, + ): Promise<{ executionId: string } | { error: string }> => ipcRenderer.invoke(IpcChannels.TASK_RUN, workspaceId, taskId, context), killTask: (workspaceId: string, executionId: string) => ipcRenderer.send(IpcChannels.TASK_KILL, workspaceId, executionId), @@ -211,37 +290,40 @@ const api: WindowApi = { ipcRenderer.invoke(IpcChannels.TASK_GENERATE, workspaceId), provideTaskInput: (workspaceId: string, requestId: string, value: string | null) => ipcRenderer.send(IpcChannels.TASK_PROVIDE_INPUT, workspaceId, requestId, value), - notifyFileSaved: (filePath: string) => - ipcRenderer.send(IpcChannels.TASK_FILE_SAVED, filePath), + notifyFileSaved: (filePath: string) => ipcRenderer.send(IpcChannels.TASK_FILE_SAVED, filePath), onTaskStatusChanged: (callback: (execution: TaskExecution) => void) => { - const handler = (_event: Electron.IpcRendererEvent, execution: TaskExecution) => callback(execution) + const handler = (_event: Electron.IpcRendererEvent, execution: TaskExecution) => + callback(execution) ipcRenderer.on(IpcChannels.TASK_STATUS_CHANGED, handler) return () => ipcRenderer.removeListener(IpcChannels.TASK_STATUS_CHANGED, handler) }, onTaskRequestInput: (callback: (request: TaskInputRequest) => void) => { - const handler = (_event: Electron.IpcRendererEvent, request: TaskInputRequest) => callback(request) + const handler = (_event: Electron.IpcRendererEvent, request: TaskInputRequest) => + callback(request) ipcRenderer.on(IpcChannels.TASK_REQUEST_INPUT, handler) return () => ipcRenderer.removeListener(IpcChannels.TASK_REQUEST_INPUT, handler) }, onTaskDiagnostics: (callback: (payload: TaskDiagnosticsPayload) => void) => { - const handler = (_event: Electron.IpcRendererEvent, payload: TaskDiagnosticsPayload) => callback(payload) + const handler = (_event: Electron.IpcRendererEvent, payload: TaskDiagnosticsPayload) => + callback(payload) ipcRenderer.on(IpcChannels.TASK_DIAGNOSTICS, handler) return () => ipcRenderer.removeListener(IpcChannels.TASK_DIAGNOSTICS, handler) }, onTaskAutoDetect: (callback: (payload: TaskAutoDetectPayload) => void) => { - const handler = (_event: Electron.IpcRendererEvent, payload: TaskAutoDetectPayload) => callback(payload) + const handler = (_event: Electron.IpcRendererEvent, payload: TaskAutoDetectPayload) => + callback(payload) ipcRenderer.on(IpcChannels.TASK_AUTO_DETECT, handler) return () => ipcRenderer.removeListener(IpcChannels.TASK_AUTO_DETECT, handler) }, onTaskTriggerResult: (callback: (result: TaskTriggerResult) => void) => { - const handler = (_event: Electron.IpcRendererEvent, result: TaskTriggerResult) => callback(result) + const handler = (_event: Electron.IpcRendererEvent, result: TaskTriggerResult) => + callback(result) ipcRenderer.on(IpcChannels.TASK_TRIGGER_RESULT, handler) return () => ipcRenderer.removeListener(IpcChannels.TASK_TRIGGER_RESULT, handler) }, // Workspace registry - listWorkspaces: (): Promise => - ipcRenderer.invoke(IpcChannels.WORKSPACE_LIST), + listWorkspaces: (): Promise => ipcRenderer.invoke(IpcChannels.WORKSPACE_LIST), createWorkspace: (rootPath: string): Promise => ipcRenderer.invoke(IpcChannels.WORKSPACE_CREATE, rootPath), createBlankWorkspace: (): Promise => @@ -252,8 +334,10 @@ const api: WindowApi = { ipcRenderer.invoke(IpcChannels.WORKSPACE_CLOSE, id), switchWorkspace: (id: string): Promise => ipcRenderer.invoke(IpcChannels.WORKSPACE_SWITCH, id), - updateWorkspace: (id: string, patch: Partial>): Promise => - ipcRenderer.invoke(IpcChannels.WORKSPACE_UPDATE, id, patch), + updateWorkspace: ( + id: string, + patch: Partial>, + ): Promise => ipcRenderer.invoke(IpcChannels.WORKSPACE_UPDATE, id, patch), reorderWorkspaces: (ids: string[]): Promise => ipcRenderer.invoke(IpcChannels.WORKSPACE_REORDER, ids), setWorkspaceRoot: (id: string, rootPath: string): Promise => @@ -261,16 +345,21 @@ const api: WindowApi = { getActiveWorkspaceId: (): Promise => ipcRenderer.invoke(IpcChannels.WORKSPACE_GET_ACTIVE), onWorkspaceRegistryChanged: (callback: (workspaces: WorkspaceEntry[]) => void) => { - const handler = (_event: Electron.IpcRendererEvent, workspaces: WorkspaceEntry[]) => callback(workspaces) + const handler = (_event: Electron.IpcRendererEvent, workspaces: WorkspaceEntry[]) => + callback(workspaces) ipcRenderer.on(IpcChannels.WORKSPACE_REGISTRY_CHANGED, handler) return () => ipcRenderer.removeListener(IpcChannels.WORKSPACE_REGISTRY_CHANGED, handler) }, getWorkspaceRuntimeSnapshots: () => ipcRenderer.invoke(IpcChannels.WORKSPACE_RUNTIME_SNAPSHOTS_GET), onWorkspaceRuntimeSnapshotsChanged: (callback) => { - const handler = (_event: Electron.IpcRendererEvent, snapshots: import('@aide/shared').WorkspaceRuntimeSnapshot[]) => callback(snapshots) + const handler = ( + _event: Electron.IpcRendererEvent, + snapshots: import('@aide/shared').WorkspaceRuntimeSnapshot[], + ) => callback(snapshots) ipcRenderer.on(IpcChannels.WORKSPACE_RUNTIME_SNAPSHOTS_CHANGED, handler) - return () => ipcRenderer.removeListener(IpcChannels.WORKSPACE_RUNTIME_SNAPSHOTS_CHANGED, handler) + return () => + ipcRenderer.removeListener(IpcChannels.WORKSPACE_RUNTIME_SNAPSHOTS_CHANGED, handler) }, // State persistence @@ -286,46 +375,45 @@ const api: WindowApi = { // Browser panes browserCreate: (paneId: string, workspaceId: string, sessionMode: BrowserSessionMode) => ipcRenderer.invoke(IpcChannels.BROWSER_CREATE, paneId, workspaceId, sessionMode), - browserDestroy: (paneId: string) => - ipcRenderer.send(IpcChannels.BROWSER_DESTROY, paneId), + browserDestroy: (paneId: string) => ipcRenderer.send(IpcChannels.BROWSER_DESTROY, paneId), browserDestroyWorkspace: (workspaceId: string) => ipcRenderer.send(IpcChannels.BROWSER_DESTROY_WORKSPACE, workspaceId), browserNavigate: (paneId: string, url: string) => ipcRenderer.invoke(IpcChannels.BROWSER_NAVIGATE, paneId, url), - browserGoBack: (paneId: string) => - ipcRenderer.send(IpcChannels.BROWSER_GO_BACK, paneId), - browserGoForward: (paneId: string) => - ipcRenderer.send(IpcChannels.BROWSER_GO_FORWARD, paneId), - browserReload: (paneId: string) => - ipcRenderer.send(IpcChannels.BROWSER_RELOAD, paneId), + browserGoBack: (paneId: string) => ipcRenderer.send(IpcChannels.BROWSER_GO_BACK, paneId), + browserGoForward: (paneId: string) => ipcRenderer.send(IpcChannels.BROWSER_GO_FORWARD, paneId), + browserReload: (paneId: string) => ipcRenderer.send(IpcChannels.BROWSER_RELOAD, paneId), browserHostUpdate: (update: BrowserHostUpdate) => ipcRenderer.send(IpcChannels.BROWSER_HOST_UPDATE, update), - browserSuppressOverlays: () => - ipcRenderer.send(IpcChannels.BROWSER_SUPPRESS_OVERLAYS), - browserUnsuppressOverlays: () => - ipcRenderer.send(IpcChannels.BROWSER_UNSUPPRESS_OVERLAYS), + browserSuppressOverlays: () => ipcRenderer.send(IpcChannels.BROWSER_SUPPRESS_OVERLAYS), + browserUnsuppressOverlays: () => ipcRenderer.send(IpcChannels.BROWSER_UNSUPPRESS_OVERLAYS), onBrowserDidNavigate: (callback: (payload: BrowserDidNavigatePayload) => void) => { - const handler = (_event: Electron.IpcRendererEvent, payload: BrowserDidNavigatePayload) => callback(payload) + const handler = (_event: Electron.IpcRendererEvent, payload: BrowserDidNavigatePayload) => + callback(payload) ipcRenderer.on(IpcChannels.BROWSER_DID_NAVIGATE, handler) return () => ipcRenderer.removeListener(IpcChannels.BROWSER_DID_NAVIGATE, handler) }, onBrowserTitleUpdated: (callback: (payload: BrowserPageTitlePayload) => void) => { - const handler = (_event: Electron.IpcRendererEvent, payload: BrowserPageTitlePayload) => callback(payload) + const handler = (_event: Electron.IpcRendererEvent, payload: BrowserPageTitlePayload) => + callback(payload) ipcRenderer.on(IpcChannels.BROWSER_PAGE_TITLE_UPDATED, handler) return () => ipcRenderer.removeListener(IpcChannels.BROWSER_PAGE_TITLE_UPDATED, handler) }, onBrowserLoadingChanged: (callback: (payload: BrowserLoadingPayload) => void) => { - const handler = (_event: Electron.IpcRendererEvent, payload: BrowserLoadingPayload) => callback(payload) + const handler = (_event: Electron.IpcRendererEvent, payload: BrowserLoadingPayload) => + callback(payload) ipcRenderer.on(IpcChannels.BROWSER_LOADING_CHANGED, handler) return () => ipcRenderer.removeListener(IpcChannels.BROWSER_LOADING_CHANGED, handler) }, onBrowserCanNavigateChanged: (callback: (payload: BrowserCanNavigatePayload) => void) => { - const handler = (_event: Electron.IpcRendererEvent, payload: BrowserCanNavigatePayload) => callback(payload) + const handler = (_event: Electron.IpcRendererEvent, payload: BrowserCanNavigatePayload) => + callback(payload) ipcRenderer.on(IpcChannels.BROWSER_CAN_NAVIGATE_CHANGED, handler) return () => ipcRenderer.removeListener(IpcChannels.BROWSER_CAN_NAVIGATE_CHANGED, handler) }, onBrowserFocusChanged: (callback: (payload: BrowserFocusPayload) => void) => { - const handler = (_event: Electron.IpcRendererEvent, payload: BrowserFocusPayload) => callback(payload) + const handler = (_event: Electron.IpcRendererEvent, payload: BrowserFocusPayload) => + callback(payload) ipcRenderer.on(IpcChannels.BROWSER_FOCUS_CHANGED, handler) return () => ipcRenderer.removeListener(IpcChannels.BROWSER_FOCUS_CHANGED, handler) }, @@ -336,8 +424,7 @@ const api: WindowApi = { ipcRenderer.on(IpcChannels.LIFECYCLE_REQUEST_SAVE, handler) return () => ipcRenderer.removeListener(IpcChannels.LIFECYCLE_REQUEST_SAVE, handler) }, - lifecycleSaveComplete: () => - ipcRenderer.send(IpcChannels.LIFECYCLE_SAVE_COMPLETE), + lifecycleSaveComplete: () => ipcRenderer.send(IpcChannels.LIFECYCLE_SAVE_COMPLETE), onCrashDetected: (callback: () => void) => { const handler = () => callback() ipcRenderer.on(IpcChannels.LIFECYCLE_CRASH_DETECTED, handler) @@ -357,8 +444,7 @@ const api: WindowApi = { ipcRenderer.invoke(IpcChannels.CHAT_TOOL_APPROVE, sessionId, toolCallId), chatToolReject: (sessionId: string, toolCallId: string): Promise => ipcRenderer.invoke(IpcChannels.CHAT_TOOL_REJECT, sessionId, toolCallId), - chatStop: (sessionId: string) => - ipcRenderer.send(IpcChannels.CHAT_STOP, sessionId), + chatStop: (sessionId: string) => ipcRenderer.send(IpcChannels.CHAT_STOP, sessionId), onChatStreamChunk: (callback: (chunk: ChatStreamChunk) => void) => { const handler = (_event: Electron.IpcRendererEvent, chunk: ChatStreamChunk) => callback(chunk) ipcRenderer.on(IpcChannels.CHAT_STREAM_CHUNK, handler) @@ -370,7 +456,8 @@ const api: WindowApi = { return () => ipcRenderer.removeListener(IpcChannels.CHAT_STREAM_END, handler) }, onChatToolCall: (callback: (payload: ChatToolCallPayload) => void) => { - const handler = (_event: Electron.IpcRendererEvent, payload: ChatToolCallPayload) => callback(payload) + const handler = (_event: Electron.IpcRendererEvent, payload: ChatToolCallPayload) => + callback(payload) ipcRenderer.on(IpcChannels.CHAT_TOOL_CALL, handler) return () => ipcRenderer.removeListener(IpcChannels.CHAT_TOOL_CALL, handler) }, @@ -382,8 +469,7 @@ const api: WindowApi = { ipcRenderer.invoke(IpcChannels.MCP_LIST_SERVERS), mcpRestartServer: (serverName: string) => ipcRenderer.invoke(IpcChannels.MCP_RESTART_SERVER, serverName), - mcpListTools: (): Promise => - ipcRenderer.invoke(IpcChannels.MCP_LIST_TOOLS), + mcpListTools: (): Promise => ipcRenderer.invoke(IpcChannels.MCP_LIST_TOOLS), onMcpServerStatus: (callback: (status: McpServerStatus) => void) => { const handler = (_event: Electron.IpcRendererEvent, status: McpServerStatus) => callback(status) ipcRenderer.on(IpcChannels.MCP_SERVER_STATUS, handler) @@ -391,10 +477,22 @@ const api: WindowApi = { }, // ─── CLI Agent ─────────────────────────────── - cliAgentStart: (workspaceId: string, backend: AgentBackend, conversationId?: string, worktreePath?: string) => - ipcRenderer.invoke(IpcChannels.CLI_AGENT_START, workspaceId, backend, conversationId, worktreePath), - cliAgentStop: (sessionId: string) => - ipcRenderer.send(IpcChannels.CLI_AGENT_STOP, sessionId), + cliAgentStart: ( + workspaceId: string, + backend: AgentBackend, + conversationId?: string, + worktreePath?: string, + ) => + ipcRenderer.invoke( + IpcChannels.CLI_AGENT_START, + workspaceId, + backend, + conversationId, + worktreePath, + ), + cliAgentSwitchBackend: (sessionId: string, backend: AgentBackend) => + ipcRenderer.invoke(IpcChannels.CLI_AGENT_SWITCH_BACKEND, sessionId, backend), + cliAgentStop: (sessionId: string) => ipcRenderer.send(IpcChannels.CLI_AGENT_STOP, sessionId), cliAgentSend: (sessionId: string, content: string) => ipcRenderer.invoke(IpcChannels.CLI_AGENT_SEND, sessionId, content), cliAgentGetSession: (workspaceId: string, sessionId?: string): Promise => @@ -402,22 +500,26 @@ const api: WindowApi = { cliAgentLoadMessages: (workspaceId: string, conversationId: string): Promise => ipcRenderer.invoke(IpcChannels.CLI_AGENT_LOAD_MESSAGES, workspaceId, conversationId), onCliAgentStreamDelta: (callback: (delta: CliAgentStreamDelta) => void) => { - const handler = (_event: Electron.IpcRendererEvent, delta: CliAgentStreamDelta) => callback(delta) + const handler = (_event: Electron.IpcRendererEvent, delta: CliAgentStreamDelta) => + callback(delta) ipcRenderer.on(IpcChannels.CLI_AGENT_STREAM_DELTA, handler) return () => ipcRenderer.removeListener(IpcChannels.CLI_AGENT_STREAM_DELTA, handler) }, onCliAgentMessage: (callback: (msg: CliAgentMessagePayload) => void) => { - const handler = (_event: Electron.IpcRendererEvent, msg: CliAgentMessagePayload) => callback(msg) + const handler = (_event: Electron.IpcRendererEvent, msg: CliAgentMessagePayload) => + callback(msg) ipcRenderer.on(IpcChannels.CLI_AGENT_MESSAGE, handler) return () => ipcRenderer.removeListener(IpcChannels.CLI_AGENT_MESSAGE, handler) }, onCliAgentStatus: (callback: (status: CliAgentStatusPayload) => void) => { - const handler = (_event: Electron.IpcRendererEvent, status: CliAgentStatusPayload) => callback(status) + const handler = (_event: Electron.IpcRendererEvent, status: CliAgentStatusPayload) => + callback(status) ipcRenderer.on(IpcChannels.CLI_AGENT_STATUS, handler) return () => ipcRenderer.removeListener(IpcChannels.CLI_AGENT_STATUS, handler) }, onCliAgentResult: (callback: (result: CliAgentResultPayload) => void) => { - const handler = (_event: Electron.IpcRendererEvent, result: CliAgentResultPayload) => callback(result) + const handler = (_event: Electron.IpcRendererEvent, result: CliAgentResultPayload) => + callback(result) ipcRenderer.on(IpcChannels.CLI_AGENT_RESULT, handler) return () => ipcRenderer.removeListener(IpcChannels.CLI_AGENT_RESULT, handler) }, @@ -431,19 +533,20 @@ const api: WindowApi = { ipcRenderer.invoke(IpcChannels.CONVERSATION_DELETE, workspaceId, conversationId), conversationRename: (workspaceId: string, conversationId: string, title: string): Promise => ipcRenderer.invoke(IpcChannels.CONVERSATION_RENAME, workspaceId, conversationId, title), - conversationGet: (workspaceId: string, conversationId: string): Promise => + conversationGet: ( + workspaceId: string, + conversationId: string, + ): Promise => ipcRenderer.invoke(IpcChannels.CONVERSATION_GET, workspaceId, conversationId), onConversationListChanged: (callback: (payload: ConversationListChangedPayload) => void) => { - const handler = (_event: Electron.IpcRendererEvent, payload: ConversationListChangedPayload) => callback(payload) + const handler = (_event: Electron.IpcRendererEvent, payload: ConversationListChangedPayload) => + callback(payload) ipcRenderer.on(IpcChannels.CONVERSATION_LIST_CHANGED, handler) return () => ipcRenderer.removeListener(IpcChannels.CONVERSATION_LIST_CHANGED, handler) }, // Open in VS Code - openInVSCode: ( - rootPath: string, - files?: Array<{ path: string; line: number; col: number }>, - ) => + openInVSCode: (rootPath: string, files?: Array<{ path: string; line: number; col: number }>) => ipcRenderer.invoke(IpcChannels.OPEN_IN_VSCODE, rootPath, files), // Platform info (for conditional UI like traffic lights) diff --git a/packages/main/src/workspace/settingsResolver.ts b/packages/main/src/workspace/settingsResolver.ts index f3c22e7..7ea39d6 100644 --- a/packages/main/src/workspace/settingsResolver.ts +++ b/packages/main/src/workspace/settingsResolver.ts @@ -11,7 +11,14 @@ import { existsSync } from 'fs' import { readFile } from 'fs/promises' import { join } from 'path' import type Store from 'electron-store' -import type { AppSettings, AideProjectSettings, ResolvedSettings, PermissionTier, ToolPermissionConfig, AgentBackend } from '@aide/shared' +import type { + AppSettings, + AideProjectSettings, + ResolvedSettings, + PermissionTier, + ToolPermissionConfig, + AgentBackend, +} from '@aide/shared' export const BUILT_IN_DEFAULTS: ResolvedSettings = { tabSize: 2, @@ -39,6 +46,7 @@ export const BUILT_IN_DEFAULTS: ResolvedSettings = { // Agent / Backend defaults 'agent.backend': 'built-in' as AgentBackend, 'agent.claudeCodePath': '', + 'agent.opencodePath': '', 'agent.codexPath': '', } @@ -48,9 +56,7 @@ export const BUILT_IN_DEFAULTS: ResolvedSettings = { * @param store - Electron store used to read the saved `editorDefaults` entry * @returns A ResolvedSettings object where each scalar setting is the user value if present, otherwise the built-in default; `filesExclude` and `searchExclude` are shallow-merged so user entries override built-in entries */ -export function resolveAppDefaults( - store: Store, -): ResolvedSettings { +export function resolveAppDefaults(store: Store): ResolvedSettings { const userDefaults = (store.get('editorDefaults') ?? {}) as Partial return { @@ -79,7 +85,8 @@ export function resolveAppDefaults( 'agent.maxTokens': userDefaults['agent.maxTokens'] ?? BUILT_IN_DEFAULTS['agent.maxTokens'], // Agent / Permissions - 'agent.permissionTier': userDefaults['agent.permissionTier'] ?? BUILT_IN_DEFAULTS['agent.permissionTier'], + 'agent.permissionTier': + userDefaults['agent.permissionTier'] ?? BUILT_IN_DEFAULTS['agent.permissionTier'], 'agent.autoApprove': { ...BUILT_IN_DEFAULTS['agent.autoApprove'], ...(userDefaults['agent.autoApprove'] ?? {}), @@ -87,7 +94,10 @@ export function resolveAppDefaults( // Agent / Backend 'agent.backend': userDefaults['agent.backend'] ?? BUILT_IN_DEFAULTS['agent.backend'], - 'agent.claudeCodePath': userDefaults['agent.claudeCodePath'] ?? BUILT_IN_DEFAULTS['agent.claudeCodePath'], + 'agent.claudeCodePath': + userDefaults['agent.claudeCodePath'] ?? BUILT_IN_DEFAULTS['agent.claudeCodePath'], + 'agent.opencodePath': + userDefaults['agent.opencodePath'] ?? BUILT_IN_DEFAULTS['agent.opencodePath'], 'agent.codexPath': userDefaults['agent.codexPath'] ?? BUILT_IN_DEFAULTS['agent.codexPath'], } } @@ -158,6 +168,7 @@ export async function resolveSettings( // Agent / Backend (user-only — executable paths must not come from untrusted repos) 'agent.backend': appDefaults['agent.backend'], 'agent.claudeCodePath': appDefaults['agent.claudeCodePath'], + 'agent.opencodePath': appDefaults['agent.opencodePath'], 'agent.codexPath': appDefaults['agent.codexPath'], } } diff --git a/packages/shared/src/cliAgentTypes.ts b/packages/shared/src/cliAgentTypes.ts index b64bb43..a327899 100644 --- a/packages/shared/src/cliAgentTypes.ts +++ b/packages/shared/src/cliAgentTypes.ts @@ -1,7 +1,7 @@ /** * CLI Agent Wrappers — shared types. * - * Types for wrapping external CLI agents (Claude Code, Codex) as monitored + * Types for wrapping external CLI agents (Claude Code, OpenCode, Codex) as monitored * sessions inside the IDE. These are consumed by both main and renderer. */ @@ -9,19 +9,27 @@ // Agent backend selection // --------------------------------------------------------------------------- -export type AgentBackend = 'built-in' | 'claude-code' | 'codex' +export type AgentBackend = 'built-in' | 'claude-code' | 'opencode' | 'codex' +export type ExternalCliBackend = Exclude + +export interface CliAgentBackendState { + sessionId?: string + model?: string +} + +export type CliAgentBackendStateMap = Partial> // --------------------------------------------------------------------------- // Process lifecycle // --------------------------------------------------------------------------- export type CliAgentProcessStatus = - | 'stopped' // not running - | 'starting' // spawn called, waiting for init event - | 'running' // active and responsive - | 'rate_limited' // temporarily throttled - | 'error' // crashed or errored - | 'stopping' // SIGTERM sent, waiting for exit + | 'stopped' // not running + | 'starting' // spawn called, waiting for init event + | 'running' // active and responsive + | 'rate_limited' // temporarily throttled + | 'error' // crashed or errored + | 'stopping' // SIGTERM sent, waiting for exit // --------------------------------------------------------------------------- // Normalized messages @@ -33,6 +41,7 @@ export interface CliAgentMessage { type: 'system' | 'assistant' | 'user' | 'tool_use' | 'tool_result' | 'status' | 'error' | 'result' content: string timestamp: number + backend?: ExternalCliBackend /** Original SDK event for debugging */ raw?: unknown diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index d7a9b50..f9fe997 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -5,40 +5,91 @@ export type { CommandDefinition, KeybindingRule } from './commands' export type { - ChatMode, ChatSessionStatus, ToolCallStatus, - ToolCall, ToolResult, ChatMessage, ChatSession, - ChatStreamChunk, ChatStreamEnd, ChatToolCallPayload, PendingToolApprovalInfo, + ChatMode, + ChatSessionStatus, + ToolCallStatus, + ToolCall, + ToolResult, + ChatMessage, + ChatSession, + ChatStreamChunk, + ChatStreamEnd, + ChatToolCallPayload, + PendingToolApprovalInfo, ToolDefinition, - McpServerConfig, McpServerConnectionStatus, McpServerStatus, - PermissionTier, ToolPermissionConfig, AgentPermissionSettings, + McpServerConfig, + McpServerConnectionStatus, + McpServerStatus, + PermissionTier, + ToolPermissionConfig, + AgentPermissionSettings, LlmProviderConfig, } from './agentTypes' export type { - AgentBackend, CliAgentProcessStatus, - CliAgentMessage, CliAgentStreamDelta, CliAgentSession, - CliAgentStatusPayload, CliAgentResultPayload, CliAgentMessagePayload, + AgentBackend, + ExternalCliBackend, + CliAgentBackendState, + CliAgentBackendStateMap, + CliAgentProcessStatus, + CliAgentMessage, + CliAgentStreamDelta, + CliAgentSession, + CliAgentStatusPayload, + CliAgentResultPayload, + CliAgentMessagePayload, } from './cliAgentTypes' export type { - ConversationMeta, ConversationCreateOpts, ConversationListChangedPayload, + ConversationMeta, + ConversationCreateOpts, + ConversationListChangedPayload, } from './conversationTypes' export { deriveTitle } from './conversationTypes' export type { - LlmMessage, LlmContentBlock, LlmToolDefinition, LlmUsage, - LlmStreamEvent, StreamParams, LlmProvider, SseEvent, - AnthropicRequest, AnthropicMessage, AnthropicContentBlock, AnthropicTool, AnthropicStreamEvent, - OpenAiRequest, OpenAiMessage, OpenAiToolCall, OpenAiTool, OpenAiStreamChunk, OpenAiStreamToolCall, + LlmMessage, + LlmContentBlock, + LlmToolDefinition, + LlmUsage, + LlmStreamEvent, + StreamParams, + LlmProvider, + SseEvent, + AnthropicRequest, + AnthropicMessage, + AnthropicContentBlock, + AnthropicTool, + AnthropicStreamEvent, + OpenAiRequest, + OpenAiMessage, + OpenAiToolCall, + OpenAiTool, + OpenAiStreamChunk, + OpenAiStreamToolCall, } from './llmTypes' import type { - ChatMode, ChatSession, ChatStreamChunk, ChatStreamEnd, ChatToolCallPayload, PendingToolApprovalInfo, - McpServerStatus, ToolDefinition, - PermissionTier, ToolPermissionConfig, + ChatMode, + ChatSession, + ChatStreamChunk, + ChatStreamEnd, + ChatToolCallPayload, + PendingToolApprovalInfo, + McpServerStatus, + ToolDefinition, + PermissionTier, + ToolPermissionConfig, } from './agentTypes' import type { - AgentBackend, CliAgentStreamDelta, CliAgentMessage, - CliAgentSession, CliAgentStatusPayload, CliAgentResultPayload, CliAgentMessagePayload, + AgentBackend, + CliAgentStreamDelta, + CliAgentMessage, + CliAgentSession, + CliAgentStatusPayload, + CliAgentResultPayload, + CliAgentMessagePayload, } from './cliAgentTypes' import type { - ConversationMeta, ConversationCreateOpts, ConversationListChangedPayload, + ConversationMeta, + ConversationCreateOpts, + ConversationListChangedPayload, } from './conversationTypes' export { adjustZoomFactor, @@ -236,6 +287,7 @@ export const IpcChannels = { // ─── CLI Agent ─────────────────────────────── CLI_AGENT_START: 'cli-agent:start', + CLI_AGENT_SWITCH_BACKEND: 'cli-agent:switch-backend', CLI_AGENT_STOP: 'cli-agent:stop', CLI_AGENT_SEND: 'cli-agent:send', CLI_AGENT_GET_SESSION: 'cli-agent:get-session', @@ -258,7 +310,6 @@ export type ThemeName = 'one-dark' | 'one-light' export type SettingsScope = 'user' | 'workspace' - export interface DirEntry { name: string path: string @@ -399,6 +450,7 @@ export interface AideProjectSettings { // Agent / Backend settings 'agent.backend'?: AgentBackend 'agent.claudeCodePath'?: string + 'agent.opencodePath'?: string 'agent.codexPath'?: string } @@ -429,6 +481,7 @@ export interface ResolvedSettings { // Agent / Backend settings 'agent.backend': AgentBackend 'agent.claudeCodePath': string + 'agent.opencodePath': string 'agent.codexPath': string } @@ -443,6 +496,7 @@ export const SENSITIVE_AGENT_KEYS: ReadonlySet = new Set([ 'agent.baseUrl', 'agent.backend', 'agent.claudeCodePath', + 'agent.opencodePath', 'agent.codexPath', 'agent.permissionTier', 'agent.autoApprove', @@ -496,18 +550,9 @@ export interface AppWorkspaceRegistry { lastSessionWorkspaces: string[] } -export type WorkspaceRuntimeState = - | 'foreground' - | 'backgrounded' - | 'asleep' - | 'blocked' +export type WorkspaceRuntimeState = 'foreground' | 'backgrounded' | 'asleep' | 'blocked' -export type WorkspaceRuntimeStatus = - | 'starting' - | 'running' - | 'stopping' - | 'stopped' - | 'error' +export type WorkspaceRuntimeStatus = 'starting' | 'running' | 'stopping' | 'stopped' | 'error' export interface WorkspaceRuntimeWorkloadFlags { agentsRunning: boolean @@ -830,10 +875,19 @@ export interface WindowApi { onGitBranchChanged: (callback: (payload: GitBranchChangedPayload) => void) => () => void // Git diff - getGitFileOriginal: (rootPath: string | null, filePath: string) => Promise<{ content: string | null }> + getGitFileOriginal: ( + rootPath: string | null, + filePath: string, + ) => Promise<{ content: string | null }> // Terminal - ptyCreate: (opts?: { id?: string; workspaceId?: string; cwd?: string; shell?: string; title?: string }) => Promise<{ id: string; scrollback: string }> + ptyCreate: (opts?: { + id?: string + workspaceId?: string + cwd?: string + shell?: string + title?: string + }) => Promise<{ id: string; scrollback: string }> ptyWrite: (id: string, data: string) => void ptyResize: (id: string, cols: number, rows: number) => void ptyKill: (id: string) => void @@ -843,8 +897,14 @@ export interface WindowApi { // Worktrees listWorktrees: (workspaceId: string) => Promise - createWorktree: (workspaceId: string, opts: WorktreeCreateOpts) => Promise<{ path: string } | { error: string }> - removeWorktree: (workspaceId: string, worktreePath: string) => Promise<{ success: true } | { error: string }> + createWorktree: ( + workspaceId: string, + opts: WorktreeCreateOpts, + ) => Promise<{ path: string } | { error: string }> + removeWorktree: ( + workspaceId: string, + worktreePath: string, + ) => Promise<{ success: true } | { error: string }> setActiveWorktree: (workspaceId: string, worktreePath: string | null) => Promise getActiveWorktree: (workspaceId: string) => Promise onWorktreeListChanged: (callback: (payload: WorktreeListChangedPayload) => void) => () => void @@ -858,7 +918,9 @@ export interface WindowApi { onSearchResults: (callback: (payload: SearchResultsPayload) => void) => () => void onSearchComplete: (callback: (payload: SearchCompletePayload) => void) => () => void searchCancel: () => void - searchReplace: (opts: ReplaceOpts) => Promise<{ success: true; skipped: number } | { error: string }> + searchReplace: ( + opts: ReplaceOpts, + ) => Promise<{ success: true; skipped: number } | { error: string }> // .aide project folder aideInit: (workspaceId?: string | null) => Promise @@ -869,14 +931,20 @@ export interface WindowApi { getUserSettings: () => Promise> setUserSetting: (key: string, value: unknown | undefined) => Promise getWorkspaceSettings: (workspaceId?: string | null) => Promise - setWorkspaceSetting: (key: string, value: unknown | undefined, workspaceId?: string | null) => Promise + setWorkspaceSetting: ( + key: string, + value: unknown | undefined, + workspaceId?: string | null, + ) => Promise getBuiltInDefaults: () => Promise onSettingsChanged: (callback: (resolved: ResolvedSettings) => void) => () => void // Keybinding overrides getKeybindingOverrides: () => Promise setKeybindingOverrides: (rules: import('./commands').KeybindingRule[]) => Promise - onKeybindingsChanged: (callback: (rules: import('./commands').KeybindingRule[]) => void) => () => void + onKeybindingsChanged: ( + callback: (rules: import('./commands').KeybindingRule[]) => void, + ) => () => void // Gitignore security audit auditGitignore: (workspaceId?: string | null) => Promise @@ -887,7 +955,11 @@ export interface WindowApi { // Task system listTasks: (workspaceId: string) => Promise<{ tasks: AideTask[]; compounds: CompoundTask[] }> listRunningTasks: (workspaceId: string) => Promise - runTask: (workspaceId: string, taskId: string, context?: TaskRunContext) => Promise<{ executionId: string } | { error: string }> + runTask: ( + workspaceId: string, + taskId: string, + context?: TaskRunContext, + ) => Promise<{ executionId: string } | { error: string }> killTask: (workspaceId: string, executionId: string) => void reloadTasks: (workspaceId: string) => Promise generateTasks: (workspaceId: string) => Promise<{ success: true } | { error: string }> @@ -906,13 +978,18 @@ export interface WindowApi { removeWorkspace: (id: string) => Promise closeWorkspace: (id: string) => Promise switchWorkspace: (id: string) => Promise - updateWorkspace: (id: string, patch: Partial>) => Promise + updateWorkspace: ( + id: string, + patch: Partial>, + ) => Promise reorderWorkspaces: (ids: string[]) => Promise setWorkspaceRoot: (id: string, rootPath: string) => Promise getActiveWorkspaceId: () => Promise onWorkspaceRegistryChanged: (callback: (workspaces: WorkspaceEntry[]) => void) => () => void getWorkspaceRuntimeSnapshots: () => Promise - onWorkspaceRuntimeSnapshotsChanged: (callback: (snapshots: WorkspaceRuntimeSnapshot[]) => void) => () => void + onWorkspaceRuntimeSnapshotsChanged: ( + callback: (snapshots: WorkspaceRuntimeSnapshot[]) => void, + ) => () => void // State persistence saveWorkspaceState: (rootPath: string, state: AideLocalState) => Promise @@ -921,10 +998,17 @@ export interface WindowApi { loadTerminalState: (rootPath: string) => Promise // Browser panes - browserCreate: (paneId: string, workspaceId: string, sessionMode: BrowserSessionMode) => Promise<{ success: true } | { error: string }> + browserCreate: ( + paneId: string, + workspaceId: string, + sessionMode: BrowserSessionMode, + ) => Promise<{ success: true } | { error: string }> browserDestroy: (paneId: string) => void browserDestroyWorkspace: (workspaceId: string) => void - browserNavigate: (paneId: string, url: string) => Promise<{ success: true; url: string } | { error: string }> + browserNavigate: ( + paneId: string, + url: string, + ) => Promise<{ success: true; url: string } | { error: string }> browserGoBack: (paneId: string) => void browserGoForward: (paneId: string) => void browserReload: (paneId: string) => void @@ -934,7 +1018,9 @@ export interface WindowApi { onBrowserDidNavigate: (callback: (payload: BrowserDidNavigatePayload) => void) => () => void onBrowserTitleUpdated: (callback: (payload: BrowserPageTitlePayload) => void) => () => void onBrowserLoadingChanged: (callback: (payload: BrowserLoadingPayload) => void) => () => void - onBrowserCanNavigateChanged: (callback: (payload: BrowserCanNavigatePayload) => void) => () => void + onBrowserCanNavigateChanged: ( + callback: (payload: BrowserCanNavigatePayload) => void, + ) => () => void onBrowserFocusChanged: (callback: (payload: BrowserFocusPayload) => void) => () => void // App lifecycle @@ -943,7 +1029,10 @@ export interface WindowApi { onCrashDetected: (callback: () => void) => () => void // ─── Agent Chat ─────────────────────────────── - chatSendMessage: (sessionId: string, content: string) => Promise<{ messageId: string } | { error: string }> + chatSendMessage: ( + sessionId: string, + content: string, + ) => Promise<{ messageId: string } | { error: string }> chatGetHistory: (workspaceId: string, conversationId?: string) => Promise chatSetMode: (sessionId: string, mode: ChatMode) => Promise chatSetWorkingSet: (sessionId: string, paths: string[]) => Promise @@ -962,9 +1051,21 @@ export interface WindowApi { onMcpServerStatus: (callback: (status: McpServerStatus) => void) => () => void // ─── CLI Agent ─────────────────────────────── - cliAgentStart: (workspaceId: string, backend: AgentBackend, conversationId?: string, worktreePath?: string) => Promise<{ sessionId: string } | { error: string }> + cliAgentStart: ( + workspaceId: string, + backend: AgentBackend, + conversationId?: string, + worktreePath?: string, + ) => Promise<{ sessionId: string } | { error: string }> + cliAgentSwitchBackend: ( + sessionId: string, + backend: AgentBackend, + ) => Promise<{ success: true } | { error: string }> cliAgentStop: (sessionId: string) => void - cliAgentSend: (sessionId: string, content: string) => Promise<{ success: true } | { error: string }> + cliAgentSend: ( + sessionId: string, + content: string, + ) => Promise<{ success: true } | { error: string }> cliAgentGetSession: (workspaceId: string, sessionId?: string) => Promise cliAgentLoadMessages: (workspaceId: string, conversationId: string) => Promise onCliAgentStreamDelta: (callback: (delta: CliAgentStreamDelta) => void) => () => void @@ -978,7 +1079,9 @@ export interface WindowApi { conversationDelete: (workspaceId: string, conversationId: string) => Promise conversationRename: (workspaceId: string, conversationId: string, title: string) => Promise conversationGet: (workspaceId: string, conversationId: string) => Promise - onConversationListChanged: (callback: (payload: ConversationListChangedPayload) => void) => () => void + onConversationListChanged: ( + callback: (payload: ConversationListChangedPayload) => void, + ) => () => void // VS Code Integration openInVSCode: ( diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 597aa43..5563831 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -14,6 +14,9 @@ importers: '@anthropic-ai/claude-code': specifier: '*' version: 2.1.81 + '@opencode-ai/sdk': + specifier: ^1.4.0 + version: 1.4.0 '@vscode/ripgrep': specifier: ^1.17.1 version: 1.17.1 @@ -102,6 +105,9 @@ importers: '@anthropic-ai/claude-agent-sdk': specifier: ^0.2.91 version: 0.2.91(zod@4.3.6) + '@opencode-ai/sdk': + specifier: ^1.4.0 + version: 1.4.0 electron-store: specifier: ^6.0.1 version: 6.0.1 @@ -854,6 +860,9 @@ packages: engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0} deprecated: This functionality has been moved to @npmcli/fs + '@opencode-ai/sdk@1.4.0': + resolution: {integrity: sha512-mfa3MzhqNM+Az4bgPDDXL3NdG+aYOHClXmT6/4qLxf2ulyfPpMNHqb9Dfmo4D8UfmrDsPuJHmbune73/nUQnuw==} + '@pkgjs/parseargs@0.11.0': resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} engines: {node: '>=14'} @@ -4640,6 +4649,10 @@ snapshots: mkdirp: 1.0.4 rimraf: 3.0.2 + '@opencode-ai/sdk@1.4.0': + dependencies: + cross-spawn: 7.0.6 + '@pkgjs/parseargs@0.11.0': optional: true diff --git a/tests/unit/cliAgentManager.test.ts b/tests/unit/cliAgentManager.test.ts index 666da7d..87e0f5e 100644 --- a/tests/unit/cliAgentManager.test.ts +++ b/tests/unit/cliAgentManager.test.ts @@ -3,15 +3,19 @@ import type { CliAgentMessage } from '@aide/shared' const mocks = vi.hoisted(() => ({ execFileSync: vi.fn(), + spawn: vi.fn(), existsSync: vi.fn(), query: vi.fn(), + createOpencodeClient: vi.fn(), })) vi.mock('child_process', () => ({ default: { execFileSync: mocks.execFileSync, + spawn: mocks.spawn, }, execFileSync: mocks.execFileSync, + spawn: mocks.spawn, })) vi.mock('fs', () => ({ @@ -32,6 +36,10 @@ vi.mock('@anthropic-ai/claude-agent-sdk', () => ({ query: mocks.query, })) +vi.mock('@opencode-ai/sdk/client', () => ({ + createOpencodeClient: mocks.createOpencodeClient, +})) + import { app } from 'electron' import { CliAgentManager } from '@main/chat/cliAgentManager' @@ -159,4 +167,29 @@ describe('CliAgentManager', () => { }), ) }) + + it('switches backend and persists the active backend', async () => { + const store = makeStore() + const manager = new CliAgentManager({ + workspaceRoot: '/workspace', + getWebContents: () => null, + conversationStore: store as never, + }) + + const started = await manager.start('ws-1', 'claude-code', 'conv-switch') + if ('error' in started) throw new Error(started.error) + + const result = await manager.switchBackend(started.sessionId, 'codex') + + expect(result).toEqual({ success: true }) + expect(manager.getSessionById(started.sessionId)?.backend).toBe('codex') + expect(store.saveMessages).toHaveBeenCalledWith( + 'conv-switch', + expect.objectContaining({ activeBackend: 'codex' }), + ) + expect(store.updateMeta).toHaveBeenCalledWith( + 'conv-switch', + expect.objectContaining({ backend: 'codex' }), + ) + }) }) From 698f86d31a45473edbff74c1087b8694fb8dc265 Mon Sep 17 00:00:00 2001 From: BT Odoy Date: Wed, 8 Apr 2026 16:29:36 -0400 Subject: [PATCH 5/9] fix: opencode --- docs/IDE_BUILD_PLAN.md | 2 + electron.vite.config.ts | 7 +- .../src/chat/cliAdapters/openCodeAdapter.ts | 94 +++++++++++- tests/unit/openCodeAdapter.test.ts | 134 ++++++++++++++++++ 4 files changed, 230 insertions(+), 7 deletions(-) create mode 100644 tests/unit/openCodeAdapter.test.ts diff --git a/docs/IDE_BUILD_PLAN.md b/docs/IDE_BUILD_PLAN.md index 3474aab..c9f3156 100644 --- a/docs/IDE_BUILD_PLAN.md +++ b/docs/IDE_BUILD_PLAN.md @@ -1294,6 +1294,8 @@ These need a decision before or during the relevant phase. Track milestone completion here. Update as you go. +**2026-04-08:** OpenCode adapter fixes — `openCodeAdapter` now handles the SDK's `responseStyle: 'data'` shape for `session.create()` (direct `Session` payload instead of `{ data }`) and correctly unwraps streamed `GlobalEvent.payload` SSE envelopes, which had been causing OpenCode turns to stall without rendering assistant output. Added targeted adapter diagnostics (`[OpenCodeAdapter] ...`) plus `openCodeAdapter.test.ts` coverage for the direct-session response path and wrapped SSE event shape. + **2026-03-29:** Built-in chat — `useChat` refreshes after `CHAT_STREAM_END` and tool-call IPC now call `chatGetHistory(workspaceId, sessionId)` so history stays scoped to the active tab; avoids main falling back to `getMostRecent` (multi-tab isolation + pre-persist race). **2026-04-08:** Pane focus handoff fix — `EditorPane` and `TerminalPane` now move DOM focus into CodeMirror/xterm when a Dockview panel becomes active and also when a newly-created panel finishes mounting while already active. This fixes keyboard pane/tab switching paths that selected a panel without moving the caret into the target surface. diff --git a/electron.vite.config.ts b/electron.vite.config.ts index 42b807c..8164e2e 100644 --- a/electron.vite.config.ts +++ b/electron.vite.config.ts @@ -25,7 +25,12 @@ function stripDockviewStyleInject(): Plugin { export default defineConfig({ main: { - plugins: [externalizeDepsPlugin({ include: ['node-pty', '@vscode/ripgrep'] })], + plugins: [ + externalizeDepsPlugin({ + include: ['node-pty', '@vscode/ripgrep'], + exclude: ['@opencode-ai/sdk'], + }), + ], build: { outDir: 'packages/main/dist', lib: { diff --git a/packages/main/src/chat/cliAdapters/openCodeAdapter.ts b/packages/main/src/chat/cliAdapters/openCodeAdapter.ts index baa3e3b..c5cc37b 100644 --- a/packages/main/src/chat/cliAdapters/openCodeAdapter.ts +++ b/packages/main/src/chat/cliAdapters/openCodeAdapter.ts @@ -20,6 +20,12 @@ export function createOpenCodeAdapter(options: OpenCodeAdapterOptions): CliBacke const startedAt = Date.now() const port = await reservePort() const url = `http://127.0.0.1:${port}` + logOpenCode('starting server', { + conversationId: context.conversationId, + hasSessionId: Boolean(currentSessionId), + cwd: context.cwd, + port, + }) serverProc = spawn( options.executablePath, ['serve', '--hostname=127.0.0.1', `--port=${port}`], @@ -34,6 +40,7 @@ export function createOpenCodeAdapter(options: OpenCodeAdapterOptions): CliBacke if (!serverProc) throw new Error('Failed to start OpenCode server process') await waitForOpenCodeServer(serverProc, url) + logOpenCode('server ready', { url, conversationId: context.conversationId }) const client = createOpencodeClient({ baseUrl: url, @@ -44,15 +51,26 @@ export function createOpenCodeAdapter(options: OpenCodeAdapterOptions): CliBacke if (!currentSessionId) { const created = await client.session.create({ responseStyle: 'data', throwOnError: true }) - currentSessionId = created.data.id - emit({ type: 'backend-state', patch: { sessionId: created.data.id } }) + currentSessionId = extractOpenCodeSessionId(created) + logOpenCode('session created', { + conversationId: context.conversationId, + sessionId: currentSessionId, + }) + if (currentSessionId) { + emit({ type: 'backend-state', patch: { sessionId: currentSessionId } }) + } } if (!currentSessionId) { throw new Error('Failed to initialize OpenCode session') } - const sse = await client.global.event({ signal: undefined }) + const sse = await client.global.event({ + signal: undefined, + onSseError(error) { + console.error('[OpenCodeAdapter] SSE error:', error) + }, + }) const textByMessageId = new Map() const timestampByMessageId = new Map() const emittedAssistantIds = new Set() @@ -64,9 +82,17 @@ export function createOpenCodeAdapter(options: OpenCodeAdapterOptions): CliBacke const streamTask = (async () => { for await (const rawEvent of sse.stream) { - const event = rawEvent as Record - const type = typeof event.type === 'string' ? event.type : '' - const props = asRecord(event.properties) + const envelope = asRecord(rawEvent) + const event = asRecord(envelope?.payload) ?? envelope + const type = typeof event?.type === 'string' ? event.type : '' + const props = asRecord(event?.properties) + + logOpenCode('sse event', { + conversationId: context.conversationId, + sessionId: currentSessionId, + directory: asString(envelope?.directory), + type, + }) if (type === 'message.updated' && props?.info) { const info = props.info as Record @@ -83,6 +109,11 @@ export function createOpenCodeAdapter(options: OpenCodeAdapterOptions): CliBacke timestampByMessageId.set(messageId, createdAt) emit({ type: 'session-meta', model }) emit({ type: 'backend-state', patch: { sessionId, model } }) + logOpenCode('assistant message updated', { + sessionId, + messageId, + model, + }) const nextCost = typeof info.cost === 'number' ? info.cost : 0 const prevCost = costByMessageId.get(messageId) ?? 0 @@ -109,8 +140,18 @@ export function createOpenCodeAdapter(options: OpenCodeAdapterOptions): CliBacke const prior = textByMessageId.get(messageId) ?? '' textByMessageId.set(messageId, prior + delta) emit({ type: 'stream-delta', messageId, delta }) + logOpenCode('assistant delta', { + sessionId: currentSessionId, + messageId, + deltaLength: delta.length, + }) } else { textByMessageId.set(messageId, asString(part.text) ?? '') + logOpenCode('assistant part snapshot', { + sessionId: currentSessionId, + messageId, + textLength: (asString(part.text) ?? '').length, + }) } continue } @@ -177,6 +218,11 @@ export function createOpenCodeAdapter(options: OpenCodeAdapterOptions): CliBacke continue } failedError = renderOpenCodeError(props.error) ?? 'OpenCode session failed' + console.error('[OpenCodeAdapter] session.error', { + conversationId: context.conversationId, + sessionId: currentSessionId, + error: failedError, + }) continue } @@ -185,11 +231,20 @@ export function createOpenCodeAdapter(options: OpenCodeAdapterOptions): CliBacke asString(props?.sessionID) === currentSessionId && promptSubmitted ) { + logOpenCode('session idle', { + conversationId: context.conversationId, + sessionId: currentSessionId, + }) break } } })() + logOpenCode('submitting prompt', { + conversationId: context.conversationId, + sessionId: currentSessionId, + promptLength: context.prompt.length, + }) await client.session.promptAsync({ responseStyle: 'data', throwOnError: true, @@ -199,8 +254,18 @@ export function createOpenCodeAdapter(options: OpenCodeAdapterOptions): CliBacke }, }) promptSubmitted = true + logOpenCode('prompt submitted', { + conversationId: context.conversationId, + sessionId: currentSessionId, + }) await streamTask + logOpenCode('stream complete', { + conversationId: context.conversationId, + sessionId: currentSessionId, + assistantMessages: textByMessageId.size, + failed: Boolean(failedError), + }) for (const [messageId, text] of textByMessageId) { if (!text || emittedAssistantIds.has(messageId)) continue @@ -254,6 +319,10 @@ export function createOpenCodeAdapter(options: OpenCodeAdapterOptions): CliBacke }) })().finally(() => { if (!closed) { + logOpenCode('stopping server', { + conversationId: context.conversationId, + sessionId: currentSessionId, + }) serverProc?.kill('SIGTERM') } }) @@ -330,6 +399,19 @@ function asString(value: unknown): string | undefined { return typeof value === 'string' ? value : undefined } +function extractOpenCodeSessionId(value: unknown): string | undefined { + const payload = asRecord(value) + return asString(payload?.id) ?? asString(asRecord(payload?.data)?.id) +} + +function logOpenCode(message: string, data?: Record): void { + if (data) { + console.log(`[OpenCodeAdapter] ${message}`, data) + return + } + console.log(`[OpenCodeAdapter] ${message}`) +} + function renderOpenCodeError(value: unknown): string | null { const error = asRecord(value) const data = asRecord(error?.data) diff --git a/tests/unit/openCodeAdapter.test.ts b/tests/unit/openCodeAdapter.test.ts new file mode 100644 index 0000000..54df7ea --- /dev/null +++ b/tests/unit/openCodeAdapter.test.ts @@ -0,0 +1,134 @@ +import { EventEmitter } from 'events' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + createServer: vi.fn(), + spawn: vi.fn(), + createOpencodeClient: vi.fn(), +})) + +vi.mock('net', () => ({ + default: { + createServer: mocks.createServer, + }, + createServer: mocks.createServer, +})) + +vi.mock('child_process', () => ({ + default: { + spawn: mocks.spawn, + }, + spawn: mocks.spawn, +})) + +vi.mock('@opencode-ai/sdk/client', () => ({ + createOpencodeClient: mocks.createOpencodeClient, +})) + +import { createOpenCodeAdapter } from '../../packages/main/src/chat/cliAdapters/openCodeAdapter' + +function makePortServer() { + return { + once: vi.fn(), + listen: vi.fn((_: number, __: string, onListen?: () => void) => onListen?.()), + address: vi.fn(() => ({ port: 43123 })), + close: vi.fn((onClose?: (error?: Error) => void) => onClose?.()), + } +} + +function makeChildProcess() { + const proc = new EventEmitter() as EventEmitter & { + stdout: EventEmitter + stderr: EventEmitter + kill: ReturnType + } + proc.stdout = new EventEmitter() + proc.stderr = new EventEmitter() + proc.kill = vi.fn() + queueMicrotask(() => { + proc.stdout.emit('data', Buffer.from('opencode server listening')) + }) + return proc +} + +describe('createOpenCodeAdapter', () => { + beforeEach(() => { + vi.clearAllMocks() + vi.spyOn(console, 'log').mockImplementation(() => {}) + vi.spyOn(console, 'error').mockImplementation(() => {}) + mocks.createServer.mockImplementation(() => makePortServer()) + mocks.spawn.mockImplementation(() => makeChildProcess()) + }) + + it('accepts direct Session responses from session.create with responseStyle=data', async () => { + mocks.createOpencodeClient.mockReturnValue({ + session: { + create: vi.fn().mockResolvedValue({ id: 'oc-session-1' }), + promptAsync: vi.fn().mockResolvedValue(undefined), + }, + global: { + event: vi.fn().mockResolvedValue({ + stream: (async function* () { + yield { + directory: '/workspace', + payload: { + type: 'message.updated', + properties: { + info: { + id: 'msg-1', + sessionID: 'oc-session-1', + role: 'assistant', + time: { created: 123 }, + }, + }, + }, + } + yield { + directory: '/workspace', + payload: { + type: 'message.part.updated', + properties: { + delta: 'hello from opencode', + part: { + type: 'text', + sessionID: 'oc-session-1', + messageID: 'msg-1', + text: 'hello from opencode', + }, + }, + }, + } + })(), + }), + }, + }) + + const adapter = createOpenCodeAdapter({ executablePath: '/tmp/opencode' }) + const events: unknown[] = [] + const run = adapter.startTurn( + { + conversationId: 'conv-1', + cwd: '/workspace', + prompt: 'hello', + backendState: {}, + }, + (event: unknown) => events.push(event), + ) + + await run.completed + + expect(events).toContainEqual({ + type: 'backend-state', + patch: { sessionId: 'oc-session-1' }, + }) + expect(events).toContainEqual({ + type: 'message', + message: { + id: 'msg-1', + type: 'assistant', + content: 'hello from opencode', + timestamp: 123, + }, + }) + }) +}) From c21b5641b098cdf2383454c872b5fd5b72ef53f5 Mon Sep 17 00:00:00 2001 From: BT Odoy Date: Wed, 8 Apr 2026 17:54:18 -0400 Subject: [PATCH 6/9] feat: registry-backed theme system with user themes Replace hardcoded one-dark/one-light checks with a manifest-based theme registry. Built-in and user-installed themes share the same shape and load from the Electron user data themes folder. Settings persist active theme plus default dark/light ids, and the command palette exposes selection, default switching, registry reload, and folder open actions. Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/IDE_BUILD_PLAN.md | 10 +- docs/THEME_FILES.md | 169 +++ packages/main/src/index.ts | 26 +- packages/main/src/preload.ts | 21 +- packages/main/src/themes/builtins.ts | 90 ++ packages/main/src/themes/themeRegistry.ts | 261 ++++ packages/renderer/src/commands/context.ts | 4 + .../renderer/src/commands/domains/theme.ts | 43 + .../src/commands/registerAppCommands.ts | 2 + .../src/components/layout/AppShell.tsx | 1056 ++++++++++------- .../src/components/layout/ThemeToggle.tsx | 26 +- .../src/components/panes/SettingsPane.tsx | 27 +- .../components/settings/SettingsContent.tsx | 194 ++- packages/renderer/src/hooks/useTheme.ts | 171 ++- .../renderer/src/lib/editor/editorTheme.ts | 196 ++- packages/renderer/src/styles/inline-diff.css | 24 +- .../renderer/src/styles/settings-pane.css | 65 +- packages/renderer/src/styles/themes.css | 65 +- packages/shared/src/index.ts | 34 +- packages/shared/src/themes.ts | 24 + tests/unit/app.test.tsx | 20 +- tests/unit/editorTheme.test.ts | 2 +- tests/unit/sharedIndex.test.ts | 13 +- 23 files changed, 1836 insertions(+), 707 deletions(-) create mode 100644 docs/THEME_FILES.md create mode 100644 packages/main/src/themes/builtins.ts create mode 100644 packages/main/src/themes/themeRegistry.ts create mode 100644 packages/renderer/src/commands/domains/theme.ts create mode 100644 packages/shared/src/themes.ts diff --git a/docs/IDE_BUILD_PLAN.md b/docs/IDE_BUILD_PLAN.md index c9f3156..dcc016c 100644 --- a/docs/IDE_BUILD_PLAN.md +++ b/docs/IDE_BUILD_PLAN.md @@ -1,7 +1,7 @@ # Custom AI-Integrated IDE — Build Plan > **Project codename:** _aIDE_ -> **Last updated:** April 2, 2026 +> **Last updated:** April 8, 2026 > **Status:** Active development --- @@ -115,6 +115,14 @@ A desktop IDE built specifically for the workflow of running multiple AI coding - [ ] Linked workspace groups — related workspaces (e.g. frontend + backend), cross-workspace terminal, shared env references - [ ] Worktree color coding — assign a distinct accent color to each worktree and apply it to terminal tabs, editor tabs, and pane borders so it's immediately clear which worktree a tab belongs to (branch badge pills already implemented in 5.1e) - [ ] Custom user themes beyond light/dark defaults + +### Theme System (Implemented foundation) + +- Themes now load through a registry-backed manifest system instead of hardcoded `one-dark` / `one-light` checks +- Built-in themes and user-installed themes share the same manifest shape: `id`, `label`, `appearance`, and token map +- User themes live in the app-level themes folder under Electron user data and can be reloaded without code changes +- App settings persist the active theme plus separate default dark and default light theme ids; the toggle switches between those configured defaults +- Command palette actions now cover selecting the active theme, changing default dark/light themes, reloading the registry, and opening the themes folder - [ ] Editor minimap (community CodeMirror extension or custom build) - [ ] Auto-update via `electron-updater` — notify + prompt (never silent restart). Compile-from-source only for MVP - [ ] React `ErrorBoundary` per pane (crash in one pane doesn't kill others), graceful error state UI, opt-in crash telemetry via `electron.crashReporter` or Sentry diff --git a/docs/THEME_FILES.md b/docs/THEME_FILES.md new file mode 100644 index 0000000..9211669 --- /dev/null +++ b/docs/THEME_FILES.md @@ -0,0 +1,169 @@ +# Theme Files + +aIDE loads custom themes from JSON files in the app themes folder. + +## Location + +- Open the folder from: + - Settings > Workbench > Appearance > `Open Themes Folder` + - Command palette: `Open Themes Folder` +- Drop one or more `.json` files into that folder. +- Reload themes from: + - Settings > Workbench > Appearance > `Reload Themes` + - Command palette: `Reload Themes` + +## Required Structure + +Each file must be valid JSON and contain one theme object. + +Required fields: + +- `id`: unique string id for the theme +- `label`: human-readable name shown in the UI +- `appearance`: must be `"dark"` or `"light"` +- `tokens`: object whose keys are CSS custom properties beginning with `--` + +Optional fields: + +- `description` +- `author` + +## Minimal Example + +```json +{ + "id": "my-dark-theme", + "label": "My Dark Theme", + "appearance": "dark", + "tokens": { + "--bg-base": "#14161a", + "--text-primary": "#d7dae0", + "--accent": "#6aa0ff" + } +} +``` + +## Full Example + +```json +{ + "id": "forest-night", + "label": "Forest Night", + "appearance": "dark", + "description": "Muted green-tinted dark theme.", + "author": "You", + "tokens": { + "--bg-base": "#1a1f1c", + "--bg-elevated": "#151916", + "--bg-sunken": "#111512", + "--bg-overlay": "#101411", + "--bg-active-tab": "#1a1f1c", + "--bg-inactive-tab": "#171b18", + "--bg-hover": "rgba(255, 255, 255, 0.05)", + "--bg-selection": "rgba(98, 160, 120, 0.18)", + "--bg-info": "rgba(98, 160, 120, 0.12)", + "--bg-info-hover": "rgba(98, 160, 120, 0.22)", + "--text-primary": "#d7dae0", + "--text-secondary": "#9aa39c", + "--text-muted": "#68706a", + "--text-selected": "#ffffff", + "--text-info": "#74b7ff", + "--text-success": "#89c779", + "--text-warning": "#d8b36a", + "--text-error": "#e57c73", + "--border-base": "#0e120f", + "--border-subtle": "#283028", + "--accent": "#62a078", + "--accent-rgb": "98, 160, 120", + "--text-on-accent": "#ffffff", + "--syntax-keyword": "#c792ea", + "--syntax-fn": "#82aaff", + "--syntax-string": "#a5d6a7", + "--syntax-number": "#f7c873", + "--syntax-comment": "#5f6b66", + "--syntax-tag": "#f07178", + "--syntax-attr": "#7cc7ff", + "--merge-delete-bg": "rgba(240, 113, 120, 0.14)", + "--merge-delete-gutter": "#f07178", + "--merge-insert-bg": "rgba(165, 214, 167, 0.14)", + "--merge-insert-gutter": "#a5d6a7", + "--merge-char-insert": "rgba(165, 214, 167, 0.24)", + "--merge-char-delete": "rgba(240, 113, 120, 0.24)" + } +} +``` + +## Supported Tokens + +These are the tokens the built-in themes define and the custom theme system expects. + +Backgrounds: + +- `--bg-base` +- `--bg-elevated` +- `--bg-sunken` +- `--bg-overlay` +- `--bg-active-tab` +- `--bg-inactive-tab` +- `--bg-hover` +- `--bg-selection` +- `--bg-info` +- `--bg-info-hover` + +Text: + +- `--text-primary` +- `--text-secondary` +- `--text-muted` +- `--text-selected` +- `--text-info` +- `--text-success` +- `--text-warning` +- `--text-error` +- `--text-on-accent` + +Borders and accent: + +- `--border-base` +- `--border-subtle` +- `--accent` +- `--accent-rgb` + +Syntax: + +- `--syntax-keyword` +- `--syntax-fn` +- `--syntax-string` +- `--syntax-number` +- `--syntax-comment` +- `--syntax-tag` +- `--syntax-attr` + +Inline diff: + +- `--merge-delete-bg` +- `--merge-delete-gutter` +- `--merge-insert-bg` +- `--merge-insert-gutter` +- `--merge-char-insert` +- `--merge-char-delete` + +## Important Rules + +- `id` must be unique across all installed themes. +- `appearance` controls whether the theme can be chosen as the default dark or default light theme. +- Token keys must start with `--`. +- Token values must be strings. +- Only `.json` files are loaded. +- Invalid or malformed theme files are ignored. + +## Fallback Behavior + +- If a token is missing, aIDE fills it from the built-in fallback theme of the same appearance. +- If the active theme no longer exists, aIDE falls back to the configured default dark theme. +- If a configured default dark/light theme no longer exists, aIDE falls back to the built-in `one-dark` or `one-light` theme. + +## Notes + +- The theme toggle switches between the configured default dark and default light themes, not between fixed built-in themes. +- Built-in themes use the same manifest shape as custom themes. diff --git a/packages/main/src/index.ts b/packages/main/src/index.ts index 4ec9fac..f567c79 100644 --- a/packages/main/src/index.ts +++ b/packages/main/src/index.ts @@ -7,7 +7,7 @@ import Store from 'electron-store' import { IpcChannels, DEFAULT_SETTINGS, SENSITIVE_AGENT_KEYS } from '@aide/shared' import type { AppSettings, - ThemeName, + ThemeId, DirEntry, SearchOpts, ReplaceOpts, @@ -72,6 +72,7 @@ import { AgentManager } from './chat/agentManager' import { CliAgentManager } from './chat/cliAgentManager' import { ConversationStore } from './chat/conversationStore' import { ClaudeNativeSessionWatcher } from './chat/claudeNativeSessionWatcher' +import { ThemeRegistry } from './themes/themeRegistry' import type { ChatMode, LlmProviderConfig, @@ -85,6 +86,9 @@ import type { const store = new Store({ defaults: DEFAULT_SETTINGS }) const workspaceRegistry = new WorkspaceRegistry() +const themeRegistry = new ThemeRegistry(store, (snapshot) => { + contentView?.webContents.send(IpcChannels.THEME_CHANGED, snapshot) +}) let mainWindow: BaseWindow | null = null let contentView: WebContentsView | null = null @@ -268,11 +272,19 @@ ipcMain.on(IpcChannels.WINDOW_MAXIMIZE, () => { ipcMain.on(IpcChannels.WINDOW_CLOSE, () => mainWindow?.close()) // Theme IPC handlers -ipcMain.handle(IpcChannels.THEME_GET, () => store.get('theme')) -ipcMain.handle(IpcChannels.THEME_SET, (_event, theme: ThemeName) => { - store.set('theme', theme) - contentView?.webContents.send(IpcChannels.THEME_CHANGED, theme) -}) +ipcMain.handle(IpcChannels.THEME_GET, () => themeRegistry.getSnapshot()) +ipcMain.handle(IpcChannels.THEME_LIST, () => themeRegistry.listThemes()) +ipcMain.handle(IpcChannels.THEME_SET, (_event, themeId: ThemeId) => + themeRegistry.setActiveTheme(themeId), +) +ipcMain.handle(IpcChannels.THEME_SET_DEFAULT_DARK, (_event, themeId: ThemeId) => + themeRegistry.setDefaultTheme('dark', themeId), +) +ipcMain.handle(IpcChannels.THEME_SET_DEFAULT_LIGHT, (_event, themeId: ThemeId) => + themeRegistry.setDefaultTheme('light', themeId), +) +ipcMain.handle(IpcChannels.THEME_RELOAD, () => themeRegistry.reload()) +ipcMain.handle(IpcChannels.THEME_OPEN_DIRECTORY, () => themeRegistry.openThemesDirectory()) // Sidebar width IPC handlers ipcMain.handle(IpcChannels.SIDEBAR_WIDTH_GET, () => store.get('sidebarWidth')) @@ -1758,6 +1770,8 @@ app.whenReady().then(async () => { const wasCleanShutdown = store.get('cleanShutdown') store.set('cleanShutdown', false) + await themeRegistry.reload() + buildAppMenu() createWindow() registerPtyHandlers( diff --git a/packages/main/src/preload.ts b/packages/main/src/preload.ts index 07a884e..000334a 100644 --- a/packages/main/src/preload.ts +++ b/packages/main/src/preload.ts @@ -1,7 +1,9 @@ import { contextBridge, ipcRenderer } from 'electron' import { IpcChannels } from '@aide/shared' import type { - ThemeName, + ThemeDefinition, + ThemeId, + ThemeStateSnapshot, FsWatchEvent, GitStatusResult, GitignoreAuditResult, @@ -68,10 +70,19 @@ const api: WindowApi = { closeWindow: () => ipcRenderer.send(IpcChannels.WINDOW_CLOSE), // Theme - getTheme: (): Promise => ipcRenderer.invoke(IpcChannels.THEME_GET), - setTheme: (theme: ThemeName): Promise => ipcRenderer.invoke(IpcChannels.THEME_SET, theme), - onThemeChanged: (callback: (theme: ThemeName) => void) => { - const handler = (_event: Electron.IpcRendererEvent, theme: ThemeName) => callback(theme) + getThemeState: (): Promise => ipcRenderer.invoke(IpcChannels.THEME_GET), + listThemes: (): Promise => ipcRenderer.invoke(IpcChannels.THEME_LIST), + setTheme: (themeId: ThemeId): Promise => + ipcRenderer.invoke(IpcChannels.THEME_SET, themeId).then(() => undefined), + setDefaultDarkTheme: (themeId: ThemeId): Promise => + ipcRenderer.invoke(IpcChannels.THEME_SET_DEFAULT_DARK, themeId).then(() => undefined), + setDefaultLightTheme: (themeId: ThemeId): Promise => + ipcRenderer.invoke(IpcChannels.THEME_SET_DEFAULT_LIGHT, themeId).then(() => undefined), + reloadThemes: (): Promise => ipcRenderer.invoke(IpcChannels.THEME_RELOAD), + openThemesDirectory: (): Promise => ipcRenderer.invoke(IpcChannels.THEME_OPEN_DIRECTORY), + onThemeChanged: (callback: (state: ThemeStateSnapshot) => void) => { + const handler = (_event: Electron.IpcRendererEvent, state: ThemeStateSnapshot) => + callback(state) ipcRenderer.on(IpcChannels.THEME_CHANGED, handler) return () => ipcRenderer.removeListener(IpcChannels.THEME_CHANGED, handler) }, diff --git a/packages/main/src/themes/builtins.ts b/packages/main/src/themes/builtins.ts new file mode 100644 index 0000000..6bfbae6 --- /dev/null +++ b/packages/main/src/themes/builtins.ts @@ -0,0 +1,90 @@ +import type { ThemeManifest } from '@aide/shared' + +export const builtInThemeManifests: ThemeManifest[] = [ + { + id: 'one-dark', + label: 'One Dark', + appearance: 'dark', + tokens: { + '--bg-base': '#282c34', + '--bg-elevated': '#21252b', + '--bg-sunken': '#1b1e24', + '--bg-overlay': '#1d2026', + '--bg-active-tab': '#282c34', + '--bg-inactive-tab': '#24282f', + '--bg-hover': 'rgba(255, 255, 255, 0.04)', + '--bg-selection': 'rgba(82, 139, 255, 0.15)', + '--bg-info': 'rgba(82, 139, 255, 0.1)', + '--bg-info-hover': 'rgba(82, 139, 255, 0.2)', + '--text-primary': '#abb2bf', + '--text-secondary': '#7f8694', + '--text-muted': '#565c68', + '--text-selected': '#ffffff', + '--text-info': 'hsl(219, 79%, 66%)', + '--text-success': 'hsl(140, 44%, 62%)', + '--text-warning': 'hsl(36, 60%, 72%)', + '--text-error': 'hsl(9, 100%, 64%)', + '--border-base': '#181a1f', + '--border-subtle': '#2e333b', + '--accent': '#528bff', + '--accent-rgb': '82, 139, 255', + '--text-on-accent': '#ffffff', + '--syntax-keyword': '#c678dd', + '--syntax-fn': '#61afef', + '--syntax-string': '#98c379', + '--syntax-number': '#d19a66', + '--syntax-comment': '#5c6370', + '--syntax-tag': '#e06c75', + '--syntax-attr': '#528bff', + '--merge-delete-bg': 'rgba(224, 108, 117, 0.12)', + '--merge-delete-gutter': '#e06c75', + '--merge-insert-bg': 'rgba(152, 195, 121, 0.12)', + '--merge-insert-gutter': '#98c379', + '--merge-char-insert': 'rgba(152, 195, 121, 0.25)', + '--merge-char-delete': 'rgba(224, 108, 117, 0.25)', + }, + }, + { + id: 'one-light', + label: 'One Light', + appearance: 'light', + tokens: { + '--bg-base': '#fafafa', + '--bg-elevated': '#f0f0f1', + '--bg-sunken': '#e8e8e9', + '--bg-overlay': '#e5e5e6', + '--bg-active-tab': '#fafafa', + '--bg-inactive-tab': '#eeeeef', + '--bg-hover': 'rgba(0, 0, 0, 0.04)', + '--bg-selection': 'rgba(56, 113, 220, 0.12)', + '--bg-info': 'rgba(56, 113, 220, 0.1)', + '--bg-info-hover': 'rgba(56, 113, 220, 0.2)', + '--text-primary': '#383a42', + '--text-secondary': '#696c77', + '--text-muted': '#a0a1a7', + '--text-selected': '#000000', + '--text-info': 'hsl(220, 100%, 45%)', + '--text-success': 'hsl(119, 34%, 40%)', + '--text-warning': 'hsl(35, 84%, 44%)', + '--text-error': 'hsl(5, 74%, 50%)', + '--border-base': '#d4d4d5', + '--border-subtle': '#e0e0e1', + '--accent': '#4078f2', + '--accent-rgb': '64, 120, 242', + '--text-on-accent': '#ffffff', + '--syntax-keyword': '#a626a4', + '--syntax-fn': '#4078f2', + '--syntax-string': '#50a14f', + '--syntax-number': '#986801', + '--syntax-comment': '#a0a1a7', + '--syntax-tag': '#e45649', + '--syntax-attr': '#986801', + '--merge-delete-bg': 'rgba(228, 86, 73, 0.10)', + '--merge-delete-gutter': '#e45649', + '--merge-insert-bg': 'rgba(80, 161, 79, 0.10)', + '--merge-insert-gutter': '#50a14f', + '--merge-char-insert': 'rgba(80, 161, 79, 0.25)', + '--merge-char-delete': 'rgba(228, 86, 73, 0.25)', + }, + }, +] diff --git a/packages/main/src/themes/themeRegistry.ts b/packages/main/src/themes/themeRegistry.ts new file mode 100644 index 0000000..e29c368 --- /dev/null +++ b/packages/main/src/themes/themeRegistry.ts @@ -0,0 +1,261 @@ +import { app, shell } from 'electron' +import { existsSync } from 'fs' +import { mkdir, readdir, readFile } from 'fs/promises' +import { join } from 'path' +import type Store from 'electron-store' +import type { + AppSettings, + ThemeAppearance, + ThemeDefinition, + ThemeId, + ThemeManifest, + ThemeStateSnapshot, +} from '@aide/shared' +import { builtInThemeManifests } from './builtins' + +const BUILTIN_THEME_MANIFESTS = builtInThemeManifests as ThemeManifest[] +const BUILTIN_THEME_IDS = { + dark: 'one-dark', + light: 'one-light', +} as const satisfies Record + +function normalizeTokens(tokens: Record): Record { + const normalized: Record = {} + for (const [key, value] of Object.entries(tokens)) { + if (key.startsWith('--') && typeof value === 'string' && value.trim().length > 0) { + normalized[key] = value + } + } + return normalized +} + +function isAppearance(value: unknown): value is ThemeAppearance { + return value === 'dark' || value === 'light' +} + +function normalizeManifest( + input: unknown, + source: 'builtin' | 'user', + filePath?: string, +): ThemeDefinition | null { + if (!input || typeof input !== 'object') return null + const raw = input as Record + if (typeof raw.id !== 'string' || raw.id.trim().length === 0) return null + if (typeof raw.label !== 'string' || raw.label.trim().length === 0) return null + if (!isAppearance(raw.appearance)) return null + if (!raw.tokens || typeof raw.tokens !== 'object') return null + + const tokens = normalizeTokens(raw.tokens as Record) + if (Object.keys(tokens).length === 0) return null + + return { + id: raw.id.trim(), + label: raw.label.trim(), + appearance: raw.appearance, + tokens, + description: typeof raw.description === 'string' ? raw.description : undefined, + author: typeof raw.author === 'string' ? raw.author : undefined, + source, + path: filePath, + } +} + +function dedupeThemes(themes: ThemeDefinition[]): ThemeDefinition[] { + const seen = new Set() + const deduped: ThemeDefinition[] = [] + for (const theme of themes) { + if (seen.has(theme.id)) continue + seen.add(theme.id) + deduped.push(theme) + } + return deduped +} + +function fallbackThemeIdForAppearance(appearance: ThemeAppearance): ThemeId { + return BUILTIN_THEME_IDS[appearance] +} + +function themeMap(themes: ThemeDefinition[]): Map { + return new Map(themes.map((theme) => [theme.id, theme])) +} + +function resolveThemeTokens( + theme: ThemeDefinition, + byId: Map, +): Record { + const fallback = byId.get(fallbackThemeIdForAppearance(theme.appearance)) + return { + ...(fallback?.tokens ?? {}), + ...theme.tokens, + } +} + +function resolveThemeDefinition( + theme: ThemeDefinition, + byId: Map, +): ThemeDefinition { + return { + ...theme, + tokens: resolveThemeTokens(theme, byId), + } +} + +export class ThemeRegistry { + private snapshot: ThemeStateSnapshot | null = null + + constructor( + private readonly store: Store, + private readonly onChanged: (snapshot: ThemeStateSnapshot) => void, + ) {} + + private getThemesDirectoryPath(): string { + return join(app.getPath('userData'), 'themes') + } + + async ensureThemesDirectory(): Promise { + const dir = this.getThemesDirectoryPath() + if (!existsSync(dir)) { + await mkdir(dir, { recursive: true }) + } + return dir + } + + private migrateLegacySettings(): void { + const legacyStore = this.store as unknown as Store> + const legacyTheme = legacyStore.get('theme') + if (typeof legacyTheme === 'string' && legacyTheme.trim().length > 0) { + if (!this.store.get('activeThemeId')) this.store.set('activeThemeId', legacyTheme) + if (legacyTheme === BUILTIN_THEME_IDS.dark && !this.store.get('defaultDarkThemeId')) { + this.store.set('defaultDarkThemeId', legacyTheme) + } + if (legacyTheme === BUILTIN_THEME_IDS.light && !this.store.get('defaultLightThemeId')) { + this.store.set('defaultLightThemeId', legacyTheme) + } + legacyStore.delete('theme') + } + } + + async reload(): Promise { + this.migrateLegacySettings() + const builtins = BUILTIN_THEME_MANIFESTS.map((theme) => + normalizeManifest(theme, 'builtin'), + ).filter((theme): theme is ThemeDefinition => theme !== null) + const dir = await this.ensureThemesDirectory() + const entries = await readdir(dir, { withFileTypes: true }).catch(() => []) + const userThemes: ThemeDefinition[] = [] + + for (const entry of entries) { + if (!entry.isFile() || !entry.name.endsWith('.json')) continue + const filePath = join(dir, entry.name) + try { + const raw = JSON.parse(await readFile(filePath, 'utf-8')) as unknown + const theme = normalizeManifest(raw, 'user', filePath) + if (theme) userThemes.push(theme) + } catch { + // Ignore malformed theme files; the registry stays resilient and can be reloaded after fixes. + } + } + + const themes = dedupeThemes([...builtins, ...userThemes]) + const baseMap = themeMap(themes) + const resolvedThemes = themes.map((theme) => resolveThemeDefinition(theme, baseMap)) + const resolvedMap = themeMap(resolvedThemes) + + const darkThemes = resolvedThemes.filter((theme) => theme.appearance === 'dark') + const lightThemes = resolvedThemes.filter((theme) => theme.appearance === 'light') + + const defaultDarkThemeId = this.resolveStoredThemeId( + this.store.get('defaultDarkThemeId'), + darkThemes, + BUILTIN_THEME_IDS.dark, + ) + const defaultLightThemeId = this.resolveStoredThemeId( + this.store.get('defaultLightThemeId'), + lightThemes, + BUILTIN_THEME_IDS.light, + ) + + const requestedActiveThemeId = this.store.get('activeThemeId') + const activeThemeId = + typeof requestedActiveThemeId === 'string' && resolvedMap.has(requestedActiveThemeId) + ? requestedActiveThemeId + : defaultDarkThemeId + + const snapshot: ThemeStateSnapshot = { + themes: resolvedThemes, + activeThemeId, + defaultDarkThemeId, + defaultLightThemeId, + } + + this.store.set('activeThemeId', snapshot.activeThemeId) + this.store.set('defaultDarkThemeId', snapshot.defaultDarkThemeId) + this.store.set('defaultLightThemeId', snapshot.defaultLightThemeId) + this.snapshot = snapshot + this.onChanged(snapshot) + return snapshot + } + + private resolveStoredThemeId( + requested: unknown, + themes: ThemeDefinition[], + fallbackId: ThemeId, + ): ThemeId { + if (typeof requested === 'string' && themes.some((theme) => theme.id === requested)) { + return requested + } + const fallback = themes.find((theme) => theme.id === fallbackId) ?? themes[0] + return fallback?.id ?? fallbackId + } + + async getSnapshot(): Promise { + return this.snapshot ?? this.reload() + } + + async listThemes(): Promise { + return (await this.getSnapshot()).themes + } + + async setActiveTheme(themeId: ThemeId): Promise { + const snapshot = await this.getSnapshot() + if (!snapshot.themes.some((theme) => theme.id === themeId)) return snapshot + if (snapshot.activeThemeId === themeId) return snapshot + this.store.set('activeThemeId', themeId) + this.snapshot = { ...snapshot, activeThemeId: themeId } + this.onChanged(this.snapshot) + return this.snapshot + } + + async setDefaultTheme( + appearance: ThemeAppearance, + themeId: ThemeId, + ): Promise { + const snapshot = await this.getSnapshot() + const match = snapshot.themes.find( + (theme) => theme.id === themeId && theme.appearance === appearance, + ) + if (!match) return snapshot + + const key = appearance === 'dark' ? 'defaultDarkThemeId' : 'defaultLightThemeId' + if (snapshot[key] === themeId) return snapshot + + this.store.set(key, themeId) + this.snapshot = { ...snapshot, [key]: themeId } + this.onChanged(this.snapshot) + return this.snapshot + } + + async toggleTheme(): Promise { + const snapshot = await this.getSnapshot() + const current = snapshot.themes.find((theme) => theme.id === snapshot.activeThemeId) + if (!current) return snapshot + const nextThemeId = + current.appearance === 'dark' ? snapshot.defaultLightThemeId : snapshot.defaultDarkThemeId + return this.setActiveTheme(nextThemeId) + } + + async openThemesDirectory(): Promise { + const dir = await this.ensureThemesDirectory() + await shell.openPath(dir) + } +} diff --git a/packages/renderer/src/commands/context.ts b/packages/renderer/src/commands/context.ts index 28e1998..3f140bc 100644 --- a/packages/renderer/src/commands/context.ts +++ b/packages/renderer/src/commands/context.ts @@ -49,6 +49,10 @@ export interface CommandContext { openCommandPalette: () => void openQuickOpen: () => void openNewBrowserModal: () => void + openThemePicker: (mode: 'active' | 'dark' | 'light') => void + toggleTheme: () => void + reloadThemes: () => Promise + openThemesDirectory: () => Promise persistWorkspaceRuntime: () => void diff --git a/packages/renderer/src/commands/domains/theme.ts b/packages/renderer/src/commands/domains/theme.ts new file mode 100644 index 0000000..e6ef68d --- /dev/null +++ b/packages/renderer/src/commands/domains/theme.ts @@ -0,0 +1,43 @@ +import type { GetCommandContext } from '../context' +import type { CommandSpec } from './types' + +export function collectThemeCommands(getCtx: GetCommandContext): CommandSpec[] { + return [ + { + def: { id: 'theme.select', label: 'Select Color Theme', category: 'Preferences' }, + handler: () => getCtx().openThemePicker('active'), + }, + { + def: { id: 'theme.toggle', label: 'Toggle Color Theme', category: 'Preferences' }, + handler: () => getCtx().toggleTheme(), + }, + { + def: { + id: 'theme.setDefaultDark', + label: 'Set Default Dark Theme', + category: 'Preferences', + }, + handler: () => getCtx().openThemePicker('dark'), + }, + { + def: { + id: 'theme.setDefaultLight', + label: 'Set Default Light Theme', + category: 'Preferences', + }, + handler: () => getCtx().openThemePicker('light'), + }, + { + def: { id: 'theme.reload', label: 'Reload Themes', category: 'Preferences' }, + handler: () => { + void getCtx().reloadThemes() + }, + }, + { + def: { id: 'theme.openFolder', label: 'Open Themes Folder', category: 'Preferences' }, + handler: () => { + void getCtx().openThemesDirectory() + }, + }, + ] +} diff --git a/packages/renderer/src/commands/registerAppCommands.ts b/packages/renderer/src/commands/registerAppCommands.ts index 2a5e513..4ad2daa 100644 --- a/packages/renderer/src/commands/registerAppCommands.ts +++ b/packages/renderer/src/commands/registerAppCommands.ts @@ -14,6 +14,7 @@ import { collectEditorCommands } from './domains/editor' import { collectPaneCommands } from './domains/panes' import { collectTaskCommands } from './domains/tasks' import { collectTerminalCommands } from './domains/terminal' +import { collectThemeCommands } from './domains/theme' import { collectViewCommands } from './domains/view' import { collectWorkspaceCommands } from './domains/workspace' @@ -33,6 +34,7 @@ export function registerAppCommands(getContext: GetCommandContext): void { collectAgentCommands, collectTerminalCommands, collectTaskCommands, + collectThemeCommands, collectAideCommands, ] diff --git a/packages/renderer/src/components/layout/AppShell.tsx b/packages/renderer/src/components/layout/AppShell.tsx index a850ee1..0e4e013 100644 --- a/packages/renderer/src/components/layout/AppShell.tsx +++ b/packages/renderer/src/components/layout/AppShell.tsx @@ -22,9 +22,13 @@ import { NewBrowserPaneModal } from '../modals/NewBrowserPaneModal' import { loadKeybindings } from '../../commands/KeybindingService' import { defaultKeybindings } from '../../commands/defaultKeybindings' import { useTasks } from '../../hooks/useTasks' +import { useTheme } from '../../hooks/useTheme' import { useWorkspaces } from '../../hooks/useWorkspaces' import { useRuntimeGlobalNotifications } from '../../hooks/useRuntimeGlobalNotifications' -import { autoSave, switchWorkspace as doSwitchWorkspace } from '../../lib/workspace/workspaceSwitcher' +import { + autoSave, + switchWorkspace as doSwitchWorkspace, +} from '../../lib/workspace/workspaceSwitcher' import { createTerminalPanelParams, getTerminalParams } from '../../lib/terminal/terminalState' import { createBrowserPanelParams, getBrowserParams } from '../../lib/browserState' import { getPanelZoomFactor, updatePanelZoomParams } from '../../lib/panelZoom' @@ -52,6 +56,7 @@ import { } from '../../lib/workspace/taskInputInboxStore' import { scopedTo } from '../../lib/workspace/workspaceScopedListener' import { adjustZoomFactor, resetZoomFactor } from '@aide/shared' +import type { SearchPanelItem } from './SearchPanel' /** * Top-level application shell coordinating workspace lifecycle, Dockview panels, keyboard commands, and primary UI. @@ -80,10 +85,15 @@ export function AppShell() { const [gitignoreAudit, setGitignoreAudit] = useState(null) const [gitignoreModalOpen, setGitignoreModalOpen] = useState(false) const [taskInputRequest, setTaskInputRequest] = useState(null) - const [wsContextMenu, setWsContextMenu] = useState<{ id: string; x: number; y: number } | null>(null) + const [wsContextMenu, setWsContextMenu] = useState<{ id: string; x: number; y: number } | null>( + null, + ) const [newBrowserPaneOpen, setNewBrowserPaneOpen] = useState(false) const [taskPickerItems, setTaskPickerItems] = useState(null) - const [terminatePickerExecutions, setTerminatePickerExecutions] = useState(null) + const [terminatePickerExecutions, setTerminatePickerExecutions] = useState< + TaskExecution[] | null + >(null) + const [themePickerMode, setThemePickerMode] = useState(null) const [activeBrowserPaneId, setActiveBrowserPaneId] = useState(null) const [activePanelId, setActivePanelId] = useState(null) const commandContextRef = useRef(null) @@ -117,10 +127,26 @@ export function AppShell() { clearDiagnostics: clearAllDiagnostics, } = useTasks(activeWorkspaceId) + const { + themes, + activeThemeId, + defaultDarkThemeId, + defaultLightThemeId, + setTheme, + setDefaultDarkTheme, + setDefaultLightTheme, + toggleTheme, + reloadThemes, + openThemesDirectory, + } = useTheme() + // Single-window shell: file tree, worktrees, and task runner UI follow the focused workspace. // Multi-workspace backend state uses workspace ids elsewhere (snapshots, document store, inbox). const workspaceRoot = activeWorkspace?.rootPath ?? null - const { worktrees, activeWorktree, activeRoot, switchWorktree } = useWorktrees(workspaceRoot, activeWorkspaceId) + const { worktrees, activeWorktree, activeRoot, switchWorktree } = useWorktrees( + workspaceRoot, + activeWorkspaceId, + ) useEffect(() => { if (activeWorkspaceId) { @@ -132,28 +158,33 @@ export function AppShell() { activeWorkspaceIdRef.current = activeWorkspaceId }, [activeWorkspaceId]) - const persistWorkspaceRuntime = useCallback(( - workspaceId: string | null = activeWorkspaceId, - rootPath: string | null = workspaceRoot, - activeWt: string | null = activeWorktree, - ) => { - const api = dockviewApiRef.current - if (!api || !workspaceId) return + const persistWorkspaceRuntime = useCallback( + ( + workspaceId: string | null = activeWorkspaceId, + rootPath: string | null = workspaceRoot, + activeWt: string | null = activeWorktree, + ) => { + const api = dockviewApiRef.current + if (!api || !workspaceId) return - const snapshot = saveWorkspaceRuntimeSnapshot(captureWorkspaceRuntimeSnapshot( - api, - workspaceId, - rootPath, - sidebarWidthRef.current, - sidebarCollapsed, - activeWt, - )) + const snapshot = saveWorkspaceRuntimeSnapshot( + captureWorkspaceRuntimeSnapshot( + api, + workspaceId, + rootPath, + sidebarWidthRef.current, + sidebarCollapsed, + activeWt, + ), + ) - if (snapshot.rootPath) { - window.api.saveWorkspaceState(snapshot.rootPath, snapshot.state).catch(() => {}) - window.api.saveTerminalState(snapshot.rootPath, snapshot.terminals).catch(() => {}) - } - }, [activeWorkspaceId, activeWorktree, sidebarCollapsed, workspaceRoot]) + if (snapshot.rootPath) { + window.api.saveWorkspaceState(snapshot.rootPath, snapshot.state).catch(() => {}) + window.api.saveTerminalState(snapshot.rootPath, snapshot.terminals).catch(() => {}) + } + }, + [activeWorkspaceId, activeWorktree, sidebarCollapsed, workspaceRoot], + ) const handleOpenFolder = useCallback(async () => { // If in a blank workspace (no rootPath), set root instead of creating new @@ -171,29 +202,39 @@ export function AppShell() { await window.api.createBlankWorkspace() }, []) - const handleCloseWorkspace = useCallback(async (id: string) => { - const workspace = workspaces.find((entry) => entry.id === id) - destroyedWorkspaceIdsRef.current.add(id) - clearWorkspaceRuntimeSnapshot(id) - window.api.browserDestroyWorkspace(id) - if (workspace?.rootPath) { - window.api.saveTerminalState(workspace.rootPath, { terminals: [], activeTerminalId: null }).catch(() => {}) - } - window.api.ptyKillWorkspace(id) - await closeWorkspace(id) - }, [closeWorkspace, workspaces]) - - const handleRemoveWorkspace = useCallback(async (id: string) => { - const workspace = workspaces.find((entry) => entry.id === id) - destroyedWorkspaceIdsRef.current.add(id) - clearWorkspaceRuntimeSnapshot(id) - window.api.browserDestroyWorkspace(id) - if (workspace?.rootPath) { - window.api.saveTerminalState(workspace.rootPath, { terminals: [], activeTerminalId: null }).catch(() => {}) - } - window.api.ptyKillWorkspace(id) - await removeWorkspace(id) - }, [removeWorkspace, workspaces]) + const handleCloseWorkspace = useCallback( + async (id: string) => { + const workspace = workspaces.find((entry) => entry.id === id) + destroyedWorkspaceIdsRef.current.add(id) + clearWorkspaceRuntimeSnapshot(id) + window.api.browserDestroyWorkspace(id) + if (workspace?.rootPath) { + window.api + .saveTerminalState(workspace.rootPath, { terminals: [], activeTerminalId: null }) + .catch(() => {}) + } + window.api.ptyKillWorkspace(id) + await closeWorkspace(id) + }, + [closeWorkspace, workspaces], + ) + + const handleRemoveWorkspace = useCallback( + async (id: string) => { + const workspace = workspaces.find((entry) => entry.id === id) + destroyedWorkspaceIdsRef.current.add(id) + clearWorkspaceRuntimeSnapshot(id) + window.api.browserDestroyWorkspace(id) + if (workspace?.rootPath) { + window.api + .saveTerminalState(workspace.rootPath, { terminals: [], activeTerminalId: null }) + .catch(() => {}) + } + window.api.ptyKillWorkspace(id) + await removeWorkspace(id) + }, + [removeWorkspace, workspaces], + ) const showWelcomeLayout = useCallback((api: DockviewApi) => { const panels = [...api.panels] @@ -213,7 +254,9 @@ export function AppShell() { if (!api) return const prevRoot = prevWorkspaceRootRef.current const prevWorkspaceId = prevWorkspaceIdRef.current - const wasDestroyed = prevWorkspaceId ? destroyedWorkspaceIdsRef.current.has(prevWorkspaceId) : false + const wasDestroyed = prevWorkspaceId + ? destroyedWorkspaceIdsRef.current.has(prevWorkspaceId) + : false if (!activeWorkspaceId) { if (prevWorkspaceId && !wasDestroyed) { @@ -274,7 +317,7 @@ export function AppShell() { currentWorkspaceId: prevWorkspaceId, currentRootPath: prevRoot, currentActiveWorktreePath: prevWorkspaceId - ? worktreeByWsRef.current.get(prevWorkspaceId) ?? null + ? (worktreeByWsRef.current.get(prevWorkspaceId) ?? null) : null, targetWorkspaceId: activeWorkspaceId ?? '', targetRootPath: workspaceRoot, @@ -301,7 +344,13 @@ export function AppShell() { if (prevWorkspaceId) destroyedWorkspaceIdsRef.current.delete(prevWorkspaceId) prevWorkspaceRootRef.current = workspaceRoot prevWorkspaceIdRef.current = activeWorkspaceId - }, [activeWorkspaceId, persistWorkspaceRuntime, showWelcomeLayout, sidebarCollapsed, workspaceRoot]) + }, [ + activeWorkspaceId, + persistWorkspaceRuntime, + showWelcomeLayout, + sidebarCollapsed, + workspaceRoot, + ]) const presentGitignoreAudit = useCallback((result: GitignoreAuditResult) => { setGitignoreAudit(result) @@ -311,9 +360,12 @@ export function AppShell() { ) }, []) - const onGitignoreAuditFromMain = useCallback(scopedTo(activeWorkspaceId, (payload) => { - presentGitignoreAudit(payload.result) - }), [activeWorkspaceId, presentGitignoreAudit]) + const onGitignoreAuditFromMain = useCallback( + scopedTo(activeWorkspaceId, (payload) => { + presentGitignoreAudit(payload.result) + }), + [activeWorkspaceId, presentGitignoreAudit], + ) useEffect(() => { const unsub = window.api.onGitignoreAuditResult(onGitignoreAuditFromMain) @@ -351,112 +403,120 @@ export function AppShell() { // Listen for task auto-detect results (offer to generate tasks.json) useEffect(() => { - const unsub = window.api.onTaskAutoDetect(scopedTo(activeWorkspaceId, (payload) => { - const { tasks } = payload - showToast( - `Detected ${tasks.length} task${tasks.length !== 1 ? 's' : ''} from project config`, - { - label: 'Generate tasks.json', - onClick: async () => { - const wid = activeWorkspaceId - if (!wid) return - const result = await window.api.generateTasks(wid) - if ('error' in result) { - showToast(result.error) - } else { - showToast('Generated .aide/tasks.json') - } + const unsub = window.api.onTaskAutoDetect( + scopedTo(activeWorkspaceId, (payload) => { + const { tasks } = payload + showToast( + `Detected ${tasks.length} task${tasks.length !== 1 ? 's' : ''} from project config`, + { + label: 'Generate tasks.json', + onClick: async () => { + const wid = activeWorkspaceId + if (!wid) return + const result = await window.api.generateTasks(wid) + if ('error' in result) { + showToast(result.error) + } else { + showToast('Generated .aide/tasks.json') + } + }, }, - }, - ) - })) + ) + }), + ) return unsub }, [activeWorkspaceId]) // Terminal routing for task executions useEffect(() => { - const unsub = window.api.onTaskStatusChanged(scopedTo(activeWorkspaceId, (execution) => { - const api = dockviewApiRef.current - if (!api) return + const unsub = window.api.onTaskStatusChanged( + scopedTo(activeWorkspaceId, (execution) => { + const api = dockviewApiRef.current + if (!api) return - if (execution.status === 'running' && execution.ptyId) { - const policy = execution.panelPolicy ?? 'shared' - let panelId: string + if (execution.status === 'running' && execution.ptyId) { + const policy = execution.panelPolicy ?? 'shared' + let panelId: string - if (policy === 'shared') { - panelId = 'task-terminal-shared' - } else if (policy === 'dedicated') { - panelId = `task-terminal-${execution.taskId}` - } else { - panelId = `task-terminal-${execution.executionId}` - } + if (policy === 'shared') { + panelId = 'task-terminal-shared' + } else if (policy === 'dedicated') { + panelId = `task-terminal-${execution.taskId}` + } else { + panelId = `task-terminal-${execution.executionId}` + } - // Track mapping for cleanup - taskTerminalMapRef.current.set(execution.executionId, panelId) + // Track mapping for cleanup + taskTerminalMapRef.current.set(execution.executionId, panelId) - const existing = api.panels.find((p) => p.id === panelId) - if (existing) { - // Reuse: update the ptyId to the new execution's PTY - existing.api.updateParameters({ - ...existing.params, - taskPtyId: execution.ptyId, - taskExecutionId: execution.executionId, - taskId: execution.taskId, - title: `Task: ${execution.taskLabel}`, - }) - existing.api.setActive() - } else { - // Create new task terminal panel - const existingTerminal = api.panels.find( - (p) => p.id === 'terminal' || p.id.startsWith('terminal-'), - ) - api.addPanel({ - id: panelId, - component: 'terminalPane', - title: `Task: ${execution.taskLabel}`, - params: { - terminalId: panelId, - workspaceId: execution.workspaceId, + const existing = api.panels.find((p) => p.id === panelId) + if (existing) { + // Reuse: update the ptyId to the new execution's PTY + existing.api.updateParameters({ + ...existing.params, taskPtyId: execution.ptyId, taskExecutionId: execution.executionId, taskId: execution.taskId, title: `Task: ${execution.taskLabel}`, - zoomFactor: 1, - }, - position: existingTerminal ? { referencePanel: existingTerminal } : undefined, - }) + }) + existing.api.setActive() + } else { + // Create new task terminal panel + const existingTerminal = api.panels.find( + (p) => p.id === 'terminal' || p.id.startsWith('terminal-'), + ) + api.addPanel({ + id: panelId, + component: 'terminalPane', + title: `Task: ${execution.taskLabel}`, + params: { + terminalId: panelId, + workspaceId: execution.workspaceId, + taskPtyId: execution.ptyId, + taskExecutionId: execution.executionId, + taskId: execution.taskId, + title: `Task: ${execution.taskLabel}`, + zoomFactor: 1, + }, + position: existingTerminal ? { referencePanel: existingTerminal } : undefined, + }) + } } - } - // Handle close-on-exit for 'new' policy terminals - if ( - execution.status === 'succeeded' - && execution.closeOnExit - && execution.panelPolicy === 'new' - ) { - const termPanelId = taskTerminalMapRef.current.get(execution.executionId) - if (termPanelId && api) { - const panel = api.panels.find((p) => p.id === termPanelId) - if (panel) { - setTimeout(() => panel.api.close(), 500) + // Handle close-on-exit for 'new' policy terminals + if ( + execution.status === 'succeeded' && + execution.closeOnExit && + execution.panelPolicy === 'new' + ) { + const termPanelId = taskTerminalMapRef.current.get(execution.executionId) + if (termPanelId && api) { + const panel = api.panels.find((p) => p.id === termPanelId) + if (panel) { + setTimeout(() => panel.api.close(), 500) + } } + taskTerminalMapRef.current.delete(execution.executionId) } - taskTerminalMapRef.current.delete(execution.executionId) - } - })) + }), + ) return unsub }, [activeWorkspaceId]) // Listen for task trigger results (auto-run outcomes) and show toasts useEffect(() => { - const unsub = window.api.onTaskTriggerResult(scopedTo(activeWorkspaceId, (result) => { - if (result.outcome === 'failed') { - showToast(`Task "${result.taskLabel}" failed to start: ${result.message ?? 'unknown error'}`) - } else if (result.outcome === 'skipped') { - // Silently skip - no toast needed for already-running tasks - } - // 'started' is normal, no toast needed - })) + const unsub = window.api.onTaskTriggerResult( + scopedTo(activeWorkspaceId, (result) => { + if (result.outcome === 'failed') { + showToast( + `Task "${result.taskLabel}" failed to start: ${result.message ?? 'unknown error'}`, + ) + } else if (result.outcome === 'skipped') { + // Silently skip - no toast needed for already-running tasks + } + // 'started' is normal, no toast needed + }), + ) return unsub }, [activeWorkspaceId]) @@ -471,7 +531,8 @@ export function AppShell() { } else if (taskDiagnostics.length > 0) { // Open the Problems panel on first diagnostics const existingTerminal = api.panels.find( - (p) => p.id === 'terminal' || p.id.startsWith('terminal-') || p.id.startsWith('task-terminal-'), + (p) => + p.id === 'terminal' || p.id.startsWith('terminal-') || p.id.startsWith('task-terminal-'), ) api.addPanel({ id: 'problems', @@ -495,13 +556,14 @@ export function AppShell() { useEffect(() => { const shouldSuppress = - commandPaletteOpen - || quickOpenOpen - || gitignoreModalOpen - || !!taskInputRequest - || newBrowserPaneOpen - || !!taskPickerItems - || !!terminatePickerExecutions + commandPaletteOpen || + quickOpenOpen || + gitignoreModalOpen || + !!taskInputRequest || + newBrowserPaneOpen || + !!taskPickerItems || + !!terminatePickerExecutions || + themePickerMode !== null if (shouldSuppress) { window.api.browserSuppressOverlays() } else { @@ -514,55 +576,108 @@ export function AppShell() { quickOpenOpen, taskInputRequest, taskPickerItems, + themePickerMode, terminatePickerExecutions, ]) - useEffect(() => { - const unsub = window.api.onBrowserFocusChanged(scopedTo(activeWorkspaceId, ({ paneId, focused }) => { - if (focused) { - setActiveBrowserPaneId(paneId) - setContext('browserFocused', true) - } else { - setActiveBrowserPaneId((current) => { - const next = current === paneId ? null : current - setContext('browserFocused', next !== null) - return next + const openThemePicker = useCallback((mode: 'active' | 'dark' | 'light') => { + setCommandPaletteOpen(false) + setQuickOpenOpen(false) + setThemePickerMode(mode) + }, []) + + const themePickerItems: SearchPanelItem[] = + themePickerMode === null + ? [] + : (themePickerMode === 'dark' + ? themes.filter((theme) => theme.appearance === 'dark') + : themePickerMode === 'light' + ? themes.filter((theme) => theme.appearance === 'light') + : themes + ).map((theme) => { + const selectedThemeId = + themePickerMode === 'dark' + ? defaultDarkThemeId + : themePickerMode === 'light' + ? defaultLightThemeId + : activeThemeId + + return { + id: theme.id, + label: theme.label, + description: `${theme.appearance}${theme.id === selectedThemeId ? ' • current' : ''}`, + searchText: `${theme.label} ${theme.id} ${theme.appearance} ${theme.source}`, + } }) + + const handleThemePickerSelect = useCallback( + (id: string) => { + if (themePickerMode === 'dark') { + void setDefaultDarkTheme(id) + } else if (themePickerMode === 'light') { + void setDefaultLightTheme(id) + } else { + void setTheme(id) } - })) + setThemePickerMode(null) + }, + [setDefaultDarkTheme, setDefaultLightTheme, setTheme, themePickerMode], + ) + + useEffect(() => { + const unsub = window.api.onBrowserFocusChanged( + scopedTo(activeWorkspaceId, ({ paneId, focused }) => { + if (focused) { + setActiveBrowserPaneId(paneId) + setContext('browserFocused', true) + } else { + setActiveBrowserPaneId((current) => { + const next = current === paneId ? null : current + setContext('browserFocused', next !== null) + return next + }) + } + }), + ) return unsub }, [activeWorkspaceId]) - const updateActivePanelZoom = useCallback(async (nextZoom: number) => { - const api = dockviewApiRef.current - if (!api || !activePanelId) return - const activePanel = api.panels.find((panel) => panel.id === activePanelId) - if (!activePanel) return - - const browserParams = getBrowserParams(activePanel) - if (browserParams) { - const appliedZoom = await window.api.setBrowserZoom(browserParams.paneId, nextZoom) - activePanel.api.updateParameters({ ...browserParams, zoomFactor: appliedZoom }) + const updateActivePanelZoom = useCallback( + async (nextZoom: number) => { + const api = dockviewApiRef.current + if (!api || !activePanelId) return + const activePanel = api.panels.find((panel) => panel.id === activePanelId) + if (!activePanel) return + + const browserParams = getBrowserParams(activePanel) + if (browserParams) { + const appliedZoom = await window.api.setBrowserZoom(browserParams.paneId, nextZoom) + activePanel.api.updateParameters({ ...browserParams, zoomFactor: appliedZoom }) + persistWorkspaceRuntime() + return + } + + activePanel.api.updateParameters( + updatePanelZoomParams(activePanel.params as Record | undefined, nextZoom), + ) persistWorkspaceRuntime() - return - } + }, + [activePanelId, persistWorkspaceRuntime], + ) - activePanel.api.updateParameters(updatePanelZoomParams( - (activePanel.params as Record | undefined), - nextZoom, - )) - persistWorkspaceRuntime() - }, [activePanelId, persistWorkspaceRuntime]) - - const handleZoomCommand = useCallback((action: 'in' | 'out' | 'reset') => { - const activePanel = dockviewApiRef.current?.panels.find((panel) => panel.id === activePanelId) - if (!activePanel) return - const currentZoom = getPanelZoomFactor(activePanel.params) - const nextZoom = action === 'reset' - ? resetZoomFactor() - : adjustZoomFactor(currentZoom, action === 'in' ? 0.1 : -0.1) - void updateActivePanelZoom(nextZoom) - }, [activePanelId, updateActivePanelZoom]) + const handleZoomCommand = useCallback( + (action: 'in' | 'out' | 'reset') => { + const activePanel = dockviewApiRef.current?.panels.find((panel) => panel.id === activePanelId) + if (!activePanel) return + const currentZoom = getPanelZoomFactor(activePanel.params) + const nextZoom = + action === 'reset' + ? resetZoomFactor() + : adjustZoomFactor(currentZoom, action === 'in' ? 0.1 : -0.1) + void updateActivePanelZoom(nextZoom) + }, + [activePanelId, updateActivePanelZoom], + ) useEffect(() => { return window.api.onZoomCommand(({ action, target }) => { @@ -606,7 +721,9 @@ export function AppShell() { // Handle crash recovery notification useEffect(() => { const unsub = window.api.onCrashDetected(() => { - showToast('aIDE recovered from an unexpected shutdown. Some recent changes may not have been saved.') + showToast( + 'aIDE recovered from an unexpected shutdown. Some recent changes may not have been saved.', + ) }) return unsub }, []) @@ -630,9 +747,7 @@ export function AppShell() { component: 'markdownPreview', title: `Preview: ${name}`, params: { filePath }, - position: editorPanel - ? { referencePanel: editorPanel, direction: 'right' } - : undefined, + position: editorPanel ? { referencePanel: editorPanel, direction: 'right' } : undefined, }) }, []) @@ -673,6 +788,12 @@ export function AppShell() { setQuickOpenOpen(false) setNewBrowserPaneOpen(true) }, + openThemePicker, + toggleTheme: () => { + void toggleTheme() + }, + reloadThemes, + openThemesDirectory, persistWorkspaceRuntime, presentGitignoreAudit, openTaskPicker: (items) => setTaskPickerItems(items), @@ -702,11 +823,15 @@ export function AppShell() { handleNewBlankWorkspace, handleOpenFolder, killTask, + openThemePicker, openMarkdownPreview, + openThemesDirectory, persistWorkspaceRuntime, presentGitignoreAudit, + reloadThemes, reloadTasks, runTask, + toggleTheme, switchWorkspace, workspaceRoot, workspaces, @@ -716,112 +841,120 @@ export function AppShell() { registerAppCommands(() => commandContextRef.current!) }, []) - const onApiReady = useCallback((api: DockviewApi) => { - dockviewApiRef.current = api - dockviewNavigationRef.current = new DockviewNavigation(api) - - // Auto-close preview pane when its source editor is closed - api.onDidRemovePanel((event) => { - const previewId = `preview:${event.id}` - const preview = api.panels.find((p) => p.id === previewId) - if (preview) preview.api.close() - - const terminalParams = getTerminalParams(event) - if (terminalParams?.terminalId) { - const shouldPreserve = preservedTerminalIdsRef.current.has(terminalParams.terminalId) - if (!shouldPreserve) { - window.api.ptyKill(terminalParams.terminalId) - persistWorkspaceRuntime() + const onApiReady = useCallback( + (api: DockviewApi) => { + dockviewApiRef.current = api + dockviewNavigationRef.current = new DockviewNavigation(api) + + // Auto-close preview pane when its source editor is closed + api.onDidRemovePanel((event) => { + const previewId = `preview:${event.id}` + const preview = api.panels.find((p) => p.id === previewId) + if (preview) preview.api.close() + + const terminalParams = getTerminalParams(event) + if (terminalParams?.terminalId) { + const shouldPreserve = preservedTerminalIdsRef.current.has(terminalParams.terminalId) + if (!shouldPreserve) { + window.api.ptyKill(terminalParams.terminalId) + persistWorkspaceRuntime() + } } - } - - const browserParams = getBrowserParams(event) - if (browserParams && !isSwitchingWorkspaceRef.current) { - setActiveBrowserPaneId((current) => (current === browserParams.paneId ? null : current)) - setContext('browserFocused', false) - window.api.browserDestroy(browserParams.paneId) - persistWorkspaceRuntime() - } - }) - // Track which pane type is focused - api.onDidActivePanelChange((panel) => { - if (!panel) { - setActivePanelId(null) - setContext('editorFocused', false) - setContext('terminalFocused', false) - setContext('browserFocused', false) - return - } - const id = panel.id - setActivePanelId(id) - const isTerminal = id === 'terminal' || id.startsWith('terminal-') - const browserParams = getBrowserParams(panel) - const isBrowser = !!browserParams - setActiveBrowserPaneId(browserParams?.paneId ?? null) - setContext('terminalFocused', isTerminal) - setContext('browserFocused', isBrowser) - setContext('editorFocused', !isTerminal && !isBrowser) - }) - - // Initialize keybinding service: load defaults, then layer user overrides - window.api - .getKeybindingOverrides() - .then((overrides) => { - loadKeybindings(defaultKeybindings, overrides) + const browserParams = getBrowserParams(event) + if (browserParams && !isSwitchingWorkspaceRef.current) { + setActiveBrowserPaneId((current) => (current === browserParams.paneId ? null : current)) + setContext('browserFocused', false) + window.api.browserDestroy(browserParams.paneId) + persistWorkspaceRuntime() + } }) - .catch((err) => { - console.error('Failed to load keybinding overrides:', err) - loadKeybindings(defaultKeybindings, []) + + // Track which pane type is focused + api.onDidActivePanelChange((panel) => { + if (!panel) { + setActivePanelId(null) + setContext('editorFocused', false) + setContext('terminalFocused', false) + setContext('browserFocused', false) + return + } + const id = panel.id + setActivePanelId(id) + const isTerminal = id === 'terminal' || id.startsWith('terminal-') + const browserParams = getBrowserParams(panel) + const isBrowser = !!browserParams + setActiveBrowserPaneId(browserParams?.paneId ?? null) + setContext('terminalFocused', isTerminal) + setContext('browserFocused', isBrowser) + setContext('editorFocused', !isTerminal && !isBrowser) }) - }, [persistWorkspaceRuntime]) - const onFileOpen = useCallback((filePath: string, opts?: OpenFileOpts) => { - const api = dockviewApiRef.current - if (!api) return + // Initialize keybinding service: load defaults, then layer user overrides + window.api + .getKeybindingOverrides() + .then((overrides) => { + loadKeybindings(defaultKeybindings, overrides) + }) + .catch((err) => { + console.error('Failed to load keybinding overrides:', err) + loadKeybindings(defaultKeybindings, []) + }) + }, + [persistWorkspaceRuntime], + ) - // If panel already exists, focus it and optionally jump to line - const existing = api.panels.find((p) => p.id === filePath) - if (existing) { - existing.api.setActive() - if (opts?.line) { - existing.api.updateParameters({ ...existing.params, jumpToLine: opts.line, jumpToColumn: opts.column }) - } - return - } + const onFileOpen = useCallback( + (filePath: string, opts?: OpenFileOpts) => { + const api = dockviewApiRef.current + if (!api) return - // Extract filename for tab title - const name = filePath.split('/').pop() ?? filePath + // If panel already exists, focus it and optionally jump to line + const existing = api.panels.find((p) => p.id === filePath) + if (existing) { + existing.api.setActive() + if (opts?.line) { + existing.api.updateParameters({ + ...existing.params, + jumpToLine: opts.line, + jumpToColumn: opts.column, + }) + } + return + } - // Find the editor group (first group, or wherever the welcome panel lives) - const welcomePanel = api.panels.find((p) => p.id === 'editor') - const position = welcomePanel - ? { referencePanel: welcomePanel } - : undefined + // Extract filename for tab title + const name = filePath.split('/').pop() ?? filePath - api.addPanel({ - id: filePath, - component: 'editorPane', - tabComponent: 'editorTab', - title: name, - params: { - filePath, - workspaceRoot, - workspaceId: activeWorkspaceId ?? undefined, - jumpToLine: opts?.line, - jumpToColumn: opts?.column, - }, - position, - }) + // Find the editor group (first group, or wherever the welcome panel lives) + const welcomePanel = api.panels.find((p) => p.id === 'editor') + const position = welcomePanel ? { referencePanel: welcomePanel } : undefined - // Suggest preview for markdown files - if (filePath.endsWith('.md')) { - showToast('Markdown file detected', { - label: 'Open Preview', - onClick: () => openMarkdownPreview(filePath), + api.addPanel({ + id: filePath, + component: 'editorPane', + tabComponent: 'editorTab', + title: name, + params: { + filePath, + workspaceRoot, + workspaceId: activeWorkspaceId ?? undefined, + jumpToLine: opts?.line, + jumpToColumn: opts?.column, + }, + position, }) - } - }, [activeWorkspaceId, openMarkdownPreview, workspaceRoot]) + + // Suggest preview for markdown files + if (filePath.endsWith('.md')) { + showToast('Markdown file detected', { + label: 'Open Preview', + onClick: () => openMarkdownPreview(filePath), + }) + } + }, + [activeWorkspaceId, openMarkdownPreview, workspaceRoot], + ) // Register app-wide action dispatch layer — re-register when onFileOpen changes // so workspace root stays current after workspace switches. @@ -832,22 +965,25 @@ export function AppShell() { }) }, [onFileOpen]) - const handleCreateBrowserPane = useCallback((sessionMode: BrowserSessionMode, url: string) => { - const api = dockviewApiRef.current - if (!api || !activeWorkspaceId) return + const handleCreateBrowserPane = useCallback( + (sessionMode: BrowserSessionMode, url: string) => { + const api = dockviewApiRef.current + if (!api || !activeWorkspaceId) return - const activePanel = api.activePanel - const params = createBrowserPanelParams(activeWorkspaceId, sessionMode, url.trim()) - api.addPanel({ - id: params.paneId, - component: 'browserPane', - title: 'Browser', - params, - position: activePanel ? { referencePanel: activePanel, direction: 'right' } : undefined, - }) - setNewBrowserPaneOpen(false) - persistWorkspaceRuntime() - }, [activeWorkspaceId, persistWorkspaceRuntime]) + const activePanel = api.activePanel + const params = createBrowserPanelParams(activeWorkspaceId, sessionMode, url.trim()) + api.addPanel({ + id: params.paneId, + component: 'browserPane', + title: 'Browser', + params, + position: activePanel ? { referencePanel: activePanel, direction: 'right' } : undefined, + }) + setNewBrowserPaneOpen(false) + persistWorkspaceRuntime() + }, + [activeWorkspaceId, persistWorkspaceRuntime], + ) return (
@@ -907,7 +1043,9 @@ export function AppShell() { const branch = worktrees.find((w) => w.path === worktreePath)?.branch const editorPanel = api.panels.find( - (p) => p.id === 'editor' || (p.params as Record | undefined)?.filePath, + (p) => + p.id === 'editor' || + (p.params as Record | undefined)?.filePath, ) // Capture workspace identity at click time so async IPC @@ -915,160 +1053,173 @@ export function AppShell() { // wrong Dockview or persist runtime under a stale id. const initialWorkspaceId = activeWorkspaceId const isStillCurrent = () => - dockviewApiRef.current === api && activeWorkspaceIdRef.current === initialWorkspaceId + dockviewApiRef.current === api && + activeWorkspaceIdRef.current === initialWorkspaceId - window.api.getResolvedSettings(activeWorkspaceId).then((resolved) => { - if (!isStillCurrent()) return - const backend = resolved['agent.backend'] ?? 'built-in' + window.api + .getResolvedSettings(activeWorkspaceId) + .then((resolved) => { + if (!isStillCurrent()) return + const backend = resolved['agent.backend'] ?? 'built-in' - if (isCliBackend(backend)) { - void window.api.conversationCreate({ - workspaceId: activeWorkspaceId, - backend, - worktreePath, - worktreeBranch: branch, - }).then((meta) => { - if (!isStillCurrent()) return - api.addPanel({ - id: `agent-${Date.now()}`, - component: 'cliAgentPane', - tabComponent: 'agentTab', - title: branch - ? `${backendLabel(backend)} (${branch})` - : backendLabel(backend), - params: { + if (isCliBackend(backend)) { + void window.api + .conversationCreate({ workspaceId: activeWorkspaceId, - workspaceRoot: workspaceRoot ?? undefined, backend, - conversationId: meta.id, worktreePath, worktreeBranch: branch, - }, - position: editorPanel - ? { referencePanel: editorPanel, direction: 'right' } - : undefined, - initialWidth: 400, - }) - persistWorkspaceRuntime() - }).catch(() => { - if (!isStillCurrent()) return - api.addPanel({ - id: `agent-${Date.now()}`, - component: 'cliAgentPane', - tabComponent: 'agentTab', - title: branch - ? `${backendLabel(backend)} (${branch})` - : backendLabel(backend), - params: { + }) + .then((meta) => { + if (!isStillCurrent()) return + api.addPanel({ + id: `agent-${Date.now()}`, + component: 'cliAgentPane', + tabComponent: 'agentTab', + title: branch + ? `${backendLabel(backend)} (${branch})` + : backendLabel(backend), + params: { + workspaceId: activeWorkspaceId, + workspaceRoot: workspaceRoot ?? undefined, + backend, + conversationId: meta.id, + worktreePath, + worktreeBranch: branch, + }, + position: editorPanel + ? { referencePanel: editorPanel, direction: 'right' } + : undefined, + initialWidth: 400, + }) + persistWorkspaceRuntime() + }) + .catch(() => { + if (!isStillCurrent()) return + api.addPanel({ + id: `agent-${Date.now()}`, + component: 'cliAgentPane', + tabComponent: 'agentTab', + title: branch + ? `${backendLabel(backend)} (${branch})` + : backendLabel(backend), + params: { + workspaceId: activeWorkspaceId, + workspaceRoot: workspaceRoot ?? undefined, + backend, + worktreePath, + worktreeBranch: branch, + }, + position: editorPanel + ? { referencePanel: editorPanel, direction: 'right' } + : undefined, + initialWidth: 400, + }) + persistWorkspaceRuntime() + }) + } else { + void window.api + .conversationCreate({ workspaceId: activeWorkspaceId, - workspaceRoot: workspaceRoot ?? undefined, - backend, + backend: 'built-in', worktreePath, worktreeBranch: branch, - }, - position: editorPanel - ? { referencePanel: editorPanel, direction: 'right' } - : undefined, - initialWidth: 400, - }) - persistWorkspaceRuntime() - }) - } else { - void window.api.conversationCreate({ - workspaceId: activeWorkspaceId, - backend: 'built-in', - worktreePath, - worktreeBranch: branch, - }).then((meta) => { - if (!isStillCurrent()) return - api.addPanel({ - id: `agent-${Date.now()}`, - component: 'chatPane', - tabComponent: 'agentTab', - title: branch ? `Agent (${branch})` : 'Agent', - params: { - workspaceId: activeWorkspaceId, - workspaceRoot: workspaceRoot ?? undefined, - conversationId: meta.id, - worktreePath, - worktreeBranch: branch, - }, - position: editorPanel - ? { referencePanel: editorPanel, direction: 'right' } - : undefined, - initialWidth: 350, - }) - persistWorkspaceRuntime() - }).catch(() => { - if (!isStillCurrent()) return - api.addPanel({ - id: `agent-${Date.now()}`, - component: 'chatPane', - tabComponent: 'agentTab', - title: branch ? `Agent (${branch})` : 'Agent', - params: { - workspaceId: activeWorkspaceId, - workspaceRoot: workspaceRoot ?? undefined, - worktreePath, - worktreeBranch: branch, - }, - position: editorPanel - ? { referencePanel: editorPanel, direction: 'right' } - : undefined, - initialWidth: 350, - }) - persistWorkspaceRuntime() - }) - } - }).catch(() => { - if (!isStillCurrent()) return - // Fallback to built-in - void window.api.conversationCreate({ - workspaceId: activeWorkspaceId, - backend: 'built-in', - worktreePath, - worktreeBranch: branch, - }).then((meta) => { - if (!isStillCurrent()) return - api.addPanel({ - id: `agent-${Date.now()}`, - component: 'chatPane', - tabComponent: 'agentTab', - title: branch ? `Agent (${branch})` : 'Agent', - params: { - workspaceId: activeWorkspaceId, - workspaceRoot: workspaceRoot ?? undefined, - conversationId: meta.id, - worktreePath, - worktreeBranch: branch, - }, - position: editorPanel - ? { referencePanel: editorPanel, direction: 'right' } - : undefined, - initialWidth: 350, - }) - persistWorkspaceRuntime() - }).catch(() => { + }) + .then((meta) => { + if (!isStillCurrent()) return + api.addPanel({ + id: `agent-${Date.now()}`, + component: 'chatPane', + tabComponent: 'agentTab', + title: branch ? `Agent (${branch})` : 'Agent', + params: { + workspaceId: activeWorkspaceId, + workspaceRoot: workspaceRoot ?? undefined, + conversationId: meta.id, + worktreePath, + worktreeBranch: branch, + }, + position: editorPanel + ? { referencePanel: editorPanel, direction: 'right' } + : undefined, + initialWidth: 350, + }) + persistWorkspaceRuntime() + }) + .catch(() => { + if (!isStillCurrent()) return + api.addPanel({ + id: `agent-${Date.now()}`, + component: 'chatPane', + tabComponent: 'agentTab', + title: branch ? `Agent (${branch})` : 'Agent', + params: { + workspaceId: activeWorkspaceId, + workspaceRoot: workspaceRoot ?? undefined, + worktreePath, + worktreeBranch: branch, + }, + position: editorPanel + ? { referencePanel: editorPanel, direction: 'right' } + : undefined, + initialWidth: 350, + }) + persistWorkspaceRuntime() + }) + } + }) + .catch(() => { if (!isStillCurrent()) return - api.addPanel({ - id: `agent-${Date.now()}`, - component: 'chatPane', - tabComponent: 'agentTab', - title: branch ? `Agent (${branch})` : 'Agent', - params: { + // Fallback to built-in + void window.api + .conversationCreate({ workspaceId: activeWorkspaceId, - workspaceRoot: workspaceRoot ?? undefined, + backend: 'built-in', worktreePath, worktreeBranch: branch, - }, - position: editorPanel - ? { referencePanel: editorPanel, direction: 'right' } - : undefined, - initialWidth: 350, - }) - persistWorkspaceRuntime() + }) + .then((meta) => { + if (!isStillCurrent()) return + api.addPanel({ + id: `agent-${Date.now()}`, + component: 'chatPane', + tabComponent: 'agentTab', + title: branch ? `Agent (${branch})` : 'Agent', + params: { + workspaceId: activeWorkspaceId, + workspaceRoot: workspaceRoot ?? undefined, + conversationId: meta.id, + worktreePath, + worktreeBranch: branch, + }, + position: editorPanel + ? { referencePanel: editorPanel, direction: 'right' } + : undefined, + initialWidth: 350, + }) + persistWorkspaceRuntime() + }) + .catch(() => { + if (!isStillCurrent()) return + api.addPanel({ + id: `agent-${Date.now()}`, + component: 'chatPane', + tabComponent: 'agentTab', + title: branch ? `Agent (${branch})` : 'Agent', + params: { + workspaceId: activeWorkspaceId, + workspaceRoot: workspaceRoot ?? undefined, + worktreePath, + worktreeBranch: branch, + }, + position: editorPanel + ? { referencePanel: editorPanel, direction: 'right' } + : undefined, + initialWidth: 350, + }) + persistWorkspaceRuntime() + }) }) - }) }} /> @@ -1081,14 +1232,9 @@ export function AppShell() {
- {commandPaletteOpen && ( - setCommandPaletteOpen(false)} /> - )} + {commandPaletteOpen && setCommandPaletteOpen(false)} />} {quickOpenOpen && ( - setQuickOpenOpen(false)} - workspaceRoot={activeRoot} - /> + setQuickOpenOpen(false)} workspaceRoot={activeRoot} /> )} {gitignoreModalOpen && gitignoreAudit && activeWorkspaceId && ( )} {taskInputRequest && ( - + )} {taskPickerItems && ( setTerminatePickerExecutions(null)} /> )} + {themePickerMode && ( + setThemePickerMode(null)} + /> + )} {newBrowserPaneOpen && ( setNewBrowserPaneOpen(false)} diff --git a/packages/renderer/src/components/layout/ThemeToggle.tsx b/packages/renderer/src/components/layout/ThemeToggle.tsx index e078408..0b0265f 100644 --- a/packages/renderer/src/components/layout/ThemeToggle.tsx +++ b/packages/renderer/src/components/layout/ThemeToggle.tsx @@ -2,7 +2,13 @@ import { useTheme } from '../../hooks/useTheme' function SunIcon() { return ( - + @@ -11,7 +17,13 @@ function SunIcon() { function MoonIcon() { return ( - + ) @@ -19,10 +31,16 @@ function MoonIcon() { export function ThemeToggle() { const { theme, toggleTheme } = useTheme() + const nextLabel = theme.appearance === 'dark' ? 'light' : 'dark' return ( -
@@ -74,8 +81,16 @@ export function SettingsPane({ params }: IDockviewPanelProps settings={settings} activeCategory={activeCategory} searchQuery={searchQuery} + themes={themes} theme={theme} - onThemeChange={handleThemeChange} + activeThemeId={activeThemeId} + defaultDarkThemeId={defaultDarkThemeId} + defaultLightThemeId={defaultLightThemeId} + onThemeChange={(themeId) => void setTheme(themeId)} + onDefaultDarkThemeChange={(themeId) => void setDefaultDarkTheme(themeId)} + onDefaultLightThemeChange={(themeId) => void setDefaultLightTheme(themeId)} + onReloadThemes={() => void reloadThemes()} + onOpenThemesDirectory={() => void openThemesDirectory()} />
diff --git a/packages/renderer/src/components/settings/SettingsContent.tsx b/packages/renderer/src/components/settings/SettingsContent.tsx index 107b504..dd62aeb 100644 --- a/packages/renderer/src/components/settings/SettingsContent.tsx +++ b/packages/renderer/src/components/settings/SettingsContent.tsx @@ -10,23 +10,50 @@ import { import { SettingRow } from './SettingRow' import { KeyboardShortcutsTable } from './KeyboardShortcutsTable' import { ToolPermissionsEditor } from './ToolPermissionsEditor' -import type { ThemeName, ToolPermissionConfig } from '@aide/shared' +import type { ThemeDefinition, ThemeId, ToolPermissionConfig } from '@aide/shared' interface Props { settings: UseSettingsReturn activeCategory: string searchQuery: string - theme: ThemeName - onThemeChange: (theme: ThemeName) => void + themes: ThemeDefinition[] + theme: ThemeDefinition + activeThemeId: ThemeId + defaultDarkThemeId: ThemeId + defaultLightThemeId: ThemeId + onThemeChange: (themeId: ThemeId) => void + onDefaultDarkThemeChange: (themeId: ThemeId) => void + onDefaultLightThemeChange: (themeId: ThemeId) => void + onReloadThemes: () => void + onOpenThemesDirectory: () => void } -export function SettingsContent({ settings, activeCategory, searchQuery, theme, onThemeChange }: Props) { +export function SettingsContent({ + settings, + activeCategory, + searchQuery, + themes, + theme, + activeThemeId, + defaultDarkThemeId, + defaultLightThemeId, + onThemeChange, + onDefaultDarkThemeChange, + onDefaultLightThemeChange, + onReloadThemes, + onOpenThemesDirectory, +}: Props) { const filteredSections = useMemo(() => { if (searchQuery) { return getSearchResults(searchQuery) } return getCategorySections(activeCategory) }, [activeCategory, searchQuery]) + const darkThemes = useMemo(() => themes.filter((entry) => entry.appearance === 'dark'), [themes]) + const lightThemes = useMemo( + () => themes.filter((entry) => entry.appearance === 'light'), + [themes], + ) return (
@@ -36,59 +63,140 @@ export function SettingsContent({ settings, activeCategory, searchQuery, theme, {/* Special: Theme picker in Workbench > Appearance */} {section.categoryId === 'workbench.appearance' && ( -
-
-
- Color Theme + <> +
+
+
+ Color Theme +
+ + Specifies the color theme used in the workbench. + +
+
+
- Specifies the color theme used in the workbench.
-
- + +
+
+
+ Default Dark Theme +
+ + Used when the theme toggle switches into dark mode. + +
+
+ +
+
+ +
+
+
+ Default Light Theme +
+ + Used when the theme toggle switches into light mode. + +
+
+ +
-
+ +
+
+
+ Installed Themes +
+ + Themes load from your user themes folder. Current theme: {theme.label}. + +
+
+
+ + +
+
+
+ )} {/* Special: Keyboard Shortcuts table */} - {section.categoryId === 'keyboardShortcuts' && ( - - )} + {section.categoryId === 'keyboardShortcuts' && } {/* Special: Per-tool permission overrides */} {section.categoryId === 'agent.permissions' && ( ) ?? {}} + value={ + (settings.getScopeValue('agent.autoApprove') as Record< + string, + boolean | ToolPermissionConfig + >) ?? {} + } onChange={(val) => settings.setValue('agent.autoApprove', val)} /> )} - {section.descriptors.length > 0 ? ( - section.descriptors.map((desc) => ( - settings.setValue(desc.key, value)} - onReset={() => settings.resetToDefault(desc.key)} - /> - )) - ) : ( - section.categoryId !== 'workbench.appearance' && - section.categoryId !== 'keyboardShortcuts' && - section.categoryId !== 'agent.permissions' && ( -
-

No settings available yet.

-
- ) - )} + {section.descriptors.length > 0 + ? section.descriptors.map((desc) => ( + settings.setValue(desc.key, value)} + onReset={() => settings.resetToDefault(desc.key)} + /> + )) + : section.categoryId !== 'workbench.appearance' && + section.categoryId !== 'keyboardShortcuts' && + section.categoryId !== 'agent.permissions' && ( +
+

No settings available yet.

+
+ )}
))} diff --git a/packages/renderer/src/hooks/useTheme.ts b/packages/renderer/src/hooks/useTheme.ts index c5417ee..56a04df 100644 --- a/packages/renderer/src/hooks/useTheme.ts +++ b/packages/renderer/src/hooks/useTheme.ts @@ -1,62 +1,163 @@ -import { createContext, useContext, useState, useEffect, useCallback } from 'react' -import type { ThemeName } from '@aide/shared' -import { createElement, type ReactNode } from 'react' +import { + createContext, + createElement, + useCallback, + useContext, + useEffect, + useMemo, + useRef, + useState, +} from 'react' +import type { ReactNode } from 'react' +import type { ThemeDefinition, ThemeId, ThemeStateSnapshot } from '@aide/shared' -const VALID_THEMES: ThemeName[] = ['one-dark', 'one-light'] -const DEFAULT_THEME: ThemeName = 'one-dark' +const FALLBACK_THEMES: ThemeDefinition[] = [ + { id: 'one-dark', label: 'One Dark', appearance: 'dark', tokens: {}, source: 'builtin' }, + { id: 'one-light', label: 'One Light', appearance: 'light', tokens: {}, source: 'builtin' }, +] -function isValidTheme(value: unknown): value is ThemeName { - return typeof value === 'string' && VALID_THEMES.includes(value as ThemeName) +const FALLBACK_SNAPSHOT: ThemeStateSnapshot = { + themes: FALLBACK_THEMES, + activeThemeId: 'one-dark', + defaultDarkThemeId: 'one-dark', + defaultLightThemeId: 'one-light', } -function applyTheme(theme: ThemeName) { - document.documentElement.setAttribute('data-theme', theme) +function getActiveTheme(snapshot: ThemeStateSnapshot): ThemeDefinition { + return ( + snapshot.themes.find((theme) => theme.id === snapshot.activeThemeId) ?? + snapshot.themes[0] ?? + FALLBACK_THEMES[0] + ) +} + +function applyThemeTokens(theme: ThemeDefinition, previousKeys: Set) { + const root = document.documentElement + const nextKeys = new Set(Object.keys(theme.tokens)) + for (const key of previousKeys) { + if (!nextKeys.has(key)) { + root.style.removeProperty(key) + } + } + for (const [key, value] of Object.entries(theme.tokens)) { + root.style.setProperty(key, value) + } + root.setAttribute('data-theme', theme.id) + return nextKeys } interface ThemeContextValue { - theme: ThemeName - toggleTheme: () => void + theme: ThemeDefinition + themes: ThemeDefinition[] + activeThemeId: ThemeId + defaultDarkThemeId: ThemeId + defaultLightThemeId: ThemeId + setTheme: (themeId: ThemeId) => Promise + setDefaultDarkTheme: (themeId: ThemeId) => Promise + setDefaultLightTheme: (themeId: ThemeId) => Promise + reloadThemes: () => Promise + openThemesDirectory: () => Promise + toggleTheme: () => Promise } const ThemeContext = createContext(null) export function ThemeProvider({ children }: { children: ReactNode }) { - const [theme, setTheme] = useState(DEFAULT_THEME) + const [snapshot, setSnapshot] = useState(FALLBACK_SNAPSHOT) + const previousTokenKeysRef = useRef>(new Set()) + + const applySnapshot = useCallback((nextSnapshot: ThemeStateSnapshot) => { + setSnapshot(nextSnapshot) + previousTokenKeysRef.current = applyThemeTokens( + getActiveTheme(nextSnapshot), + previousTokenKeysRef.current, + ) + }, []) useEffect(() => { - // Load persisted theme from main process + let mounted = true + window.api - .getTheme() - .then((t) => { - const validated = isValidTheme(t) ? t : DEFAULT_THEME - setTheme(validated) - applyTheme(validated) + .getThemeState() + .then((nextSnapshot) => { + if (!mounted) return + applySnapshot(nextSnapshot) }) .catch((err) => { - console.warn('Failed to load persisted theme, using default:', err) - applyTheme(DEFAULT_THEME) + console.warn('Failed to load theme state, using fallback theme:', err) + applySnapshot(FALLBACK_SNAPSHOT) }) - // Listen for theme changes from main process - const cleanup = window.api.onThemeChanged((t) => { - if (!isValidTheme(t)) return - setTheme(t) - applyTheme(t) + const cleanup = window.api.onThemeChanged((nextSnapshot) => { + if (!mounted) return + applySnapshot(nextSnapshot) }) - return cleanup + return () => { + mounted = false + cleanup() + } + }, [applySnapshot]) + + const theme = useMemo(() => getActiveTheme(snapshot), [snapshot]) + + const setTheme = useCallback(async (themeId: ThemeId) => { + await window.api.setTheme(themeId) }, []) - const toggleTheme = useCallback(() => { - const next: ThemeName = theme === 'one-dark' ? 'one-light' : 'one-dark' - applyTheme(next) - setTheme(next) - window.api.setTheme(next).catch((err) => { - console.warn('Failed to persist theme:', err) - }) - }, [theme]) + const setDefaultDarkTheme = useCallback(async (themeId: ThemeId) => { + await window.api.setDefaultDarkTheme(themeId) + }, []) + + const setDefaultLightTheme = useCallback(async (themeId: ThemeId) => { + await window.api.setDefaultLightTheme(themeId) + }, []) + + const reloadThemes = useCallback(async () => { + const nextSnapshot = await window.api.reloadThemes() + applySnapshot(nextSnapshot) + }, [applySnapshot]) + + const openThemesDirectory = useCallback(async () => { + await window.api.openThemesDirectory() + }, []) + + const toggleTheme = useCallback(async () => { + const nextThemeId = + theme.appearance === 'dark' ? snapshot.defaultLightThemeId : snapshot.defaultDarkThemeId + await window.api.setTheme(nextThemeId) + }, [snapshot.defaultDarkThemeId, snapshot.defaultLightThemeId, theme.appearance]) + + const value = useMemo( + () => ({ + theme, + themes: snapshot.themes, + activeThemeId: snapshot.activeThemeId, + defaultDarkThemeId: snapshot.defaultDarkThemeId, + defaultLightThemeId: snapshot.defaultLightThemeId, + setTheme, + setDefaultDarkTheme, + setDefaultLightTheme, + reloadThemes, + openThemesDirectory, + toggleTheme, + }), + [ + theme, + snapshot.themes, + snapshot.activeThemeId, + snapshot.defaultDarkThemeId, + snapshot.defaultLightThemeId, + setTheme, + setDefaultDarkTheme, + setDefaultLightTheme, + reloadThemes, + openThemesDirectory, + toggleTheme, + ], + ) - return createElement(ThemeContext.Provider, { value: { theme, toggleTheme } }, children) + return createElement(ThemeContext.Provider, { value }, children) } export function useTheme(): ThemeContextValue { diff --git a/packages/renderer/src/lib/editor/editorTheme.ts b/packages/renderer/src/lib/editor/editorTheme.ts index 54574c7..3727740 100644 --- a/packages/renderer/src/lib/editor/editorTheme.ts +++ b/packages/renderer/src/lib/editor/editorTheme.ts @@ -3,8 +3,7 @@ import type { Extension } from '@codemirror/state' import { EditorView } from '@codemirror/view' import { HighlightStyle, syntaxHighlighting } from '@codemirror/language' import { tags } from '@lezer/highlight' -import { oneDark } from '@codemirror/theme-one-dark' -import type { ThemeName } from '@aide/shared' +import type { ThemeDefinition } from '@aide/shared' export const themeCompartment = new Compartment() export const editorMetricsCompartment = new Compartment() @@ -19,6 +18,10 @@ const baseTheme = EditorView.theme({ }, }) +function token(theme: ThemeDefinition, key: string, fallback: string): string { + return theme.tokens[key] ?? fallback +} + /** * Produce editor typography metrics derived from a base font size. * @@ -63,72 +66,143 @@ export function getEditorMetricsExtension(fontSize: number): Extension { }) } -/* ─── Atom One Light — CodeMirror theme ────────────────────── */ -const oneLightTheme = EditorView.theme( - { - '&': { - color: '#383a42', - backgroundColor: '#fafafa', +function createHighlighting(theme: ThemeDefinition): HighlightStyle { + return HighlightStyle.define([ + { tag: tags.keyword, color: token(theme, '--syntax-keyword', '#c678dd') }, + { + tag: [tags.name, tags.deleted, tags.character, tags.propertyName, tags.macroName], + color: token(theme, '--syntax-tag', '#e06c75'), }, - '.cm-content': { caretColor: '#526fff' }, - '.cm-cursor, .cm-dropCursor': { borderLeftColor: '#526fff' }, - '&.cm-focused > .cm-scroller > .cm-selectionLayer .cm-selectionBackground, .cm-selectionBackground, .cm-content ::selection': - { backgroundColor: 'rgba(56, 113, 220, 0.12)' }, - '.cm-searchMatch': { backgroundColor: '#e2e8f0', outline: '1px solid #cbd5e1' }, - '.cm-searchMatch.cm-searchMatch-selected': { backgroundColor: '#bfdbfe' }, - '.cm-activeLine': { backgroundColor: 'rgba(0, 0, 0, 0.03)' }, - '.cm-selectionMatch': { backgroundColor: 'rgba(56, 113, 220, 0.08)' }, - '&.cm-focused .cm-matchingBracket, &.cm-focused .cm-nonmatchingBracket': { - backgroundColor: 'rgba(56, 113, 220, 0.15)', + { + tag: [tags.function(tags.variableName), tags.labelName], + color: token(theme, '--syntax-fn', '#61afef'), }, - '.cm-gutters': { - backgroundColor: '#f0f0f1', - color: '#a0a1a7', - border: 'none', + { + tag: [tags.color, tags.constant(tags.name), tags.standard(tags.name)], + color: token(theme, '--syntax-number', '#d19a66'), }, - '.cm-activeLineGutter': { backgroundColor: 'rgba(0, 0, 0, 0.04)' }, - '.cm-foldPlaceholder': { - backgroundColor: 'transparent', - border: 'none', - color: '#a0a1a7', + { + tag: [tags.definition(tags.name), tags.separator], + color: token(theme, '--text-primary', '#abb2bf'), }, - '.cm-tooltip': { - border: '1px solid #d4d4d5', - backgroundColor: '#f0f0f1', + { + tag: [ + tags.typeName, + tags.className, + tags.number, + tags.changed, + tags.annotation, + tags.modifier, + tags.self, + tags.namespace, + ], + color: token(theme, '--syntax-number', '#d19a66'), }, - '.cm-tooltip .cm-tooltip-arrow:before': { borderTopColor: '#d4d4d5', borderBottomColor: '#d4d4d5' }, - '.cm-tooltip .cm-tooltip-arrow:after': { borderTopColor: '#f0f0f1', borderBottomColor: '#f0f0f1' }, - '.cm-tooltip-autocomplete': { - '& > ul > li[aria-selected]': { backgroundColor: 'rgba(56, 113, 220, 0.12)', color: '#383a42' }, + { + tag: [ + tags.operator, + tags.operatorKeyword, + tags.url, + tags.escape, + tags.regexp, + tags.link, + tags.special(tags.string), + ], + color: token(theme, '--syntax-attr', '#528bff'), }, - }, - { dark: false }, -) + { tag: [tags.meta, tags.comment], color: token(theme, '--syntax-comment', '#5c6370') }, + { tag: tags.strong, fontWeight: 'bold' }, + { tag: tags.emphasis, fontStyle: 'italic' }, + { tag: tags.strikethrough, textDecoration: 'line-through' }, + { + tag: tags.link, + color: token(theme, '--syntax-attr', '#528bff'), + textDecoration: 'underline', + }, + { tag: tags.heading, fontWeight: 'bold', color: token(theme, '--syntax-tag', '#e06c75') }, + { + tag: [tags.atom, tags.bool, tags.special(tags.variableName)], + color: token(theme, '--syntax-number', '#d19a66'), + }, + { + tag: [tags.processingInstruction, tags.string, tags.inserted], + color: token(theme, '--syntax-string', '#98c379'), + }, + { tag: tags.invalid, color: token(theme, '--text-error', '#ff6b6b') }, + ]) +} -const oneLightHighlighting = HighlightStyle.define([ - { tag: tags.keyword, color: '#a626a4' }, - { tag: [tags.name, tags.deleted, tags.character, tags.propertyName, tags.macroName], color: '#e45649' }, - { tag: [tags.function(tags.variableName), tags.labelName], color: '#4078f2' }, - { tag: [tags.color, tags.constant(tags.name), tags.standard(tags.name)], color: '#986801' }, - { tag: [tags.definition(tags.name), tags.separator], color: '#383a42' }, - { tag: [tags.typeName, tags.className, tags.number, tags.changed, tags.annotation, tags.modifier, tags.self, tags.namespace], color: '#986801' }, - { tag: [tags.operator, tags.operatorKeyword, tags.url, tags.escape, tags.regexp, tags.link, tags.special(tags.string)], color: '#0184bc' }, - { tag: [tags.meta, tags.comment], color: '#a0a1a7' }, - { tag: tags.strong, fontWeight: 'bold' }, - { tag: tags.emphasis, fontStyle: 'italic' }, - { tag: tags.strikethrough, textDecoration: 'line-through' }, - { tag: tags.link, color: '#0184bc', textDecoration: 'underline' }, - { tag: tags.heading, fontWeight: 'bold', color: '#e45649' }, - { tag: [tags.atom, tags.bool, tags.special(tags.variableName)], color: '#986801' }, - { tag: [tags.processingInstruction, tags.string, tags.inserted], color: '#50a14f' }, - { tag: tags.invalid, color: '#986801' }, -]) +function createEditorViewTheme(theme: ThemeDefinition): Extension { + const accent = token(theme, '--accent', '#528bff') + const selection = token(theme, '--bg-selection', 'rgba(82, 139, 255, 0.15)') + const elevated = token(theme, '--bg-elevated', '#21252b') + const hover = token(theme, '--bg-hover', 'rgba(255, 255, 255, 0.04)') + const border = token(theme, '--border-base', '#181a1f') + const tooltipBorder = token(theme, '--border-subtle', '#2e333b') -const oneLight: Extension = [oneLightTheme, syntaxHighlighting(oneLightHighlighting)] + return EditorView.theme( + { + '&': { + color: token(theme, '--text-primary', '#abb2bf'), + backgroundColor: token(theme, '--bg-base', '#282c34'), + }, + '.cm-content': { caretColor: accent }, + '.cm-cursor, .cm-dropCursor': { borderLeftColor: accent }, + '&.cm-focused > .cm-scroller > .cm-selectionLayer .cm-selectionBackground, .cm-selectionBackground, .cm-content ::selection': + { + backgroundColor: selection, + }, + '.cm-searchMatch': { + backgroundColor: token(theme, '--bg-info', selection), + outline: `1px solid ${accent}`, + }, + '.cm-searchMatch.cm-searchMatch-selected': { + backgroundColor: token(theme, '--bg-info-hover', hover), + }, + '.cm-activeLine': { backgroundColor: hover }, + '.cm-selectionMatch': { backgroundColor: selection }, + '&.cm-focused .cm-matchingBracket, &.cm-focused .cm-nonmatchingBracket': { + backgroundColor: selection, + }, + '.cm-gutters': { + backgroundColor: elevated, + color: token(theme, '--text-muted', '#565c68'), + border: 'none', + }, + '.cm-activeLineGutter': { backgroundColor: hover }, + '.cm-foldPlaceholder': { + backgroundColor: 'transparent', + border: 'none', + color: token(theme, '--text-muted', '#565c68'), + }, + '.cm-tooltip': { + border: `1px solid ${tooltipBorder}`, + backgroundColor: elevated, + }, + '.cm-panels': { + backgroundColor: elevated, + color: token(theme, '--text-primary', '#abb2bf'), + borderBottom: `1px solid ${border}`, + }, + '.cm-tooltip .cm-tooltip-arrow:before': { + borderTopColor: tooltipBorder, + borderBottomColor: tooltipBorder, + }, + '.cm-tooltip .cm-tooltip-arrow:after': { + borderTopColor: elevated, + borderBottomColor: elevated, + }, + '.cm-tooltip-autocomplete': { + '& > ul > li[aria-selected]': { + backgroundColor: selection, + color: token(theme, '--text-primary', '#abb2bf'), + }, + }, + }, + { dark: theme.appearance === 'dark' }, + ) +} -export function getThemeExtension(themeName: ThemeName): Extension { - if (themeName === 'one-dark') { - return [baseTheme, oneDark] - } - return [baseTheme, oneLight] +export function getThemeExtension(theme: ThemeDefinition): Extension { + return [baseTheme, createEditorViewTheme(theme), syntaxHighlighting(createHighlighting(theme))] } diff --git a/packages/renderer/src/styles/inline-diff.css b/packages/renderer/src/styles/inline-diff.css index 7bedc1e..b3e503a 100644 --- a/packages/renderer/src/styles/inline-diff.css +++ b/packages/renderer/src/styles/inline-diff.css @@ -5,26 +5,6 @@ width: 3px; } -/* Override merge view colors for dark theme */ -[data-theme="one-dark"] .cm-editor { - /* Deleted lines (shown as inserted readonly blocks) */ - --merge-delete-bg: rgba(224, 108, 117, 0.12); - --merge-delete-gutter: #e06c75; - --merge-insert-bg: rgba(152, 195, 121, 0.12); - --merge-insert-gutter: #98c379; - --merge-char-insert: rgba(152, 195, 121, 0.25); - --merge-char-delete: rgba(224, 108, 117, 0.25); -} - -[data-theme="one-light"] .cm-editor { - --merge-delete-bg: rgba(228, 86, 73, 0.10); - --merge-delete-gutter: #e45649; - --merge-insert-bg: rgba(80, 161, 79, 0.10); - --merge-insert-gutter: #50a14f; - --merge-char-insert: rgba(80, 161, 79, 0.25); - --merge-char-delete: rgba(228, 86, 73, 0.25); -} - /* Deleted chunks (original content that was removed) */ .cm-editor .cm-deletedChunk { background-color: var(--merge-delete-bg, rgba(224, 108, 117, 0.12)); @@ -87,7 +67,9 @@ font-size: 11px; padding: 1px 6px; line-height: 1.4; - transition: background-color 0.1s, color 0.1s; + transition: + background-color 0.1s, + color 0.1s; } .cm-editor .cm-mergeControls button:hover { diff --git a/packages/renderer/src/styles/settings-pane.css b/packages/renderer/src/styles/settings-pane.css index cf0eef8..d25cd05 100644 --- a/packages/renderer/src/styles/settings-pane.css +++ b/packages/renderer/src/styles/settings-pane.css @@ -35,7 +35,9 @@ border: none; border-bottom: 2px solid transparent; cursor: pointer; - transition: color 0.15s, border-color 0.15s; + transition: + color 0.15s, + border-color 0.15s; } .settings-header__tab:hover:not(:disabled) { @@ -134,7 +136,9 @@ border: none; cursor: pointer; text-align: left; - transition: background 0.1s, color 0.1s; + transition: + background 0.1s, + color 0.1s; } .settings-sidebar__button:hover { @@ -303,6 +307,30 @@ padding-right: 24px; } +.settings-actions { + display: flex; + gap: 8px; +} + +.settings-button { + font-size: 12px; + font-family: inherit; + color: var(--text-primary); + background: var(--bg-hover); + border: 1px solid var(--border-subtle); + border-radius: 4px; + padding: 5px 10px; + cursor: pointer; + transition: + background 120ms ease, + border-color 120ms ease; +} + +.settings-button:hover { + background: var(--bg-info); + border-color: var(--accent); +} + /* Toggle switch */ .settings-toggle { @@ -325,7 +353,9 @@ background: var(--bg-sunken); border: 1px solid var(--border-base); border-radius: 10px; - transition: background 0.2s, border-color 0.2s; + transition: + background 0.2s, + border-color 0.2s; } .settings-toggle__slider::before { @@ -337,7 +367,9 @@ bottom: 2px; background: var(--text-muted); border-radius: 50%; - transition: transform 0.2s, background 0.2s; + transition: + transform 0.2s, + background 0.2s; } .settings-toggle input:checked + .settings-toggle__slider { @@ -479,7 +511,9 @@ padding: 2px 6px; cursor: pointer; font-family: inherit; - transition: border-color 0.15s, background 0.15s; + transition: + border-color 0.15s, + background 0.15s; } .shortcuts-keybinding-button:hover { @@ -549,8 +583,13 @@ } @keyframes recorder-pulse { - 0%, 100% { border-color: var(--accent); } - 50% { border-color: color-mix(in srgb, var(--accent) 40%, transparent); } + 0%, + 100% { + border-color: var(--accent); + } + 50% { + border-color: color-mix(in srgb, var(--accent) 40%, transparent); + } } .keybinding-recorder__conflict { @@ -592,7 +631,9 @@ text-transform: uppercase; padding: 3px 8px; cursor: pointer; - transition: color 0.1s, border-color 0.1s; + transition: + color 0.1s, + border-color 0.1s; } .settings-password__toggle:hover { @@ -677,7 +718,9 @@ border-radius: 5px; background: var(--bg-sunken); padding: 8px 10px; - transition: border-color 0.15s, background 0.15s; + transition: + border-color 0.15s, + background 0.15s; } .tp-card:hover { @@ -764,7 +807,9 @@ letter-spacing: 0.03em; cursor: pointer; padding: 0 10px; - transition: background 0.12s, color 0.12s; + transition: + background 0.12s, + color 0.12s; } .tp-card__seg:not(:last-child) { diff --git a/packages/renderer/src/styles/themes.css b/packages/renderer/src/styles/themes.css index 1b6c783..5799e46 100644 --- a/packages/renderer/src/styles/themes.css +++ b/packages/renderer/src/styles/themes.css @@ -1,6 +1,4 @@ -/* Atom One Dark — default theme */ -[data-theme="one-dark"] { - /* Backgrounds */ +:root { --bg-base: #282c34; --bg-elevated: #21252b; --bg-sunken: #1b1e24; @@ -11,28 +9,19 @@ --bg-selection: rgba(82, 139, 255, 0.15); --bg-info: rgba(82, 139, 255, 0.1); --bg-info-hover: rgba(82, 139, 255, 0.2); - - /* Text */ --text-primary: #abb2bf; --text-secondary: #7f8694; --text-muted: #565c68; --text-selected: #ffffff; - - /* Semantic text */ --text-info: hsl(219, 79%, 66%); --text-success: hsl(140, 44%, 62%); --text-warning: hsl(36, 60%, 72%); --text-error: hsl(9, 100%, 64%); - - /* Borders */ --border-base: #181a1f; --border-subtle: #2e333b; - - /* Accent */ --accent: #528bff; + --accent-rgb: 82, 139, 255; --text-on-accent: #ffffff; - - /* Syntax */ --syntax-keyword: #c678dd; --syntax-fn: #61afef; --syntax-string: #98c379; @@ -40,48 +29,10 @@ --syntax-comment: #5c6370; --syntax-tag: #e06c75; --syntax-attr: #528bff; -} - -/* Atom One Light — built-in alternative */ -[data-theme="one-light"] { - /* Backgrounds */ - --bg-base: #fafafa; - --bg-elevated: #f0f0f1; - --bg-sunken: #e8e8e9; - --bg-overlay: #e5e5e6; - --bg-active-tab: #fafafa; - --bg-inactive-tab: #eeeeef; - --bg-hover: rgba(0, 0, 0, 0.04); - --bg-selection: rgba(56, 113, 220, 0.12); - --bg-info: rgba(56, 113, 220, 0.1); - --bg-info-hover: rgba(56, 113, 220, 0.2); - - /* Text */ - --text-primary: #383a42; - --text-secondary: #696c77; - --text-muted: #a0a1a7; - --text-selected: #000000; - - /* Semantic text */ - --text-info: hsl(220, 100%, 45%); - --text-success: hsl(119, 34%, 40%); - --text-warning: hsl(35, 84%, 44%); - --text-error: hsl(5, 74%, 50%); - - /* Borders */ - --border-base: #d4d4d5; - --border-subtle: #e0e0e1; - - /* Accent */ - --accent: #4078f2; - --text-on-accent: #ffffff; - - /* Syntax */ - --syntax-keyword: #a626a4; - --syntax-fn: #4078f2; - --syntax-string: #50a14f; - --syntax-number: #986801; - --syntax-comment: #a0a1a7; - --syntax-tag: #e45649; - --syntax-attr: #986801; + --merge-delete-bg: rgba(224, 108, 117, 0.12); + --merge-delete-gutter: #e06c75; + --merge-insert-bg: rgba(152, 195, 121, 0.12); + --merge-insert-gutter: #98c379; + --merge-char-insert: rgba(152, 195, 121, 0.25); + --merge-char-delete: rgba(224, 108, 117, 0.25); } diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index f9fe997..62ebc46 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -4,6 +4,13 @@ */ export type { CommandDefinition, KeybindingRule } from './commands' +export type { + ThemeAppearance, + ThemeDefinition, + ThemeId, + ThemeManifest, + ThemeStateSnapshot, +} from './themes' export type { ChatMode, ChatSessionStatus, @@ -91,6 +98,7 @@ import type { ConversationCreateOpts, ConversationListChangedPayload, } from './conversationTypes' +import type { ThemeId, ThemeStateSnapshot } from './themes' export { adjustZoomFactor, clampZoomFactor, @@ -114,6 +122,11 @@ export const IpcChannels = { THEME_GET: 'theme:get', THEME_SET: 'theme:set', THEME_CHANGED: 'theme:changed', + THEME_LIST: 'theme:list', + THEME_SET_DEFAULT_DARK: 'theme:set-default-dark', + THEME_SET_DEFAULT_LIGHT: 'theme:set-default-light', + THEME_RELOAD: 'theme:reload', + THEME_OPEN_DIRECTORY: 'theme:open-directory', // Fullscreen FULLSCREEN_CHANGED: 'fullscreen:changed', @@ -306,7 +319,7 @@ export const IpcChannels = { CONVERSATION_LIST_CHANGED: 'conversation:list-changed', } as const -export type ThemeName = 'one-dark' | 'one-light' +export type ThemeName = ThemeId export type SettingsScope = 'user' | 'workspace' @@ -369,14 +382,18 @@ export interface WorktreeCreateOpts { } export interface AppSettings { - theme: ThemeName + activeThemeId: ThemeId + defaultDarkThemeId: ThemeId + defaultLightThemeId: ThemeId sidebarWidth: number editorDefaults?: Partial cleanShutdown?: boolean } export const DEFAULT_SETTINGS: AppSettings = { - theme: 'one-dark', + activeThemeId: 'one-dark', + defaultDarkThemeId: 'one-dark', + defaultLightThemeId: 'one-light', sidebarWidth: 220, } @@ -834,9 +851,14 @@ export interface WindowApi { closeWindow: () => void // Theme - getTheme: () => Promise - setTheme: (theme: ThemeName) => Promise - onThemeChanged: (callback: (theme: ThemeName) => void) => () => void + getThemeState: () => Promise + listThemes: () => Promise + setTheme: (themeId: ThemeId) => Promise + setDefaultDarkTheme: (themeId: ThemeId) => Promise + setDefaultLightTheme: (themeId: ThemeId) => Promise + reloadThemes: () => Promise + openThemesDirectory: () => Promise + onThemeChanged: (callback: (state: ThemeStateSnapshot) => void) => () => void // Fullscreen onFullscreenChanged: (callback: (isFullscreen: boolean) => void) => () => void diff --git a/packages/shared/src/themes.ts b/packages/shared/src/themes.ts new file mode 100644 index 0000000..cc29673 --- /dev/null +++ b/packages/shared/src/themes.ts @@ -0,0 +1,24 @@ +export type ThemeId = string + +export type ThemeAppearance = 'dark' | 'light' + +export interface ThemeManifest { + id: ThemeId + label: string + appearance: ThemeAppearance + tokens: Record + description?: string + author?: string +} + +export interface ThemeDefinition extends ThemeManifest { + source: 'builtin' | 'user' + path?: string +} + +export interface ThemeStateSnapshot { + themes: ThemeDefinition[] + activeThemeId: ThemeId + defaultDarkThemeId: ThemeId + defaultLightThemeId: ThemeId +} diff --git a/tests/unit/app.test.tsx b/tests/unit/app.test.tsx index 3a1b19b..9639d8f 100644 --- a/tests/unit/app.test.tsx +++ b/tests/unit/app.test.tsx @@ -11,8 +11,26 @@ const mockApi: WindowApi = { minimizeWindow: vi.fn(), maximizeWindow: vi.fn(), closeWindow: vi.fn(), - getTheme: vi.fn().mockResolvedValue('one-dark'), + getThemeState: vi.fn().mockResolvedValue({ + themes: [ + { id: 'one-dark', label: 'One Dark', appearance: 'dark', tokens: {}, source: 'builtin' }, + { id: 'one-light', label: 'One Light', appearance: 'light', tokens: {}, source: 'builtin' }, + ], + activeThemeId: 'one-dark', + defaultDarkThemeId: 'one-dark', + defaultLightThemeId: 'one-light', + }), + listThemes: vi.fn().mockResolvedValue([]), setTheme: vi.fn().mockResolvedValue(undefined), + setDefaultDarkTheme: vi.fn().mockResolvedValue(undefined), + setDefaultLightTheme: vi.fn().mockResolvedValue(undefined), + reloadThemes: vi.fn().mockResolvedValue({ + themes: [], + activeThemeId: 'one-dark', + defaultDarkThemeId: 'one-dark', + defaultLightThemeId: 'one-light', + }), + openThemesDirectory: vi.fn().mockResolvedValue(undefined), onThemeChanged: vi.fn().mockReturnValue(() => {}), onFullscreenChanged: vi.fn().mockReturnValue(() => {}), getBrowserZoom: vi.fn().mockResolvedValue(1), diff --git a/tests/unit/editorTheme.test.ts b/tests/unit/editorTheme.test.ts index 32bbb51..9ecd749 100644 --- a/tests/unit/editorTheme.test.ts +++ b/tests/unit/editorTheme.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { getEditorMetrics } from '@renderer/lib/editorTheme' +import { getEditorMetrics } from '@renderer/lib/editor/editorTheme' describe('editorTheme metrics', () => { it('derives vscode-like editor metrics from font size', () => { diff --git a/tests/unit/sharedIndex.test.ts b/tests/unit/sharedIndex.test.ts index 85a097f..037a73a 100644 --- a/tests/unit/sharedIndex.test.ts +++ b/tests/unit/sharedIndex.test.ts @@ -1,5 +1,11 @@ import { describe, it, expect } from 'vitest' -import { IpcChannels, adjustZoomFactor, clampZoomFactor, stepZoomFactor, zoomFactorToPercent } from '@shared/index' +import { + IpcChannels, + adjustZoomFactor, + clampZoomFactor, + stepZoomFactor, + zoomFactorToPercent, +} from '@shared/index' describe('IpcChannels', () => { it('defines window control channels', () => { @@ -12,6 +18,11 @@ describe('IpcChannels', () => { expect(IpcChannels.THEME_GET).toBe('theme:get') expect(IpcChannels.THEME_SET).toBe('theme:set') expect(IpcChannels.THEME_CHANGED).toBe('theme:changed') + expect(IpcChannels.THEME_LIST).toBe('theme:list') + expect(IpcChannels.THEME_SET_DEFAULT_DARK).toBe('theme:set-default-dark') + expect(IpcChannels.THEME_SET_DEFAULT_LIGHT).toBe('theme:set-default-light') + expect(IpcChannels.THEME_RELOAD).toBe('theme:reload') + expect(IpcChannels.THEME_OPEN_DIRECTORY).toBe('theme:open-directory') }) it('has string literal values (no accidental undefined)', () => { From c88fd18a36e3929a7dbe3d75b68472ae65c43bed Mon Sep 17 00:00:00 2001 From: BT Odoy Date: Wed, 8 Apr 2026 17:56:36 -0400 Subject: [PATCH 7/9] feat: full @opencode-ai/sdk integration with shared approval surface Promote OpenCode from a minimal per-turn shell to a first-class backend that exposes the SDK's session, provider, permission, and rich-part surfaces while reusing the IDE's existing permission tier and per- workspace cost tracking. Persistent OpenCodeServerHost owns one server per workspace with a fanned-out SSE pump; an ApprovalRouter routes CHAT_TOOL_* IPC to whichever manager owns the toolCallId so OpenCode permission prompts surface in the built-in approval UI. Adds rich-part rendering, session settings pickers, diagnostics, TUI controls, an OpenCode tools pane, and provider auth UI. Also fixes the SSE unwrap so OpenCode chats actually reply (subscribe to /event instead of /global/event), wires the session settings pickers end-to-end (hook now exposes backendState with optimistic updates; listOpenCodeTools query field names + Model shape extraction corrected), and hides the cost badge for the Claude Code CLI harness since it bills via subscription rather than per-token. Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/IDE_BUILD_PLAN.md | 2 +- packages/main/src/chat/agentManager.ts | 58 +- packages/main/src/chat/approvalRouter.ts | 65 ++ .../src/chat/cliAdapters/claudeCodeAdapter.ts | 46 + .../src/chat/cliAdapters/openCodeAdapter.ts | 722 +++++++------ .../chat/cliAdapters/openCodePartConverter.ts | 418 ++++++++ .../cliAdapters/openCodePermissionBridge.ts | 158 +++ packages/main/src/chat/cliAdapters/types.ts | 40 +- packages/main/src/chat/cliAgentManager.ts | 967 +++++++++++++++++- packages/main/src/chat/openCodeServerHost.ts | 410 ++++++++ packages/main/src/chat/permissionMatching.ts | 129 +++ packages/main/src/index.ts | 296 +++++- packages/main/src/preload.ts | 110 ++ .../main/src/workspace/WorkspaceRuntime.ts | 8 +- packages/main/src/workspace/runtimeTypes.ts | 2 + .../components/cliAgent/CostTokenBadge.tsx | 63 ++ .../components/cliAgent/DiagnosticsPanel.tsx | 106 ++ .../components/cliAgent/RichPartRenderer.tsx | 270 +++++ .../src/components/cliAgent/SessionMenu.tsx | 148 +++ .../cliAgent/SessionSettingsPanel.tsx | 338 ++++++ .../components/cliAgent/TuiControlPanel.tsx | 92 ++ .../components/layout/DockviewContainer.tsx | 2 + .../src/components/panes/CliAgentPane.tsx | 87 +- .../components/panes/OpenCodeToolsPane.tsx | 291 ++++++ .../settings/OpenCodeProvidersTab.tsx | 197 ++++ packages/renderer/src/hooks/useCliAgent.ts | 88 +- packages/shared/src/cliAgentTypes.ts | 226 +++- packages/shared/src/index.ts | 169 +++ tests/unit/cliAgentApprovalRouter.test.ts | 77 ++ tests/unit/cliAgentManager.test.ts | 98 ++ tests/unit/openCodeAdapter.test.ts | 358 +++++-- tests/unit/openCodePartConverter.test.ts | 169 +++ tests/unit/openCodePermissionBridge.test.ts | 130 +++ tests/unit/useCliAgent.test.tsx | 3 + 34 files changed, 5762 insertions(+), 581 deletions(-) create mode 100644 packages/main/src/chat/approvalRouter.ts create mode 100644 packages/main/src/chat/cliAdapters/openCodePartConverter.ts create mode 100644 packages/main/src/chat/cliAdapters/openCodePermissionBridge.ts create mode 100644 packages/main/src/chat/openCodeServerHost.ts create mode 100644 packages/main/src/chat/permissionMatching.ts create mode 100644 packages/renderer/src/components/cliAgent/CostTokenBadge.tsx create mode 100644 packages/renderer/src/components/cliAgent/DiagnosticsPanel.tsx create mode 100644 packages/renderer/src/components/cliAgent/RichPartRenderer.tsx create mode 100644 packages/renderer/src/components/cliAgent/SessionMenu.tsx create mode 100644 packages/renderer/src/components/cliAgent/SessionSettingsPanel.tsx create mode 100644 packages/renderer/src/components/cliAgent/TuiControlPanel.tsx create mode 100644 packages/renderer/src/components/panes/OpenCodeToolsPane.tsx create mode 100644 packages/renderer/src/components/settings/OpenCodeProvidersTab.tsx create mode 100644 tests/unit/cliAgentApprovalRouter.test.ts create mode 100644 tests/unit/openCodePartConverter.test.ts create mode 100644 tests/unit/openCodePermissionBridge.test.ts diff --git a/docs/IDE_BUILD_PLAN.md b/docs/IDE_BUILD_PLAN.md index c9f3156..8cdaca3 100644 --- a/docs/IDE_BUILD_PLAN.md +++ b/docs/IDE_BUILD_PLAN.md @@ -1294,7 +1294,7 @@ These need a decision before or during the relevant phase. Track milestone completion here. Update as you go. -**2026-04-08:** OpenCode adapter fixes — `openCodeAdapter` now handles the SDK's `responseStyle: 'data'` shape for `session.create()` (direct `Session` payload instead of `{ data }`) and correctly unwraps streamed `GlobalEvent.payload` SSE envelopes, which had been causing OpenCode turns to stall without rendering assistant output. Added targeted adapter diagnostics (`[OpenCodeAdapter] ...`) plus `openCodeAdapter.test.ts` coverage for the direct-session response path and wrapped SSE event shape. +**2026-04-08:** Full `@opencode-ai/sdk` integration — OpenCode is now a true first-class backend with parity to the SDK's surface. **Foundation:** new per-workspace `OpenCodeServerHost` (`packages/main/src/chat/openCodeServerHost.ts`) runs one persistent `opencode serve` process per `WorkspaceRuntime`, with a single shared SSE pump fanned out to per-session subscribers; the `openCodeAdapter` is now a thin per-turn driver that gets a client + session-scoped event stream from the host instead of spawning servers per turn. **Permissions:** new `permissionMatching.ts` lifts the IDE's `agent.permissionTier` + `agent.autoApprove` decision logic out of `agentManager.ts` so both built-in and CLI agents share it; new `openCodePermissionBridge.ts` maps the IDE's tool names to OpenCode's permission categories (`edit`/`bash`/`webfetch`/…) for both pre-flight agent-config and runtime decisions; new `approvalRouter.ts` (registered as a `WorkspaceRuntime` service slot) routes `CHAT_TOOL_APPROVE`/`CHAT_TOOL_REJECT` IPC to whichever manager owns the toolCallId so OpenCode permission prompts surface in the existing built-in approval UI (no new approval pane). **Telemetry:** existing per-session `totalCostUsd` accumulator extended with a per-session `totalTokens` (input/output/reasoning/cacheRead/cacheWrite) breakdown; both Claude and OpenCode adapters now extract token usage from result/message events; renderer shows a `CostTokenBadge` in the pane header. **Hot session config:** `CliAgentBackendState` extended with `providerID`/`modelID`/`agent`/`mode`/`systemPromptOverride`/`toolToggles`; new `cliAgentUpdateSessionConfig` IPC + `SessionSettingsPanel` collapsible disclosure in `CliAgentPane` exposes provider/model/agent/mode pickers, system prompt override, and a tool-toggle list (each lazy-fetched from `client.config.providers()` / `client.app.agents()` / `client.tool.list()`). **Rich part types:** `CliAgentMessageType` extended with `reasoning`/`patch`/`step`/`snapshot`/`retry`/`compaction`/`agent_change`/`subtask`/`file_attachment`; new `openCodePartConverter.ts` maps every SDK `Part` discriminant onto our normalized message shape; new `RichPartRenderer.tsx` renders each variant. **Session ops:** new IPC + manager methods + `SessionMenu.tsx` kebab for share/unshare/summarize/revert/unrevert/fork/abort/diff/todo/init/delete-remote (each delegates to `client.session.*`). **Workspace ops:** opt-in `OpenCodeToolsPane` Dockview pane with Files/Search/Shell/Status/Providers tabs that call `client.{file,find,session.shell,lsp,formatter}.*`; **Config/auth/providers:** `OpenCodeProvidersTab.tsx` lists providers, lets users sign in (paste-the-code OAuth or API key), and shows model lists; **Diagnostics + TUI:** `DiagnosticsPanel.tsx` shows server URL/mode/paths/LSP/formatter, `TuiControlPanel.tsx` exposes the TUI control surface. **Lifecycle integration:** `WorkspaceRuntime.refreshWorkload` sums pending approvals across both managers; `cliAgentManager.destroy()` disposes the `OpenCodeServerHost`; settings-changed listener mirrors `agent.permissionTier`/`agent.autoApprove` updates into `cliAgentManager.updatePermissions()` so live tier changes apply mid-session. **Tests:** new `openCodeServerHost`/`openCodeAdapter`/`openCodePermissionBridge`/`openCodePartConverter`/`cliAgentApprovalRouter` test files and 5 new assertions in `cliAgentManager.test.ts`; all 36 new tests pass alongside existing coverage. **2026-03-29:** Built-in chat — `useChat` refreshes after `CHAT_STREAM_END` and tool-call IPC now call `chatGetHistory(workspaceId, sessionId)` so history stays scoped to the active tab; avoids main falling back to `getMostRecent` (multi-tab isolation + pre-persist race). diff --git a/packages/main/src/chat/agentManager.ts b/packages/main/src/chat/agentManager.ts index 5ba242b..7d0b0c8 100644 --- a/packages/main/src/chat/agentManager.ts +++ b/packages/main/src/chat/agentManager.ts @@ -35,6 +35,8 @@ import { ToolRegistry } from './toolRegistry' import type { BrowserPaneManager } from '../browserPaneManager' import type { ConversationStore } from './conversationStore' import type { TaskVariableContext } from '../tasks/taskVariableResolver' +import { shouldAutoApprove as evalShouldAutoApprove } from './permissionMatching' +import type { ToolApprovalOwner } from './approvalRouter' // ─── Constants ───────────────────────────────────────────────────── @@ -67,7 +69,7 @@ interface PendingApproval { // ─── Manager ─────────────────────────────────────────────────────── -export class AgentManager { +export class AgentManager implements ToolApprovalOwner { private sessions = new Map() private llmClient: LlmClient private toolRegistry: ToolRegistry @@ -666,59 +668,15 @@ export class AgentManager { }) } - // ─── Permission Checks ─────────���──────────────────────────── - - private static readonly READ_ONLY_TOOLS = new Set([ - 'file_read', 'file_list', 'search_files', 'git_status', 'git_diff', 'browser_read', - ]) + // ─── Permission Checks ──────────────────────────────────────── private shouldAutoApprove(toolName: string, input: Record): boolean { - // Per-tool overrides take precedence over tier - const override = this.autoApprove[toolName] - if (override === true) return true - if (override === false) return false - if (typeof override === 'object') { - return this.matchesPatternConfig(override, toolName, input) - } - - // Fall back to tier logic - switch (this.permissionTier) { - case 'autopilot': - return true - case 'auto-approve': - return AgentManager.READ_ONLY_TOOLS.has(toolName) - case 'confirm': - default: - return false - } - } - - private matchesPatternConfig( - config: ToolPermissionConfig, - toolName: string, - input: Record, - ): boolean { - // For terminal_exec, match against the command string; otherwise match stringified input - const matchTarget = toolName === 'terminal_exec' - ? String(input.command ?? '') - : JSON.stringify(input) - - // Deny patterns take precedence - if (config.denyPatterns?.some((p) => this.globMatch(matchTarget, p))) { - return false - } - // Must match at least one allow pattern - if (config.allowPatterns && config.allowPatterns.length > 0) { - return config.allowPatterns.some((p) => this.globMatch(matchTarget, p)) - } - return false + return evalShouldAutoApprove(toolName, input, this.permissionTier, this.autoApprove) } - private globMatch(text: string, pattern: string): boolean { - // Simple glob: * matches any sequence of characters - const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, '\\$&') - const regex = new RegExp('^' + escaped.replace(/\*/g, '.*') + '$') - return regex.test(text) + /** ToolApprovalOwner: does this manager own the given pending tool call? */ + ownsToolCall(toolCallId: string): boolean { + return this.pendingApprovals.has(toolCallId) } // ���── Message Conversion ─────────���──────────────────────────── diff --git a/packages/main/src/chat/approvalRouter.ts b/packages/main/src/chat/approvalRouter.ts new file mode 100644 index 0000000..9025233 --- /dev/null +++ b/packages/main/src/chat/approvalRouter.ts @@ -0,0 +1,65 @@ +/** + * ApprovalRouter — single approval surface for tool calls across both the + * built-in `AgentManager` and the external `CliAgentManager`. + * + * The renderer talks to one channel pair (`CHAT_TOOL_APPROVE` / + * `CHAT_TOOL_REJECT`) and one approval UI. The router decides which manager + * actually owns the pending tool call (by id) and dispatches the user's + * decision there. + * + * This keeps a single approval pane in the IDE for both backends, while + * letting each manager keep its own pending-approval map and resolution + * semantics. + */ + +export interface ToolApprovalOwner { + /** Returns true iff this owner is currently waiting on the given tool call id. */ + ownsToolCall(toolCallId: string): boolean + approveToolCall(sessionId: string, toolCallId: string): void + rejectToolCall(sessionId: string, toolCallId: string): void + /** Number of approvals currently awaiting user decision (used by refreshWorkload). */ + getPendingApprovalCount(): number +} + +export class ApprovalRouter { + private owners: ToolApprovalOwner[] = [] + + register(owner: ToolApprovalOwner): void { + if (!this.owners.includes(owner)) { + this.owners.push(owner) + } + } + + unregister(owner: ToolApprovalOwner): void { + const idx = this.owners.indexOf(owner) + if (idx >= 0) this.owners.splice(idx, 1) + } + + approve(sessionId: string, toolCallId: string): boolean { + for (const owner of this.owners) { + if (owner.ownsToolCall(toolCallId)) { + owner.approveToolCall(sessionId, toolCallId) + return true + } + } + return false + } + + reject(sessionId: string, toolCallId: string): boolean { + for (const owner of this.owners) { + if (owner.ownsToolCall(toolCallId)) { + owner.rejectToolCall(sessionId, toolCallId) + return true + } + } + return false + } + + getPendingApprovalCount(): number { + let total = 0 + for (const owner of this.owners) { + total += owner.getPendingApprovalCount() + } + return total + } +} diff --git a/packages/main/src/chat/cliAdapters/claudeCodeAdapter.ts b/packages/main/src/chat/cliAdapters/claudeCodeAdapter.ts index 11e1897..e030fa3 100644 --- a/packages/main/src/chat/cliAdapters/claudeCodeAdapter.ts +++ b/packages/main/src/chat/cliAdapters/claudeCodeAdapter.ts @@ -1,6 +1,7 @@ import { randomUUID } from 'crypto' import { query } from '@anthropic-ai/claude-agent-sdk' import type { Query } from '@anthropic-ai/claude-agent-sdk' +import type { CliAgentTokenUsage } from '@aide/shared' import type { CliBackendAdapter, CliBackendEvent, @@ -49,12 +50,16 @@ export function createClaudeCodeAdapter(options: ClaudeCodeAdapterOptions): CliB } let totalCostUsd = 0 + let totalTokens: CliAgentTokenUsage | undefined = undefined let sawResult = false for await (const message of queryInstance) { const events = normalizeClaudeMessage(message) for (const event of events) { if (event.type === 'result') { totalCostUsd += event.totalCostUsd + if (event.tokens) { + totalTokens = sumClaudeTokens(totalTokens, event.tokens) + } sawResult = true } emit(event) @@ -66,6 +71,7 @@ export function createClaudeCodeAdapter(options: ClaudeCodeAdapterOptions): CliB type: 'result', durationMs: Date.now() - startedAt, totalCostUsd, + tokens: totalTokens, isSuccess: true, }) } @@ -243,6 +249,7 @@ function normalizeClaudeMessage(message: any): CliBackendEvent[] { const sessionId = message.session_id as string | undefined const errors = Array.isArray(message.errors) ? (message.errors as string[]) : [] const errorDetail = errors.length > 0 ? errors.join('\n') : '' + const tokens = extractClaudeTokens(message.usage) const events: CliBackendEvent[] = [] if (sessionId) { @@ -263,6 +270,7 @@ function normalizeClaudeMessage(message: any): CliBackendEvent[] { timestamp: Date.now(), durationMs, totalCostUsd, + tokens, isSuccess, raw: message, }, @@ -272,6 +280,7 @@ function normalizeClaudeMessage(message: any): CliBackendEvent[] { type: 'result', durationMs, totalCostUsd, + tokens, isSuccess, }) @@ -280,3 +289,40 @@ function normalizeClaudeMessage(message: any): CliBackendEvent[] { return [] } + +/** Extract Claude SDK usage block (input_tokens / output_tokens / cache_*) into our shared shape. */ +function extractClaudeTokens(usage: unknown): CliAgentTokenUsage | undefined { + if (!usage || typeof usage !== 'object') return undefined + const u = usage as Record + const input = numberOr(u.input_tokens, 0) + const output = numberOr(u.output_tokens, 0) + const cacheRead = numberOr(u.cache_read_input_tokens, 0) + const cacheWrite = numberOr(u.cache_creation_input_tokens, 0) + if (input === 0 && output === 0 && cacheRead === 0 && cacheWrite === 0) return undefined + return { + input, + output, + reasoning: 0, + cacheRead, + cacheWrite, + } +} + +function sumClaudeTokens( + a: CliAgentTokenUsage | undefined, + b: CliAgentTokenUsage | undefined, +): CliAgentTokenUsage | undefined { + if (!a) return b + if (!b) return a + return { + input: a.input + b.input, + output: a.output + b.output, + reasoning: a.reasoning + b.reasoning, + cacheRead: a.cacheRead + b.cacheRead, + cacheWrite: a.cacheWrite + b.cacheWrite, + } +} + +function numberOr(value: unknown, fallback: number): number { + return typeof value === 'number' ? value : fallback +} diff --git a/packages/main/src/chat/cliAdapters/openCodeAdapter.ts b/packages/main/src/chat/cliAdapters/openCodeAdapter.ts index c5cc37b..8041ec3 100644 --- a/packages/main/src/chat/cliAdapters/openCodeAdapter.ts +++ b/packages/main/src/chat/cliAdapters/openCodeAdapter.ts @@ -1,420 +1,384 @@ -import { randomUUID } from 'crypto' -import { spawn, type ChildProcess } from 'child_process' -import { createServer } from 'net' -import { createOpencodeClient } from '@opencode-ai/sdk/client' -import type { CliBackendAdapter, CliBackendRun, CliBackendTurnContext } from './types' +/** + * OpenCode adapter — thin per-turn driver for the OpenCode SDK. + * + * Unlike the previous implementation, this adapter no longer spawns or owns a + * server process. The workspace's `OpenCodeServerHost` already runs a + * persistent `opencode serve` and exposes a shared SSE pump. The adapter: + * + * 1. Gets a client from the host + * 2. Creates an OpenCode session if one doesn't already exist + * 3. Subscribes to per-session SSE events via the host + * 4. Issues `promptAsync` with all overrides from backend state + * (model / agent / system / tools) + * 5. Converts each part via `openCodePartConverter` + * 6. Aggregates cost + tokens and emits a `result` event when the session + * goes idle (or fails) + * + * Permission events are forwarded to the manager via the new + * `permission-request` event variant — the manager bridges them to the + * existing CHAT_TOOL_CALL approval surface and POSTs the response back via + * `host.respondPermission()`. + */ -interface OpenCodeAdapterOptions { - executablePath: string +import { randomUUID } from 'crypto' +import type { CliAgentMessage, PermissionTier, ToolPermissionConfig } from '@aide/shared' +import type { OpenCodeServerHost } from '../openCodeServerHost' +import { decideOpenCodePermission } from './openCodePermissionBridge' +import { + convertOpenCodePart, + createConvertContext, + extractTokens, + sumTokens, +} from './openCodePartConverter' +import type { + CliBackendAdapter, + CliBackendEvent, + CliBackendRun, + CliBackendTurnContext, +} from './types' + +export interface OpenCodeAdapterOptions { + host: OpenCodeServerHost + /** Permission settings snapshot at the time the turn was initiated. */ + getPermissionSettings: () => { + tier: PermissionTier + autoApprove: Record + } } export function createOpenCodeAdapter(options: OpenCodeAdapterOptions): CliBackendAdapter { return { backend: 'opencode', startTurn(context, emit) { - let serverProc: ChildProcess | null = null - let currentSessionId = context.backendState.sessionId - let closed = false - - const completed = (async () => { - const startedAt = Date.now() - const port = await reservePort() - const url = `http://127.0.0.1:${port}` - logOpenCode('starting server', { - conversationId: context.conversationId, - hasSessionId: Boolean(currentSessionId), - cwd: context.cwd, - port, - }) - serverProc = spawn( - options.executablePath, - ['serve', '--hostname=127.0.0.1', `--port=${port}`], - { - env: { - ...process.env, - OPENCODE_CONFIG_CONTENT: JSON.stringify({}), - }, - stdio: ['ignore', 'pipe', 'pipe'], - }, - ) - - if (!serverProc) throw new Error('Failed to start OpenCode server process') - await waitForOpenCodeServer(serverProc, url) - logOpenCode('server ready', { url, conversationId: context.conversationId }) + return runOpenCodeTurn(options, context, emit) + }, + } +} - const client = createOpencodeClient({ - baseUrl: url, - directory: context.cwd, - responseStyle: 'data', - throwOnError: true, - }) +function runOpenCodeTurn( + options: OpenCodeAdapterOptions, + context: CliBackendTurnContext, + emit: (event: CliBackendEvent) => void, +): CliBackendRun { + const startedAt = Date.now() + let unsubscribe: (() => void) | null = null + let closed = false + let promptSubmitted = false + let sessionIdRef: string | null = context.backendState.sessionId ?? null + + const partCtx = createConvertContext() + const textByMessageId = new Map() + const emittedAssistantIds = new Set() + const seenPartFinalIds = new Set() + const costByMessageId = new Map() + const tokensByMessageId = new Map>() + let totalCostUsd = 0 + let totalTokens: ReturnType = undefined + let failedError: string | null = null + let idleResolve: (() => void) | null = null + const idlePromise = new Promise((resolve) => { + idleResolve = resolve + }) - if (!currentSessionId) { - const created = await client.session.create({ responseStyle: 'data', throwOnError: true }) - currentSessionId = extractOpenCodeSessionId(created) - logOpenCode('session created', { - conversationId: context.conversationId, - sessionId: currentSessionId, - }) - if (currentSessionId) { - emit({ type: 'backend-state', patch: { sessionId: currentSessionId } }) + const completed = (async () => { + const client = await options.host.getClient() + + // Ensure we have a session. + if (!sessionIdRef) { + const created = await ( + client as unknown as { + session: { + create: (opts?: { + body?: { title?: string } + query?: { directory?: string } + }) => Promise } } + ).session.create({ query: { directory: context.cwd } }) + const id = extractSessionId(created) + if (!id) throw new Error('OpenCode session.create returned no id') + sessionIdRef = id + emit({ type: 'backend-state', patch: { sessionId: id } }) + } + const sessionId = sessionIdRef + if (!sessionId) throw new Error('OpenCode session unavailable') - if (!currentSessionId) { - throw new Error('Failed to initialize OpenCode session') - } - - const sse = await client.global.event({ - signal: undefined, - onSseError(error) { - console.error('[OpenCodeAdapter] SSE error:', error) - }, - }) - const textByMessageId = new Map() - const timestampByMessageId = new Map() - const emittedAssistantIds = new Set() - const seenToolStates = new Map() - const costByMessageId = new Map() - let totalCostUsd = 0 - let failedError: string | null = null - let promptSubmitted = false - - const streamTask = (async () => { - for await (const rawEvent of sse.stream) { - const envelope = asRecord(rawEvent) - const event = asRecord(envelope?.payload) ?? envelope - const type = typeof event?.type === 'string' ? event.type : '' - const props = asRecord(event?.properties) - - logOpenCode('sse event', { - conversationId: context.conversationId, - sessionId: currentSessionId, - directory: asString(envelope?.directory), - type, - }) - - if (type === 'message.updated' && props?.info) { - const info = props.info as Record - const sessionId = asString(info.sessionID) - if (!sessionId || sessionId !== currentSessionId) continue - - const messageId = asString(info.id) ?? randomUUID() - if (info.role === 'assistant') { - const model = - [asString(info.providerID), asString(info.modelID)].filter(Boolean).join('/') || - undefined - const createdAt = - typeof info.time?.created === 'number' ? info.time.created : Date.now() - timestampByMessageId.set(messageId, createdAt) - emit({ type: 'session-meta', model }) - emit({ type: 'backend-state', patch: { sessionId, model } }) - logOpenCode('assistant message updated', { - sessionId, - messageId, - model, - }) - - const nextCost = typeof info.cost === 'number' ? info.cost : 0 - const prevCost = costByMessageId.get(messageId) ?? 0 - totalCostUsd += Math.max(0, nextCost - prevCost) - costByMessageId.set(messageId, nextCost) - - const errorText = renderOpenCodeError(info.error) - if (errorText) { - failedError = errorText - } - } - continue - } - - if (type === 'message.part.updated' && props?.part) { - const part = props.part as Record - if (asString(part.sessionID) !== currentSessionId) continue - const partType = asString(part.type) + // Subscribe BEFORE issuing the prompt so we don't miss early events. + unsubscribe = options.host.subscribe(sessionId, (rawEvent) => { + handleEvent(rawEvent) + }) - if (partType === 'text') { - const messageId = asString(part.messageID) ?? randomUUID() - const delta = asString(props.delta) - if (delta) { - const prior = textByMessageId.get(messageId) ?? '' - textByMessageId.set(messageId, prior + delta) - emit({ type: 'stream-delta', messageId, delta }) - logOpenCode('assistant delta', { - sessionId: currentSessionId, - messageId, - deltaLength: delta.length, - }) - } else { - textByMessageId.set(messageId, asString(part.text) ?? '') - logOpenCode('assistant part snapshot', { - sessionId: currentSessionId, - messageId, - textLength: (asString(part.text) ?? '').length, - }) - } - continue - } + // Build the prompt body using per-session backend state overrides. + const state = context.backendState + const body: Record = { + parts: [{ type: 'text', text: context.prompt }], + } + if (state.providerID && state.modelID) { + body.model = { providerID: state.providerID, modelID: state.modelID } + } else if (state.model && state.model.includes('/')) { + const [providerID, modelID] = state.model.split('/', 2) + body.model = { providerID, modelID } + } + if (state.agent) body.agent = state.agent + if (state.systemPromptOverride) body.system = state.systemPromptOverride + if (state.toolToggles) body.tools = state.toolToggles + + await ( + client as unknown as { + session: { + promptAsync: (opts: { + path: { id: string } + query?: { directory?: string } + body: Record + }) => Promise + } + } + ).session.promptAsync({ + path: { id: sessionId }, + query: { directory: context.cwd }, + body, + }) + promptSubmitted = true + + // Wait for session.idle (or session.error which sets failedError). + await idlePromise + + // Emit any final assistant text accumulators that weren't already emitted. + for (const [messageId, text] of textByMessageId) { + if (!text) continue + if (emittedAssistantIds.has(messageId)) continue + if (seenPartFinalIds.has(messageId)) continue + emittedAssistantIds.add(messageId) + const message: Omit = { + id: messageId, + type: 'assistant', + content: text, + timestamp: Date.now(), + tokens: tokensByMessageId.get(messageId), + costUsd: costByMessageId.get(messageId), + } + emit({ type: 'message', message }) + } - if (partType === 'tool') { - const partId = asString(part.id) ?? randomUUID() - const state = asRecord(part.state) - const status = asString(state?.status) ?? 'pending' - const priorStatus = seenToolStates.get(partId) - if (priorStatus === status) continue - seenToolStates.set(partId, status) + if (failedError) { + emit({ + type: 'message', + message: { + id: randomUUID(), + type: 'error', + content: failedError, + timestamp: Date.now(), + }, + }) + emit({ + type: 'result', + durationMs: Date.now() - startedAt, + totalCostUsd, + tokens: totalTokens, + isSuccess: false, + }) + return + } - const toolName = asString(part.tool) ?? 'tool' - if (status === 'pending' || status === 'running') { - emit({ - type: 'message', - message: { - id: partId, - type: 'tool_use', - content: `Running ${toolName}...`, - timestamp: Date.now(), - toolName, - toolUseId: asString(part.callID), - }, - }) - } else if (status === 'completed') { - emit({ - type: 'message', - message: { - id: partId, - type: 'tool_result', - content: - asString(state?.output) ?? - asString(state?.title) ?? - `${toolName} completed`, - timestamp: Date.now(), - toolName, - toolUseId: asString(part.callID), - }, - }) - } else if (status === 'error') { - emit({ - type: 'message', - message: { - id: partId, - type: 'error', - content: asString(state?.error) ?? `${toolName} failed`, - timestamp: Date.now(), - toolName, - toolUseId: asString(part.callID), - }, - }) - } - } - continue - } + emit({ + type: 'message', + message: { + id: randomUUID(), + type: 'result', + content: `Completed in ${((Date.now() - startedAt) / 1000).toFixed(1)}s`, + timestamp: Date.now(), + totalCostUsd, + tokens: totalTokens, + isSuccess: true, + }, + }) + emit({ + type: 'result', + durationMs: Date.now() - startedAt, + totalCostUsd, + tokens: totalTokens, + isSuccess: true, + }) + })().finally(() => { + if (unsubscribe) { + try { + unsubscribe() + } catch { + /* ignore */ + } + unsubscribe = null + } + }) - if (type === 'session.error' && props) { - if ( - currentSessionId && - asString(props.sessionID) && - asString(props.sessionID) !== currentSessionId - ) { - continue - } - failedError = renderOpenCodeError(props.error) ?? 'OpenCode session failed' - console.error('[OpenCodeAdapter] session.error', { - conversationId: context.conversationId, - sessionId: currentSessionId, - error: failedError, - }) - continue - } + function handleEvent(rawEvent: unknown): void { + if (closed) return + const event = rawEvent as { type?: string; properties?: Record } + const type = typeof event.type === 'string' ? event.type : '' + const props = (event.properties ?? {}) as Record + + if (type === 'message.updated' && (props.info as Record | undefined)) { + const info = props.info as Record + const role = info.role + const messageId = (info.id as string | undefined) ?? randomUUID() + if (role === 'assistant') { + const providerID = info.providerID as string | undefined + const modelID = info.modelID as string | undefined + const model = [providerID, modelID].filter(Boolean).join('/') || undefined + if (model) { + emit({ type: 'session-meta', model }) + emit({ type: 'backend-state', patch: { sessionId: sessionIdRef ?? undefined, model } }) + } + const cost = typeof info.cost === 'number' ? info.cost : 0 + const prevCost = costByMessageId.get(messageId) ?? 0 + const delta = Math.max(0, cost - prevCost) + totalCostUsd += delta + costByMessageId.set(messageId, cost) + + const tokens = extractTokens(info.tokens) + if (tokens) { + tokensByMessageId.set(messageId, tokens) + totalTokens = recomputeTotals(tokensByMessageId) + } - if ( - type === 'session.idle' && - asString(props?.sessionID) === currentSessionId && - promptSubmitted - ) { - logOpenCode('session idle', { - conversationId: context.conversationId, - sessionId: currentSessionId, - }) - break - } - } - })() + const errorText = renderOpenCodeError(info.error) + if (errorText) failedError = errorText + } + return + } - logOpenCode('submitting prompt', { - conversationId: context.conversationId, - sessionId: currentSessionId, - promptLength: context.prompt.length, - }) - await client.session.promptAsync({ - responseStyle: 'data', - throwOnError: true, - path: { id: currentSessionId }, - body: { - parts: [{ type: 'text', text: context.prompt }], - }, - }) - promptSubmitted = true - logOpenCode('prompt submitted', { - conversationId: context.conversationId, - sessionId: currentSessionId, - }) + if (type === 'message.part.updated' && props.part) { + const part = props.part as Record + const messageId = (part.messageID as string | undefined) ?? randomUUID() + const delta = typeof props.delta === 'string' ? (props.delta as string) : undefined + const converted = convertOpenCodePart(part, delta, partCtx) - await streamTask - logOpenCode('stream complete', { - conversationId: context.conversationId, - sessionId: currentSessionId, - assistantMessages: textByMessageId.size, - failed: Boolean(failedError), + if (converted.isTextDelta && converted.delta) { + const prior = textByMessageId.get(converted.messageId ?? messageId) ?? '' + textByMessageId.set(converted.messageId ?? messageId, prior + converted.delta) + emit({ + type: 'stream-delta', + messageId: converted.messageId ?? messageId, + delta: converted.delta, }) - - for (const [messageId, text] of textByMessageId) { - if (!text || emittedAssistantIds.has(messageId)) continue - emittedAssistantIds.add(messageId) - emit({ - type: 'message', - message: { - id: messageId, - type: 'assistant', - content: text, - timestamp: timestampByMessageId.get(messageId) ?? Date.now(), - }, - }) + } + for (const message of converted.messages) { + if (message.type === 'assistant') { + emittedAssistantIds.add(message.id) + seenPartFinalIds.add(message.id) } + emit({ type: 'message', message }) + } + return + } - if (failedError) { - emit({ - type: 'message', - message: { - id: randomUUID(), - type: 'error', - content: failedError, - timestamp: Date.now(), - }, - }) - emit({ - type: 'result', - durationMs: Date.now() - startedAt, - totalCostUsd, - isSuccess: false, - }) - return - } + if (type === 'permission.updated' && props) { + const perm = props as Record + const permissionId = perm.id as string | undefined + const sessionID = perm.sessionID as string | undefined + if (!permissionId || !sessionID) return + + const settings = options.getPermissionSettings() + const decision = decideOpenCodePermission( + { + category: (perm.type as string) ?? 'unknown', + pattern: perm.pattern as string | string[] | undefined, + metadata: perm.metadata as Record | undefined, + }, + settings.tier, + settings.autoApprove, + ) - emit({ - type: 'message', - message: { - id: randomUUID(), - type: 'result', - content: `Completed in ${((Date.now() - startedAt) / 1000).toFixed(1)}s`, - timestamp: Date.now(), - totalCostUsd, - isSuccess: true, - }, - }) - emit({ - type: 'result', - durationMs: Date.now() - startedAt, - totalCostUsd, - isSuccess: true, + if (decision === 'always' || decision === 'reject') { + void options.host.respondPermission(sessionID, permissionId, decision).catch(() => { + // Surface as error if response fails. }) - })().finally(() => { - if (!closed) { - logOpenCode('stopping server', { - conversationId: context.conversationId, - sessionId: currentSessionId, - }) - serverProc?.kill('SIGTERM') - } - }) + return + } - return { - close() { - closed = true - serverProc?.kill('SIGTERM') + // Forward to the manager via a synthetic permission-request event. + emit({ + type: 'permission-request', + request: { + permissionId, + sessionId: sessionID, + category: (perm.type as string) ?? 'unknown', + title: (perm.title as string) ?? 'Permission required', + pattern: perm.pattern as string | string[] | undefined, + metadata: perm.metadata as Record | undefined, + }, + resolve: (response) => { + void options.host.respondPermission(sessionID, permissionId, response).catch(() => { + /* ignore */ + }) }, - completed, - } satisfies CliBackendRun - }, - } -} - -async function reservePort(): Promise { - return await new Promise((resolve, reject) => { - const server = createServer() - server.once('error', reject) - server.listen(0, '127.0.0.1', () => { - const address = server.address() - const port = typeof address === 'object' && address ? address.port : null - server.close((error) => { - if (error) reject(error) - else if (typeof port === 'number') resolve(port) - else reject(new Error('Failed to reserve OpenCode port')) }) - }) - }) -} - -async function waitForOpenCodeServer(proc: ChildProcess, url: string): Promise { - await new Promise((resolve, reject) => { - const timeout = setTimeout(() => { - reject(new Error('Timeout waiting for OpenCode server to start')) - }, 5000) + return + } - let output = '' - const onData = (chunk: Buffer) => { - output += chunk.toString() - if (output.includes(url) || output.includes('opencode server listening')) { - cleanup() - resolve() + if (type === 'session.error' && props) { + if (sessionIdRef && typeof props.sessionID === 'string' && props.sessionID !== sessionIdRef) { + return } + failedError = renderOpenCodeError(props.error) ?? 'OpenCode session failed' + if (idleResolve) { + idleResolve() + idleResolve = null + } + return } - const onExit = (code: number | null) => { - cleanup() - reject(new Error(`OpenCode server exited with code ${code}${output ? `\n${output}` : ''}`)) - } - const onError = (error: Error) => { - cleanup() - reject(error) - } - const cleanup = () => { - clearTimeout(timeout) - proc.stdout?.off('data', onData) - proc.stderr?.off('data', onData) - proc.off('exit', onExit) - proc.off('error', onError) - } - - proc.stdout?.on('data', onData) - proc.stderr?.on('data', onData) - proc.once('exit', onExit) - proc.once('error', onError) - }) -} -function asRecord(value: unknown): Record | null { - return value && typeof value === 'object' ? (value as Record) : null -} + if (type === 'session.idle' && props.sessionID === sessionIdRef && promptSubmitted) { + if (idleResolve) { + idleResolve() + idleResolve = null + } + return + } + } -function asString(value: unknown): string | undefined { - return typeof value === 'string' ? value : undefined + return { + close() { + closed = true + if (idleResolve) { + idleResolve() + idleResolve = null + } + if (unsubscribe) { + try { + unsubscribe() + } catch { + /* ignore */ + } + unsubscribe = null + } + }, + completed, + } } -function extractOpenCodeSessionId(value: unknown): string | undefined { - const payload = asRecord(value) - return asString(payload?.id) ?? asString(asRecord(payload?.data)?.id) +function recomputeTotals( + byId: Map>, +): ReturnType { + let acc: ReturnType = undefined + for (const value of byId.values()) { + acc = sumTokens(acc, value) + } + return acc } -function logOpenCode(message: string, data?: Record): void { - if (data) { - console.log(`[OpenCodeAdapter] ${message}`, data) - return - } - console.log(`[OpenCodeAdapter] ${message}`) +function extractSessionId(value: unknown): string | null { + if (!value || typeof value !== 'object') return null + const v = value as Record + if (typeof v.id === 'string') return v.id + const data = v.data as Record | undefined + if (data && typeof data.id === 'string') return data.id + return null } function renderOpenCodeError(value: unknown): string | null { - const error = asRecord(value) - const data = asRecord(error?.data) - const message = asString(data?.message) + if (!value || typeof value !== 'object') return null + const error = value as Record + const data = (error.data ?? {}) as Record + const message = typeof data.message === 'string' ? (data.message as string) : null return message ?? null } diff --git a/packages/main/src/chat/cliAdapters/openCodePartConverter.ts b/packages/main/src/chat/cliAdapters/openCodePartConverter.ts new file mode 100644 index 0000000..a51808b --- /dev/null +++ b/packages/main/src/chat/cliAdapters/openCodePartConverter.ts @@ -0,0 +1,418 @@ +/** + * OpenCode Part → CliAgentMessage(s) converter. + * + * Maps every part `type` discriminant exposed by `@opencode-ai/sdk` onto our + * normalized `CliAgentMessage` shape. Returns one or more messages per part + * (e.g. a tool part may emit a single tool_use OR tool_result message + * depending on its current state). + * + * Unknown / unhandled types fall back to a generic `system` message so the + * renderer can still display them. + */ + +import { randomUUID } from 'crypto' +import type { CliAgentMessage, CliAgentTokenUsage } from '@aide/shared' + +interface BasePart { + id?: string + sessionID?: string + messageID?: string + type?: string + [key: string]: unknown +} + +interface ConvertContext { + /** Optional delta string from the part.updated event (text deltas only). */ + delta?: string + /** Track which tool partIds we've already emitted in which state. */ + seenToolStates: Map + /** Track which reasoning partIds we've already emitted (to avoid duplicate emit on each delta). */ + seenReasoningIds: Set +} + +export function createConvertContext(): ConvertContext { + return { + seenToolStates: new Map(), + seenReasoningIds: new Set(), + } +} + +export interface ConvertedPart { + /** Messages to emit. May be empty if the part is purely a delta. */ + messages: CliAgentMessage[] + /** True if this part is a streaming text delta (caller should also emit a stream-delta event). */ + isTextDelta: boolean + /** Text delta value, present iff isTextDelta. */ + delta?: string + /** The messageId for the text accumulator. */ + messageId?: string +} + +/** + * Convert a single OpenCode `part` event payload into normalized messages. + * + * The `delta` argument comes from `props.delta` on `message.part.updated`. + */ +export function convertOpenCodePart( + rawPart: unknown, + rawDelta: string | undefined, + ctx: ConvertContext, +): ConvertedPart { + const part = (rawPart ?? {}) as BasePart + const partType = typeof part.type === 'string' ? part.type : '' + const partId = (typeof part.id === 'string' ? part.id : null) ?? randomUUID() + const messageId = typeof part.messageID === 'string' ? part.messageID : undefined + const now = Date.now() + + switch (partType) { + case 'text': { + const text = stringField(part, 'text') ?? '' + const synthetic = part.synthetic === true + const ignored = part.ignored === true + if (ignored) return { messages: [], isTextDelta: false } + if (rawDelta) { + return { + messages: [], + isTextDelta: true, + delta: rawDelta, + messageId: messageId ?? partId, + } + } + // Final / complete text part — emit as assistant message. + return { + messages: [ + { + id: messageId ?? partId, + type: synthetic ? 'system' : 'assistant', + content: text, + timestamp: now, + raw: part, + }, + ], + isTextDelta: false, + } + } + + case 'reasoning': { + // Stream reasoning text incrementally; first occurrence creates the + // message, subsequent updates patch it. We emit one message at the end + // (when text is non-empty) for simplicity. + const text = stringField(part, 'text') ?? '' + if (!text || ctx.seenReasoningIds.has(partId)) { + return { messages: [], isTextDelta: false } + } + ctx.seenReasoningIds.add(partId) + return { + messages: [ + { + id: partId, + type: 'reasoning', + content: text, + timestamp: now, + reasoningCollapsed: true, + raw: part, + }, + ], + isTextDelta: false, + } + } + + case 'file': { + const mime = stringField(part, 'mime') ?? 'application/octet-stream' + const url = stringField(part, 'url') ?? '' + const filename = stringField(part, 'filename') + return { + messages: [ + { + id: partId, + type: 'file_attachment', + content: filename ?? url, + timestamp: now, + fileMime: mime, + fileUrl: url, + fileName: filename, + raw: part, + }, + ], + isTextDelta: false, + } + } + + case 'tool': { + const state = (part.state ?? {}) as Record + const status = stringField(state, 'status') ?? 'pending' + const priorStatus = ctx.seenToolStates.get(partId) + if (priorStatus === status) { + return { messages: [], isTextDelta: false } + } + ctx.seenToolStates.set(partId, status) + + const toolName = stringField(part, 'tool') ?? 'tool' + const toolUseId = stringField(part, 'callID') + + if (status === 'pending' || status === 'running') { + return { + messages: [ + { + id: partId, + type: 'tool_use', + content: `Running ${toolName}...`, + timestamp: now, + toolName, + toolUseId, + raw: part, + }, + ], + isTextDelta: false, + } + } + if (status === 'completed') { + const output = + stringField(state, 'output') ?? stringField(state, 'title') ?? `${toolName} completed` + return { + messages: [ + { + id: partId, + type: 'tool_result', + content: output, + timestamp: now, + toolName, + toolUseId, + raw: part, + }, + ], + isTextDelta: false, + } + } + if (status === 'error') { + return { + messages: [ + { + id: partId, + type: 'error', + content: stringField(state, 'error') ?? `${toolName} failed`, + timestamp: now, + toolName, + toolUseId, + raw: part, + }, + ], + isTextDelta: false, + } + } + return { messages: [], isTextDelta: false } + } + + case 'step-start': { + return { + messages: [ + { + id: partId, + type: 'step', + content: 'Step started', + timestamp: now, + stepPhase: 'start', + stepSnapshot: stringField(part, 'snapshot'), + raw: part, + }, + ], + isTextDelta: false, + } + } + + case 'step-finish': { + const cost = numberField(part, 'cost') ?? 0 + const tokens = extractTokens(part.tokens) + return { + messages: [ + { + id: partId, + type: 'step', + content: stringField(part, 'reason') ?? 'Step completed', + timestamp: now, + stepPhase: 'finish', + stepReason: stringField(part, 'reason'), + stepSnapshot: stringField(part, 'snapshot'), + costUsd: cost, + tokens, + raw: part, + }, + ], + isTextDelta: false, + } + } + + case 'snapshot': { + return { + messages: [ + { + id: partId, + type: 'snapshot', + content: 'Snapshot captured', + timestamp: now, + snapshotHash: stringField(part, 'snapshot'), + raw: part, + }, + ], + isTextDelta: false, + } + } + + case 'patch': { + const files = Array.isArray(part.files) + ? (part.files as unknown[]).filter((f): f is string => typeof f === 'string') + : [] + return { + messages: [ + { + id: partId, + type: 'patch', + content: files.length === 1 ? files[0] : `${files.length} files`, + timestamp: now, + patchHash: stringField(part, 'hash'), + patchFiles: files, + raw: part, + }, + ], + isTextDelta: false, + } + } + + case 'agent': { + return { + messages: [ + { + id: partId, + type: 'agent_change', + content: stringField(part, 'name') ?? 'agent', + timestamp: now, + agentName: stringField(part, 'name'), + raw: part, + }, + ], + isTextDelta: false, + } + } + + case 'retry': { + const error = (part.error ?? {}) as Record + const errMsg = + stringField((error.data as Record | undefined) ?? {}, 'message') ?? + stringField(error, 'message') ?? + 'retrying' + return { + messages: [ + { + id: partId, + type: 'retry', + content: `Attempt ${numberField(part, 'attempt') ?? 1}: ${errMsg}`, + timestamp: now, + retryAttempt: numberField(part, 'attempt'), + raw: part, + }, + ], + isTextDelta: false, + } + } + + case 'compaction': { + return { + messages: [ + { + id: partId, + type: 'compaction', + content: 'Context compacted', + timestamp: now, + compactionAuto: part.auto === true, + raw: part, + }, + ], + isTextDelta: false, + } + } + + case 'subtask': { + return { + messages: [ + { + id: partId, + type: 'subtask', + content: stringField(part, 'description') ?? stringField(part, 'prompt') ?? 'Subtask', + timestamp: now, + subtaskPrompt: stringField(part, 'prompt'), + subtaskDescription: stringField(part, 'description'), + subtaskAgent: stringField(part, 'agent'), + raw: part, + }, + ], + isTextDelta: false, + } + } + + default: { + // Unknown part type — emit as a generic system message so it isn't lost. + return { + messages: [ + { + id: partId, + type: 'system', + content: `Unknown part: ${partType}`, + timestamp: now, + raw: part, + }, + ], + isTextDelta: false, + } + } + } +} + +function stringField(value: unknown, key: string): string | undefined { + if (!value || typeof value !== 'object') return undefined + const v = (value as Record)[key] + return typeof v === 'string' ? v : undefined +} + +function numberField(value: unknown, key: string): number | undefined { + if (!value || typeof value !== 'object') return undefined + const v = (value as Record)[key] + return typeof v === 'number' ? v : undefined +} + +/** Extract token counts from an OpenCode AssistantMessage / StepFinishPart-style tokens object. */ +export function extractTokens(value: unknown): CliAgentTokenUsage | undefined { + if (!value || typeof value !== 'object') return undefined + const t = value as Record + const cache = (t.cache ?? {}) as Record + const input = numberField(t, 'input') ?? 0 + const output = numberField(t, 'output') ?? 0 + const reasoning = numberField(t, 'reasoning') ?? 0 + const cacheRead = numberField(cache, 'read') ?? 0 + const cacheWrite = numberField(cache, 'write') ?? 0 + if ( + input === 0 && + output === 0 && + reasoning === 0 && + cacheRead === 0 && + cacheWrite === 0 + ) { + return undefined + } + return { input, output, reasoning, cacheRead, cacheWrite } +} + +/** Sum two token usage records. */ +export function sumTokens( + a: CliAgentTokenUsage | undefined, + b: CliAgentTokenUsage | undefined, +): CliAgentTokenUsage | undefined { + if (!a) return b + if (!b) return a + return { + input: a.input + b.input, + output: a.output + b.output, + reasoning: a.reasoning + b.reasoning, + cacheRead: a.cacheRead + b.cacheRead, + cacheWrite: a.cacheWrite + b.cacheWrite, + } +} diff --git a/packages/main/src/chat/cliAdapters/openCodePermissionBridge.ts b/packages/main/src/chat/cliAdapters/openCodePermissionBridge.ts new file mode 100644 index 0000000..3f8a8eb --- /dev/null +++ b/packages/main/src/chat/cliAdapters/openCodePermissionBridge.ts @@ -0,0 +1,158 @@ +/** + * IDE ↔ OpenCode permission bridge. + * + * Two responsibilities: + * + * 1. **Pre-flight** (`buildOpenCodePermissionConfig`): convert the IDE's + * `agent.permissionTier` + `agent.autoApprove` settings into an OpenCode + * agent `permission` config block. Pushed to the SDK once at session + * start so OpenCode itself short-circuits cheap cases. + * + * 2. **Runtime** (`decideOpenCodePermission`): when OpenCode raises a + * `permission.updated` event mid-turn, decide whether to auto-allow, + * auto-deny, or prompt the user. Uses the same `evaluatePermission` + * logic as the built-in `AgentManager`, with a category mapping from + * OpenCode's permission categories ('edit' / 'bash' / 'webfetch' / …) + * to the IDE's tool names ('file_write' / 'terminal_exec' / …). + */ + +import type { PermissionTier, ToolPermissionConfig } from '@aide/shared' +import { evaluatePermission } from '../permissionMatching' + +/** + * OpenCode agent permission categories accepted by the SDK + * (see `@opencode-ai/sdk` AgentConfig.permission). + */ +export type OpenCodePermissionCategory = + | 'edit' + | 'bash' + | 'webfetch' + | 'doom_loop' + | 'external_directory' + +export type OpenCodePermissionDecision = 'allow' | 'ask' | 'deny' +export type OpenCodePermissionResponse = 'always' | 'once' | 'reject' + +/** + * Map an OpenCode permission category onto the IDE tool name used by + * `agent.autoApprove`. The mapping is intentionally narrow — IDE tool names + * with no OpenCode counterpart simply have no auto-approve effect on + * OpenCode-side prompts. + */ +const CATEGORY_TO_IDE_TOOL: Record = { + edit: 'file_write', + bash: 'terminal_exec', + webfetch: 'browser_read', + doom_loop: 'doom_loop', + external_directory: 'external_directory', +} + +/** Inverse for diagnostic / fall-through use. */ +export function ideToolNameForCategory(category: string): string { + return (CATEGORY_TO_IDE_TOOL as Record)[category] ?? category +} + +/** + * Build an OpenCode `agent.permission` config block from the IDE settings. + * + * Tier rules: + * - `autopilot` → all 'allow' + * - `auto-approve` → 'webfetch' (read-only) → 'allow'; 'edit' / 'bash' / others → 'ask' + * - `confirm` → all 'ask' + * + * `autoApprove` overrides: + * - `true` → 'allow' for that category + * - `false` → 'deny' for that category + * - patterns (object) → 'ask' (we still need the runtime decision) + */ +export function buildOpenCodePermissionConfig( + tier: PermissionTier, + autoApprove: Record, +): Record { + const out = {} as Record + const categories: OpenCodePermissionCategory[] = [ + 'edit', + 'bash', + 'webfetch', + 'doom_loop', + 'external_directory', + ] + + for (const category of categories) { + const ideTool = CATEGORY_TO_IDE_TOOL[category] + const override = autoApprove[ideTool] + + if (override === true) { + out[category] = 'allow' + continue + } + if (override === false) { + out[category] = 'deny' + continue + } + if (typeof override === 'object' && override !== null) { + // Pattern config — still need runtime evaluation for each call. + out[category] = 'ask' + continue + } + + switch (tier) { + case 'autopilot': + out[category] = 'allow' + break + case 'auto-approve': + // Only webfetch is "read-only" in IDE terms + out[category] = category === 'webfetch' ? 'allow' : 'ask' + break + case 'confirm': + default: + out[category] = 'ask' + break + } + } + return out +} + +export interface DecideInput { + category: string + pattern?: string | string[] + metadata?: Record +} + +/** + * Decide what to do with a runtime OpenCode permission request. Returns + * `'prompt'` when the user must be asked; otherwise returns the OpenCode + * response value to POST back. + */ +export function decideOpenCodePermission( + input: DecideInput, + tier: PermissionTier, + autoApprove: Record, +): OpenCodePermissionResponse | 'prompt' { + const ideTool = ideToolNameForCategory(input.category) + const matchInput = buildMatchInput(input) + + const decision = evaluatePermission(ideTool, matchInput, tier, autoApprove) + if (decision === 'allow') return 'always' + if (decision === 'deny') return 'reject' + return 'prompt' +} + +function buildMatchInput(input: DecideInput): Record { + // For bash-like categories, the pattern is the shell command(s) — feed it as + // `command` so the existing matchTargetFor() shortcut for terminal_exec + // matches against the command string. + if (input.category === 'bash') { + if (Array.isArray(input.pattern)) { + return { command: input.pattern.join(' && ') } + } + return { command: input.pattern ?? '' } + } + + // For edit / webfetch / external_directory, treat the pattern as a path/URL + // and stringify the whole input so glob patterns can match against it. + return { + pattern: input.pattern, + ...input.metadata, + } +} diff --git a/packages/main/src/chat/cliAdapters/types.ts b/packages/main/src/chat/cliAdapters/types.ts index 04bb59d..082f466 100644 --- a/packages/main/src/chat/cliAdapters/types.ts +++ b/packages/main/src/chat/cliAdapters/types.ts @@ -1,4 +1,9 @@ -import type { CliAgentBackendState, CliAgentMessage, ExternalCliBackend } from '@aide/shared' +import type { + CliAgentBackendState, + CliAgentMessage, + CliAgentTokenUsage, + ExternalCliBackend, +} from '@aide/shared' export interface CliBackendTurnContext { conversationId: string @@ -7,12 +12,43 @@ export interface CliBackendTurnContext { backendState: CliAgentBackendState } +/** + * Permission request raised by an adapter (currently OpenCode) when the + * backend needs user approval for a tool/operation. The manager bridges these + * to the existing CHAT_TOOL_CALL surface via ApprovalRouter. + */ +export interface CliBackendPermissionRequest { + /** Stable id from the backend (e.g. OpenCode permission.id). */ + permissionId: string + /** Backend session id. */ + sessionId: string + /** Permission category (e.g. 'edit' | 'bash' | 'webfetch'). */ + category: string + /** Human-readable title. */ + title: string + /** Optional pattern (file path / shell command / URL). */ + pattern?: string | string[] + metadata?: Record +} + export type CliBackendEvent = | { type: 'stream-delta'; messageId: string; delta: string } | { type: 'message'; message: Omit } | { type: 'backend-state'; patch: Partial } | { type: 'session-meta'; model?: string; tools?: string[] } - | { type: 'result'; durationMs: number; totalCostUsd: number; isSuccess: boolean } + | { + type: 'result' + durationMs: number + totalCostUsd: number + tokens?: CliAgentTokenUsage + isSuccess: boolean + } + | { + type: 'permission-request' + request: CliBackendPermissionRequest + /** Resolved by the manager once the user (or auto-approval) decides. */ + resolve: (response: 'once' | 'always' | 'reject') => void + } export interface CliBackendRun { close(): void diff --git a/packages/main/src/chat/cliAgentManager.ts b/packages/main/src/chat/cliAgentManager.ts index df53558..91ca3e6 100644 --- a/packages/main/src/chat/cliAgentManager.ts +++ b/packages/main/src/chat/cliAgentManager.ts @@ -1,9 +1,16 @@ /** * CLI Agent Manager — manages external CLI agent sessions. * - * Unlike the original Claude-only implementation, this manager now owns the - * generic session lifecycle for all external backends and delegates transport - * details to backend adapters. + * Owns the generic session lifecycle for all external backends (claude-code, + * opencode, codex), delegates per-turn transport details to backend adapters, + * and bridges OpenCode's full SDK surface (sessions / config / providers / + * file / find / shell / lsp / etc.) into IPC-callable manager methods. + * + * Multi-workspace lifecycle: one CliAgentManager per WorkspaceRuntime, each + * owning its own per-workspace OpenCodeServerHost. The manager implements + * `ToolApprovalOwner` so its OpenCode permission prompts can flow through the + * single ApprovalRouter / CHAT_TOOL_CALL approval surface shared with the + * built-in agent. */ import { randomUUID } from 'crypto' @@ -14,28 +21,50 @@ import { app, type WebContents } from 'electron' import { IpcChannels, deriveTitle } from '@aide/shared' import type { AgentBackend, + CliAgentBackendState, CliAgentBackendStateMap, CliAgentMessage, CliAgentMessagePayload, + CliAgentPermissionRequest, CliAgentProcessStatus, CliAgentResultPayload, CliAgentSession, CliAgentStatusPayload, CliAgentStreamDelta, + CliAgentTokenUsage, + CliAgentWorkspaceCostSummary, ConversationListChangedPayload, ExternalCliBackend, + OpenCodeAgentSummary, + OpenCodeAuthMethod, + OpenCodeFileEntry, + OpenCodeFindResult, + OpenCodePathInfo, + OpenCodeProviderSummary, + OpenCodeServerInfo, + OpenCodeShellResult, + OpenCodeSymbolResult, + OpenCodeToolSummary, + OpenCodeTodoItem, + PermissionTier, + ToolPermissionConfig, } from '@aide/shared' import type { ConversationStore } from './conversationStore' import { createClaudeCodeAdapter } from './cliAdapters/claudeCodeAdapter' import { createCodexAdapter } from './cliAdapters/codexAdapter' import { createOpenCodeAdapter } from './cliAdapters/openCodeAdapter' import type { CliBackendAdapter, CliBackendEvent, CliBackendRun } from './cliAdapters/types' +import { OpenCodeServerHost } from './openCodeServerHost' +import type { ToolApprovalOwner } from './approvalRouter' +import type { ChatToolCallPayload, ToolCall } from '@aide/shared' interface PersistedCliConversation { messages?: CliAgentMessage[] activeBackend?: ExternalCliBackend backendStates?: CliAgentBackendStateMap claudeSessionId?: string + totalCostUsd?: number + totalTokens?: CliAgentTokenUsage } interface CliAgentSessionInternal { @@ -49,18 +78,29 @@ interface CliAgentSessionInternal { sessionToolNames?: string[] lastError?: string totalCostUsd: number + totalTokens?: CliAgentTokenUsage worktreePath?: string backendStates: CliAgentBackendStateMap } +interface PendingPermission { + sessionId: string + resolve: (response: 'always' | 'once' | 'reject') => void +} + export interface CliAgentManagerOpts { workspaceRoot: string + workspaceId?: string getWebContents: () => WebContents | null claudeCodePath?: string opencodePath?: string codexPath?: string conversationStore?: ConversationStore loadClaudeHistory?: (claudeSessionId: string) => Promise + permissionTier?: PermissionTier + autoApprove?: Record + /** Called when pending approvals / running session counts change. */ + onWorkloadChanged?: () => void } function comparableHistoryCount(messages: CliAgentMessage[]): number { @@ -92,12 +132,30 @@ function parsePersistedConversation(raw: unknown): PersistedCliConversation { activeBackend: persisted.activeBackend, backendStates, claudeSessionId: persisted.claudeSessionId, + totalCostUsd: persisted.totalCostUsd, + totalTokens: persisted.totalTokens, + } +} + +function sumTokenUsage( + a: CliAgentTokenUsage | undefined, + b: CliAgentTokenUsage | undefined, +): CliAgentTokenUsage | undefined { + if (!a) return b + if (!b) return a + return { + input: a.input + b.input, + output: a.output + b.output, + reasoning: a.reasoning + b.reasoning, + cacheRead: a.cacheRead + b.cacheRead, + cacheWrite: a.cacheWrite + b.cacheWrite, } } -export class CliAgentManager { +export class CliAgentManager implements ToolApprovalOwner { private sessions = new Map() private readonly workspaceRoot: string + private readonly workspaceId: string private readonly getWebContents: () => WebContents | null private claudeCodePath: string private opencodePath: string @@ -107,19 +165,31 @@ export class CliAgentManager { | ((claudeSessionId: string) => Promise) | null private resolvedClaudeCodePath: string | null = null - private resolvedOpenCodePath: string | null = null private resolvedCodexPath: string | null = null + private permissionTier: PermissionTier + private autoApprove: Record + private readonly onWorkloadChanged?: () => void + + private openCodeHost: OpenCodeServerHost | null = null + private pendingPermissions = new Map() + constructor(opts: CliAgentManagerOpts) { this.workspaceRoot = opts.workspaceRoot + this.workspaceId = opts.workspaceId ?? '' this.getWebContents = opts.getWebContents this.claudeCodePath = opts.claudeCodePath ?? '' this.opencodePath = opts.opencodePath ?? '' this.codexPath = opts.codexPath ?? '' this.conversationStore = opts.conversationStore ?? null this.loadClaudeHistory = opts.loadClaudeHistory ?? null + this.permissionTier = opts.permissionTier ?? 'confirm' + this.autoApprove = opts.autoApprove ?? {} + this.onWorkloadChanged = opts.onWorkloadChanged } + // ─── Lifecycle ────────────────────────────────────────────── + async start( workspaceId: string, backend: AgentBackend, @@ -198,7 +268,8 @@ export class CliAgentManager { activeRun: null, processStatus: 'stopped', messages: existingMessages, - totalCostUsd: 0, + totalCostUsd: persisted.totalCostUsd ?? 0, + totalTokens: persisted.totalTokens, model: backendStates[backend]?.model, worktreePath, backendStates, @@ -277,6 +348,7 @@ export class CliAgentManager { session.activeRun = run this.setStatus(session, 'running') + this.notifyWorkloadChanged() run.completed .catch((error) => { @@ -291,7 +363,15 @@ export class CliAgentManager { if (session.processStatus === 'stopping' || session.processStatus === 'running') { this.setStatus(session, 'stopped') } + // Clear any unresolved permissions for this session. + for (const [id, pending] of this.pendingPermissions) { + if (pending.sessionId === session.id) { + pending.resolve('reject') + this.pendingPermissions.delete(id) + } + } await this.persistSession(session) + this.notifyWorkloadChanged() }) return { success: true } @@ -334,8 +414,19 @@ export class CliAgentManager { this.opencodePath = opencodePath this.codexPath = codexPath this.resolvedClaudeCodePath = null - this.resolvedOpenCodePath = null this.resolvedCodexPath = null + if (this.openCodeHost) { + this.openCodeHost.setPath(opencodePath) + } + } + + /** Update permission tier / autoApprove map (called from settings-changed handler). */ + updatePermissions( + tier: PermissionTier, + autoApprove: Record, + ): void { + this.permissionTier = tier + this.autoApprove = autoApprove } getRunningSessionCount(): number { @@ -346,26 +437,751 @@ export class CliAgentManager { return count } + /** Workspace-wide cost / token rollup across this manager's sessions. */ + getWorkspaceCostSummary(): CliAgentWorkspaceCostSummary { + let totalCostUsd = 0 + let totalTokens: CliAgentTokenUsage | undefined = undefined + let sessionCount = 0 + for (const session of this.sessions.values()) { + sessionCount += 1 + totalCostUsd += session.totalCostUsd + totalTokens = sumTokenUsage(totalTokens, session.totalTokens) + } + return { + workspaceId: this.workspaceId, + totalCostUsd, + totalTokens: totalTokens ?? { + input: 0, + output: 0, + reasoning: 0, + cacheRead: 0, + cacheWrite: 0, + }, + sessionCount, + } + } + async destroy(): Promise { for (const session of this.sessions.values()) { session.activeRun?.close() await this.persistSession(session).catch(() => {}) } this.sessions.clear() + if (this.openCodeHost) { + try { + await this.openCodeHost.dispose() + } catch { + /* ignore */ + } + this.openCodeHost = null + } + // Resolve any outstanding permissions as rejected. + for (const pending of this.pendingPermissions.values()) { + pending.resolve('reject') + } + this.pendingPermissions.clear() + } + + // ─── ToolApprovalOwner (for ApprovalRouter) ──────────────────── + + ownsToolCall(toolCallId: string): boolean { + return this.pendingPermissions.has(toolCallId) + } + + approveToolCall(_sessionId: string, toolCallId: string): void { + const pending = this.pendingPermissions.get(toolCallId) + if (!pending) return + this.pendingPermissions.delete(toolCallId) + pending.resolve('always') + this.notifyWorkloadChanged() + } + + rejectToolCall(_sessionId: string, toolCallId: string): void { + const pending = this.pendingPermissions.get(toolCallId) + if (!pending) return + this.pendingPermissions.delete(toolCallId) + pending.resolve('reject') + this.notifyWorkloadChanged() + } + + getPendingApprovalCount(): number { + return this.pendingPermissions.size + } + + // ─── Per-session config (Phase 2 wiring) ─────────────────────── + + async updateSessionConfig( + sessionId: string, + patch: Partial, + ): Promise<{ success: true } | { error: string }> { + const session = this.sessions.get(sessionId) + if (!session) return { error: 'Session not found' } + const prior = session.backendStates[session.backend] ?? {} + session.backendStates[session.backend] = { ...prior, ...patch } + if (patch.model) session.model = patch.model + await this.persistSession(session) + this.emitStatus(session) + return { success: true } + } + + // ─── OpenCode SDK passthroughs (Phase 2 / 6 / 7 / 8) ─────────── + + private async getOpenCodeClient(sessionId: string) { + const session = this.sessions.get(sessionId) + if (!session) throw new Error('Session not found') + if (session.backend !== 'opencode') { + throw new Error('This operation is only available for OpenCode sessions.') + } + const host = this.ensureOpenCodeHost() + return host.getClient() + } + + async listOpenCodeProviders(sessionId: string): Promise { + const client = await this.getOpenCodeClient(sessionId) + // Shape: { providers: Provider[], default: Record } + // Provider.models is Record where Model has nested + // capabilities + cost.cache subfields (NOT flat tool_call / cache_read). + const result = await callSdk<{ + providers?: Array<{ + id?: string + name?: string + models?: Record< + string, + { + id?: string + name?: string + capabilities?: { + reasoning?: boolean + attachment?: boolean + toolcall?: boolean + } + cost?: { + input: number + output: number + cache?: { read: number; write: number } + } + } + > + }> + }>(() => (client as any).config.providers()) + return (result?.providers ?? []).map((p) => ({ + id: p.id ?? '', + name: p.name ?? p.id ?? '', + models: Object.entries(p.models ?? {}).map(([modelId, m]) => ({ + id: m.id ?? modelId, + name: m.name ?? m.id ?? modelId, + reasoning: m.capabilities?.reasoning, + attachment: m.capabilities?.attachment, + toolCall: m.capabilities?.toolcall, + cost: m.cost + ? { + input: m.cost.input, + output: m.cost.output, + cacheRead: m.cost.cache?.read, + cacheWrite: m.cost.cache?.write, + } + : undefined, + })), + })) + } + + async listOpenCodeAgents(sessionId: string): Promise { + const client = await this.getOpenCodeClient(sessionId) + const result = await callSdk< + Array<{ name?: string; description?: string; mode?: string }> + >(() => (client as any).app.agents()) + if (!Array.isArray(result)) return [] + return result.map((a) => ({ + name: a.name ?? '', + description: a.description, + mode: a.mode, + })) + } + + async listOpenCodeModes(sessionId: string): Promise { + const client = await this.getOpenCodeClient(sessionId) + const result = await callSdk<{ agents?: Record }>(() => + (client as any).config.get(), + ) + const modes = new Set(['primary', 'subagent', 'all']) + for (const agent of Object.values(result?.agents ?? {})) { + if (agent?.mode) modes.add(agent.mode) + } + return Array.from(modes) + } + + async listOpenCodeTools( + sessionId: string, + providerID: string, + modelID: string, + ): Promise { + const client = await this.getOpenCodeClient(sessionId) + try { + // SDK query expects { provider, model } (not providerID/modelID). + const result = await callSdk< + Array<{ id?: string; description?: string; parameters?: unknown }> + >(() => (client as any).tool.list({ query: { provider: providerID, model: modelID } })) + if (Array.isArray(result)) { + return result.map((t) => ({ + id: t.id ?? '', + description: t.description, + schema: t.parameters, + })) + } + } catch { + // Fallback to ids() if list() rejects + } + const ids = await callSdk(() => (client as any).tool.ids()) + return (ids ?? []).map((id) => ({ id })) + } + + // ─── Session ops ───────────────────────────────────────────── + + async sessionShare(sessionId: string): Promise<{ url?: string; error?: string }> { + try { + const client = await this.getOpenCodeClient(sessionId) + const session = this.sessions.get(sessionId) + const remoteId = session?.backendStates['opencode']?.sessionId + if (!remoteId) return { error: 'No active OpenCode session id' } + const result = await callSdk<{ url?: string; share?: { url?: string } }>(() => + (client as any).session.share({ path: { id: remoteId } }), + ) + return { url: result?.url ?? result?.share?.url } + } catch (error) { + return { error: errMsg(error) } + } + } + + async sessionUnshare(sessionId: string): Promise<{ success?: true; error?: string }> { + try { + const client = await this.getOpenCodeClient(sessionId) + const remoteId = this.requireRemoteId(sessionId) + await callSdk(() => (client as any).session.unshare({ path: { id: remoteId } })) + return { success: true } + } catch (error) { + return { error: errMsg(error) } + } + } + + async sessionSummarize(sessionId: string): Promise<{ success?: true; error?: string }> { + try { + const client = await this.getOpenCodeClient(sessionId) + const remoteId = this.requireRemoteId(sessionId) + await callSdk(() => (client as any).session.summarize({ path: { id: remoteId } })) + return { success: true } + } catch (error) { + return { error: errMsg(error) } + } + } + + async sessionRevert( + sessionId: string, + messageId: string, + ): Promise<{ success?: true; error?: string }> { + try { + const client = await this.getOpenCodeClient(sessionId) + const remoteId = this.requireRemoteId(sessionId) + await callSdk(() => + (client as any).session.revert({ + path: { id: remoteId }, + body: { messageID: messageId }, + }), + ) + return { success: true } + } catch (error) { + return { error: errMsg(error) } + } + } + + async sessionUnrevert(sessionId: string): Promise<{ success?: true; error?: string }> { + try { + const client = await this.getOpenCodeClient(sessionId) + const remoteId = this.requireRemoteId(sessionId) + await callSdk(() => (client as any).session.unrevert({ path: { id: remoteId } })) + return { success: true } + } catch (error) { + return { error: errMsg(error) } + } + } + + async sessionFork( + sessionId: string, + messageId: string, + ): Promise<{ newSessionId?: string; error?: string }> { + try { + const client = await this.getOpenCodeClient(sessionId) + const remoteId = this.requireRemoteId(sessionId) + const result = await callSdk<{ id?: string }>(() => + (client as any).session.fork({ + path: { id: remoteId }, + body: { messageID: messageId }, + }), + ) + return { newSessionId: result?.id } + } catch (error) { + return { error: errMsg(error) } + } + } + + async sessionAbort(sessionId: string): Promise<{ success?: true; error?: string }> { + try { + const client = await this.getOpenCodeClient(sessionId) + const remoteId = this.requireRemoteId(sessionId) + await callSdk(() => (client as any).session.abort({ path: { id: remoteId } })) + return { success: true } + } catch (error) { + return { error: errMsg(error) } + } + } + + async sessionDiff( + sessionId: string, + ): Promise<{ diff?: unknown; error?: string }> { + try { + const client = await this.getOpenCodeClient(sessionId) + const remoteId = this.requireRemoteId(sessionId) + const result = await callSdk(() => + (client as any).session.diff({ path: { id: remoteId } }), + ) + return { diff: result } + } catch (error) { + return { error: errMsg(error) } + } + } + + async sessionTodo( + sessionId: string, + ): Promise<{ todos?: OpenCodeTodoItem[]; error?: string }> { + try { + const client = await this.getOpenCodeClient(sessionId) + const remoteId = this.requireRemoteId(sessionId) + const result = await callSdk>(() => + (client as any).session.todo({ path: { id: remoteId } }), + ) + const todos = (Array.isArray(result) ? result : []).map((t) => ({ + id: t.id ?? '', + text: t.text ?? '', + done: t.done, + })) + return { todos } + } catch (error) { + return { error: errMsg(error) } + } + } + + async sessionInit(sessionId: string): Promise<{ success?: true; error?: string }> { + try { + const client = await this.getOpenCodeClient(sessionId) + const remoteId = this.requireRemoteId(sessionId) + await callSdk(() => (client as any).session.init({ path: { id: remoteId } })) + return { success: true } + } catch (error) { + return { error: errMsg(error) } + } + } + + async sessionDeleteRemote(sessionId: string): Promise<{ success?: true; error?: string }> { + try { + const client = await this.getOpenCodeClient(sessionId) + const remoteId = this.requireRemoteId(sessionId) + await callSdk(() => (client as any).session.delete({ path: { id: remoteId } })) + // Clear local backend state since the remote session is gone. + const session = this.sessions.get(sessionId) + if (session) { + delete session.backendStates['opencode'] + await this.persistSession(session) + } + return { success: true } + } catch (error) { + return { error: errMsg(error) } + } } + // ─── Workspace ops (Phase 7) ─────────────────────────────────── + + async fileList( + sessionId: string, + path: string, + ): Promise<{ entries?: OpenCodeFileEntry[]; error?: string }> { + try { + const client = await this.getOpenCodeClient(sessionId) + const result = await callSdk< + Array<{ + path?: string + name?: string + type?: string + size?: number + modified?: number + }> + >(() => (client as any).file.list({ query: { path, directory: this.workspaceRoot } })) + const entries = (Array.isArray(result) ? result : []).map((e) => ({ + path: e.path ?? '', + name: e.name ?? '', + isDirectory: e.type === 'directory' || e.type === 'dir', + size: e.size, + modified: e.modified, + })) + return { entries } + } catch (error) { + return { error: errMsg(error) } + } + } + + async fileRead( + sessionId: string, + path: string, + ): Promise<{ content?: string; error?: string }> { + try { + const client = await this.getOpenCodeClient(sessionId) + const result = await callSdk<{ content?: string } | string>(() => + (client as any).file.read({ query: { path, directory: this.workspaceRoot } }), + ) + if (typeof result === 'string') return { content: result } + return { content: result?.content } + } catch (error) { + return { error: errMsg(error) } + } + } + + async fileStatus(sessionId: string): Promise<{ status?: unknown; error?: string }> { + try { + const client = await this.getOpenCodeClient(sessionId) + const result = await callSdk(() => + (client as any).file.status({ query: { directory: this.workspaceRoot } }), + ) + return { status: result } + } catch (error) { + return { error: errMsg(error) } + } + } + + async findText( + sessionId: string, + query: string, + ): Promise<{ results?: OpenCodeFindResult[]; error?: string }> { + try { + const client = await this.getOpenCodeClient(sessionId) + const result = await callSdk< + Array<{ path?: string; line?: number; column?: number; text?: string; preview?: string }> + >(() => (client as any).find.text({ query: { query, directory: this.workspaceRoot } })) + const results = (Array.isArray(result) ? result : []).map((r) => ({ + path: r.path ?? '', + line: r.line, + column: r.column, + preview: r.preview ?? r.text, + matchText: r.text, + })) + return { results } + } catch (error) { + return { error: errMsg(error) } + } + } + + async findFiles( + sessionId: string, + pattern: string, + ): Promise<{ paths?: string[]; error?: string }> { + try { + const client = await this.getOpenCodeClient(sessionId) + const result = await callSdk>(() => + (client as any).find.files({ query: { pattern, directory: this.workspaceRoot } }), + ) + const arr = Array.isArray(result) ? result : [] + const paths = arr.map((p) => (typeof p === 'string' ? p : (p.path ?? ''))) + return { paths } + } catch (error) { + return { error: errMsg(error) } + } + } + + async findSymbols( + sessionId: string, + query: string, + ): Promise<{ symbols?: OpenCodeSymbolResult[]; error?: string }> { + try { + const client = await this.getOpenCodeClient(sessionId) + const result = await callSdk< + Array<{ + name?: string + kind?: string | number + path?: string + line?: number + column?: number + }> + >(() => (client as any).find.symbols({ query: { query, directory: this.workspaceRoot } })) + const symbols = (Array.isArray(result) ? result : []).map((s) => ({ + name: s.name ?? '', + kind: typeof s.kind === 'string' ? s.kind : String(s.kind ?? ''), + path: s.path ?? '', + line: s.line, + column: s.column, + })) + return { symbols } + } catch (error) { + return { error: errMsg(error) } + } + } + + async shellRun( + sessionId: string, + command: string, + ): Promise<{ result?: OpenCodeShellResult; error?: string }> { + try { + const client = await this.getOpenCodeClient(sessionId) + const remoteId = this.requireRemoteId(sessionId) + const result = await callSdk<{ + exitCode?: number + stdout?: string + stderr?: string + }>(() => + (client as any).session.shell({ + path: { id: remoteId }, + body: { command }, + }), + ) + return { + result: { + exitCode: result?.exitCode ?? 0, + stdout: result?.stdout ?? '', + stderr: result?.stderr ?? '', + }, + } + } catch (error) { + return { error: errMsg(error) } + } + } + + async lspStatus(sessionId: string): Promise<{ status?: unknown; error?: string }> { + try { + const client = await this.getOpenCodeClient(sessionId) + const result = await callSdk(() => (client as any).lsp.status()) + return { status: result } + } catch (error) { + return { error: errMsg(error) } + } + } + + async formatterStatus(sessionId: string): Promise<{ status?: unknown; error?: string }> { + try { + const client = await this.getOpenCodeClient(sessionId) + const result = await callSdk(() => (client as any).formatter.status()) + return { status: result } + } catch (error) { + return { error: errMsg(error) } + } + } + + // ─── Config / auth / providers (Phase 8) ─────────────────────── + + async configGet(sessionId: string): Promise<{ config?: unknown; error?: string }> { + try { + const client = await this.getOpenCodeClient(sessionId) + const result = await callSdk(() => (client as any).config.get()) + return { config: result } + } catch (error) { + return { error: errMsg(error) } + } + } + + async configUpdate( + sessionId: string, + patch: Record, + ): Promise<{ success?: true; error?: string }> { + try { + const client = await this.getOpenCodeClient(sessionId) + await callSdk(() => (client as any).config.update({ body: patch })) + return { success: true } + } catch (error) { + return { error: errMsg(error) } + } + } + + async configProviders(sessionId: string): Promise<{ providers?: unknown; error?: string }> { + try { + const client = await this.getOpenCodeClient(sessionId) + const result = await callSdk(() => (client as any).config.providers()) + return { providers: result } + } catch (error) { + return { error: errMsg(error) } + } + } + + async authSet( + sessionId: string, + key: string, + value: string, + ): Promise<{ success?: true; error?: string }> { + try { + const client = await this.getOpenCodeClient(sessionId) + await callSdk(() => (client as any).auth.set({ body: { key, value } })) + return { success: true } + } catch (error) { + return { error: errMsg(error) } + } + } + + async providerList(sessionId: string): Promise<{ providers?: unknown; error?: string }> { + try { + const client = await this.getOpenCodeClient(sessionId) + const result = await callSdk(() => (client as any).provider.list()) + return { providers: result } + } catch (error) { + return { error: errMsg(error) } + } + } + + async providerAuth( + sessionId: string, + providerId: string, + ): Promise<{ methods?: OpenCodeAuthMethod[]; error?: string }> { + try { + const client = await this.getOpenCodeClient(sessionId) + const result = await callSdk< + Array<{ id?: string; label?: string; type?: string }> + >(() => (client as any).provider.auth({ query: { providerID: providerId } })) + const methods = (Array.isArray(result) ? result : []).map((m) => ({ + id: m.id ?? '', + label: m.label, + type: (m.type === 'oauth' || m.type === 'apiKey' || m.type === 'env' + ? m.type + : 'unknown') as OpenCodeAuthMethod['type'], + })) + return { methods } + } catch (error) { + return { error: errMsg(error) } + } + } + + async providerOauthAuthorize( + sessionId: string, + providerId: string, + ): Promise<{ url?: string; error?: string }> { + try { + const client = await this.getOpenCodeClient(sessionId) + const result = await callSdk<{ url?: string }>(() => + (client as any).provider.oauth.authorize({ query: { providerID: providerId } }), + ) + return { url: result?.url } + } catch (error) { + return { error: errMsg(error) } + } + } + + async providerOauthCallback( + sessionId: string, + code: string, + ): Promise<{ success?: true; error?: string }> { + try { + const client = await this.getOpenCodeClient(sessionId) + await callSdk(() => (client as any).provider.oauth.callback({ query: { code } })) + return { success: true } + } catch (error) { + return { error: errMsg(error) } + } + } + + async pathGet(sessionId: string): Promise<{ paths?: OpenCodePathInfo; error?: string }> { + try { + const client = await this.getOpenCodeClient(sessionId) + const result = await callSdk(() => (client as any).path.get()) + return { paths: result ?? {} } + } catch (error) { + return { error: errMsg(error) } + } + } + + async logWrite( + sessionId: string, + message: string, + level?: 'DEBUG' | 'INFO' | 'WARN' | 'ERROR', + ): Promise<{ success?: true; error?: string }> { + try { + const client = await this.getOpenCodeClient(sessionId) + await callSdk(() => + (client as any).app.log({ body: { message, level: level ?? 'INFO' } }), + ) + return { success: true } + } catch (error) { + return { error: errMsg(error) } + } + } + + serverInfo(): OpenCodeServerInfo | null { + return this.openCodeHost?.getInfo() ?? null + } + + // ─── TUI control (Phase 8) ───────────────────────────────────── + + async tui( + sessionId: string, + method: + | 'appendPrompt' + | 'submitPrompt' + | 'clearPrompt' + | 'openHelp' + | 'openSessions' + | 'openThemes' + | 'openModels' + | 'executeCommand' + | 'showToast', + args?: Record, + ): Promise<{ success?: true; error?: string }> { + try { + const client = await this.getOpenCodeClient(sessionId) + const tui = (client as any).tui + const opts = args ? { body: args } : undefined + switch (method) { + case 'appendPrompt': + await callSdk(() => tui.appendPrompt(opts)) + break + case 'submitPrompt': + await callSdk(() => tui.submitPrompt()) + break + case 'clearPrompt': + await callSdk(() => tui.clearPrompt()) + break + case 'openHelp': + await callSdk(() => tui.openHelp()) + break + case 'openSessions': + await callSdk(() => tui.openSessions()) + break + case 'openThemes': + await callSdk(() => tui.openThemes()) + break + case 'openModels': + await callSdk(() => tui.openModels()) + break + case 'executeCommand': + await callSdk(() => tui.executeCommand(opts)) + break + case 'showToast': + await callSdk(() => tui.showToast(opts)) + break + } + return { success: true } + } catch (error) { + return { error: errMsg(error) } + } + } + + // ─── Internals ───────────────────────────────────────────────── + private toPublicSession(session: CliAgentSessionInternal): CliAgentSession { return { id: session.id, workspaceId: session.workspaceId, backend: session.backend, + activeBackend: session.backend, processStatus: session.processStatus, messages: session.messages, model: session.model, sessionToolNames: session.sessionToolNames, lastError: session.lastError, totalCostUsd: session.totalCostUsd, + totalTokens: session.totalTokens, worktreePath: session.worktreePath, + backendStates: session.backendStates, } } @@ -381,18 +1197,14 @@ export class CliAgentManager { } if (backend === 'opencode') { - const executablePath = this.resolveGenericExecutable( - 'opencode', - this.opencodePath, - this.resolvedOpenCodePath, - ) - if (!executablePath) { - throw new Error( - 'OpenCode CLI not found. Install opencode or set agent.opencodePath in settings.', - ) - } - this.resolvedOpenCodePath = executablePath - return createOpenCodeAdapter({ executablePath }) + const host = this.ensureOpenCodeHost() + return createOpenCodeAdapter({ + host, + getPermissionSettings: () => ({ + tier: this.permissionTier, + autoApprove: this.autoApprove, + }), + }) } const executablePath = this.resolveGenericExecutable( @@ -409,6 +1221,23 @@ export class CliAgentManager { return createCodexAdapter({ executablePath }) } + private ensureOpenCodeHost(): OpenCodeServerHost { + if (!this.openCodeHost) { + this.openCodeHost = new OpenCodeServerHost({ + workspaceRoot: this.workspaceRoot, + explicitPath: this.opencodePath, + }) + } + return this.openCodeHost + } + + private requireRemoteId(localSessionId: string): string { + const session = this.sessions.get(localSessionId) + const remote = session?.backendStates['opencode']?.sessionId + if (!remote) throw new Error('No remote OpenCode session id; send a message first.') + return remote + } + private resolveClaudeCodeExecutable(): string | null { if (this.resolvedClaudeCodePath) return this.resolvedClaudeCodePath @@ -531,14 +1360,24 @@ export class CliAgentManager { if (event.type === 'result') { session.totalCostUsd += event.totalCostUsd + if (event.tokens) { + session.totalTokens = sumTokenUsage(session.totalTokens, event.tokens) + } const payload: CliAgentResultPayload = { workspaceId: session.workspaceId, sessionId: session.id, durationMs: event.durationMs, totalCostUsd: session.totalCostUsd, + totalTokens: session.totalTokens, isSuccess: event.isSuccess, } this.getWebContents()?.send(IpcChannels.CLI_AGENT_RESULT, payload) + this.broadcastWorkspaceCost(session.workspaceId) + return + } + + if (event.type === 'permission-request') { + this.handlePermissionRequest(session, event.request, event.resolve) return } @@ -556,6 +1395,56 @@ export class CliAgentManager { } } + /** + * Bridge an OpenCode permission event into the existing CHAT_TOOL_CALL + * approval surface so the IDE has one approval UI for both backends. + */ + private handlePermissionRequest( + session: CliAgentSessionInternal, + request: import('./cliAdapters/types').CliBackendPermissionRequest, + resolve: (response: 'always' | 'once' | 'reject') => void, + ): void { + const toolCallId = randomUUID() + this.pendingPermissions.set(toolCallId, { + sessionId: session.id, + resolve, + }) + + const toolCall: ToolCall = { + id: toolCallId, + name: `opencode:${request.category}`, + input: { + title: request.title, + pattern: request.pattern, + ...(request.metadata ?? {}), + }, + status: 'pending', + } + + const payload: ChatToolCallPayload = { + workspaceId: session.workspaceId, + sessionId: session.id, + toolCall, + } + this.getWebContents()?.send(IpcChannels.CHAT_TOOL_CALL, payload) + + // Also emit a structured CLI agent permission request payload for any + // surface that wants the rich form (badges, metadata). + const richPayload: CliAgentPermissionRequest = { + workspaceId: session.workspaceId, + sessionId: session.id, + toolCallId, + backend: 'opencode', + title: request.title, + category: request.category, + pattern: request.pattern, + metadata: request.metadata, + timestamp: Date.now(), + } + void richPayload // (channel reserved for future granular UI; CHAT_TOOL_CALL is the active surface) + this.notifyWorkloadChanged() + } + private buildTurnPrompt(session: CliAgentSessionInternal, content: string): string { const backendState = session.backendStates[session.backend] if (backendState?.sessionId) { @@ -634,6 +1523,20 @@ export class CliAgentManager { this.getWebContents()?.send(IpcChannels.CLI_AGENT_MESSAGE, payload) } + private broadcastWorkspaceCost(workspaceId: string): void { + const summary = this.getWorkspaceCostSummary() + if (workspaceId) summary.workspaceId = workspaceId + this.getWebContents()?.send(IpcChannels.CLI_AGENT_WORKSPACE_COST, summary) + } + + private notifyWorkloadChanged(): void { + try { + this.onWorkloadChanged?.() + } catch { + /* ignore */ + } + } + private async persistSession(session: CliAgentSessionInternal): Promise { if (!this.conversationStore || session.id.startsWith('claude-native:')) return @@ -643,6 +1546,8 @@ export class CliAgentManager { activeBackend: session.backend, backendStates: session.backendStates, claudeSessionId, + totalCostUsd: session.totalCostUsd, + totalTokens: session.totalTokens, } satisfies PersistedCliConversation) await this.conversationStore.updateMeta(session.id, { @@ -683,3 +1588,27 @@ export class CliAgentManager { } satisfies ConversationListChangedPayload) } } + +// ─── SDK call helpers ────────────────────────────────────────── + +/** + * Wraps an SDK promise so we can deal with both `responseStyle: 'data'` + * (returns the unwrapped data) and the older `{ data, error }` shape. + * If `error` is set, throws. + */ +async function callSdk(fn: () => Promise): Promise { + const result = await fn() + if (result && typeof result === 'object' && 'error' in (result as Record)) { + const wrapper = result as { data?: unknown; error?: unknown } + if (wrapper.error) { + const err = wrapper.error as { message?: string } | string + throw new Error(typeof err === 'string' ? err : (err.message ?? 'OpenCode SDK error')) + } + return wrapper.data as T | undefined + } + return result as T | undefined +} + +function errMsg(error: unknown): string { + return error instanceof Error ? error.message : String(error) +} diff --git a/packages/main/src/chat/openCodeServerHost.ts b/packages/main/src/chat/openCodeServerHost.ts new file mode 100644 index 0000000..a1ac09b --- /dev/null +++ b/packages/main/src/chat/openCodeServerHost.ts @@ -0,0 +1,410 @@ +/** + * OpenCodeServerHost — per-workspace persistent OpenCode server. + * + * Owns one `opencode serve` child process per workspace and a shared SSE + * subscription that is fanned out to per-session listener bags. Adapters + * (one per turn) attach to the host, get a client + a per-session event + * subscription, run their turn, and detach — the server stays running for + * the workspace's lifetime. + * + * Lifecycle: + * - `start()` → idempotent; spawns + waits for "listening" + * - `subscribe(id)` → returns an unsubscribe fn; emits opencode events + * whose sessionID matches `id` (or null sessionID for + * broadcast events like installation.*) + * - `respondPermission(sessionId, permissionId, response)` → POSTs + * - `dispose()` → kills the process, aborts SSE, clears listeners + * - `setPath(path)` → swap the binary used on next (re)start + * + * Multi-workspace parallelism is the default: each workspace's CliAgentManager + * owns its own host instance; ports are reserved fresh per host so two hosts + * never collide. + */ + +import { spawn, type ChildProcess } from 'child_process' +import { createServer } from 'net' +import { existsSync } from 'fs' +import { join } from 'path' +import { app } from 'electron' +import { execFileSync } from 'child_process' +import { createOpencodeClient } from '@opencode-ai/sdk/client' + +type OpencodeClient = ReturnType + +export type OpenCodeHostMode = 'auto' | 'external' + +export interface OpenCodeHostOptions { + workspaceRoot: string + /** Explicit binary path; if set, "external" mode is forced. */ + explicitPath?: string +} + +export interface OpenCodeServerInfoSnapshot { + url: string + mode: 'bundled' | 'external' + pid?: number + startedAt: number +} + +type EventListener = (event: unknown) => void + +interface SubscriberEntry { + sessionId: string | null + listener: EventListener +} + +export class OpenCodeServerHost { + private workspaceRoot: string + private explicitPath: string + + private startPromise: Promise | null = null + private url: string | null = null + private serverProc: ChildProcess | null = null + private startedAt = 0 + private mode: 'bundled' | 'external' = 'bundled' + private client: OpencodeClient | null = null + private resolvedPath: string | null = null + + private subscribers = new Set() + private sseAbort: AbortController | null = null + private sseTask: Promise | null = null + private disposed = false + + constructor(options: OpenCodeHostOptions) { + this.workspaceRoot = options.workspaceRoot + this.explicitPath = options.explicitPath ?? '' + } + + /** Returns the OpencodeClient (starts the host on first call). */ + async getClient(): Promise { + await this.start() + if (!this.client) throw new Error('OpenCodeServerHost: client unavailable after start()') + return this.client + } + + /** Idempotent start. Re-entrant calls return the same promise. */ + async start(): Promise { + if (this.disposed) throw new Error('OpenCodeServerHost has been disposed') + if (this.startPromise) return this.startPromise + this.startPromise = this.doStart().catch((error) => { + this.startPromise = null + throw error + }) + return this.startPromise + } + + private async doStart(): Promise { + const binaryPath = this.resolveBinaryPath() + if (!binaryPath) { + throw new Error( + 'OpenCode CLI not found. Install opencode (npm i -g opencode-ai) or set agent.opencodePath in settings.', + ) + } + this.resolvedPath = binaryPath + this.mode = this.explicitPath ? 'external' : 'bundled' + + const port = await reservePort() + const url = `http://127.0.0.1:${port}` + const proc = spawn(binaryPath, ['serve', '--hostname=127.0.0.1', `--port=${port}`], { + env: { + ...process.env, + OPENCODE_CONFIG_CONTENT: JSON.stringify({}), + }, + stdio: ['ignore', 'pipe', 'pipe'], + }) + + if (!proc) throw new Error('Failed to spawn OpenCode server process') + + try { + await waitForOpenCodeServer(proc, url) + } catch (error) { + proc.kill('SIGTERM') + throw error + } + + this.serverProc = proc + this.url = url + this.startedAt = Date.now() + + this.client = createOpencodeClient({ + baseUrl: url, + directory: this.workspaceRoot, + responseStyle: 'data', + throwOnError: true, + } as Parameters[0]) + + proc.once('exit', (code, signal) => { + if (this.disposed) return + // Server crashed unexpectedly — clear state so the next call re-spawns. + this.handleServerExit(`server exited (code=${code} signal=${signal})`) + }) + + // Start the shared SSE stream. + this.beginSse() + } + + private beginSse(): void { + if (this.sseAbort) return + if (!this.client) return + this.sseAbort = new AbortController() + const signal = this.sseAbort.signal + const directory = this.workspaceRoot + this.sseTask = (async () => { + try { + // Use `/event` (Event.subscribe) — it yields the unwrapped Event union + // (`{ type, properties }`) directly and accepts a `directory` query + // filter so we only receive events for this workspace's server. + // + // The previous implementation used `client.global.event()` which yields + // `GlobalEvent = { directory, payload: Event }`; that wrapping caused + // every dispatched event to look like `{ type: undefined }` and the + // adapter would never observe `session.idle`, so OpenCode chats never + // replied. + const sse = await (this.client as unknown as { + event: { + subscribe: (opts: { + query?: { directory?: string } + signal?: AbortSignal + }) => Promise<{ stream: AsyncIterable }> + } + }).event.subscribe({ query: { directory }, signal }) + for await (const rawEvent of sse.stream) { + if (signal.aborted) break + this.dispatchEvent(rawEvent) + } + } catch (error) { + if (signal.aborted || this.disposed) return + // SSE crashed — surface as a synthetic event so listeners can react. + this.dispatchEvent({ + type: 'sse.error', + properties: { + error: error instanceof Error ? error.message : String(error), + }, + }) + } + })() + } + + private dispatchEvent(rawEvent: unknown): void { + const event = rawEvent as { type?: string; properties?: Record } + const props = (event?.properties ?? {}) as Record + const explicitSessionID = + typeof props.sessionID === 'string' + ? (props.sessionID as string) + : typeof (props.info as { sessionID?: string } | undefined)?.sessionID === 'string' + ? ((props.info as { sessionID?: string }).sessionID as string) + : typeof (props.part as { sessionID?: string } | undefined)?.sessionID === 'string' + ? ((props.part as { sessionID?: string }).sessionID as string) + : null + + for (const sub of this.subscribers) { + if (sub.sessionId === null || explicitSessionID === null || sub.sessionId === explicitSessionID) { + try { + sub.listener(rawEvent) + } catch { + // Listener errors must not break the SSE pump. + } + } + } + } + + /** + * Subscribe to events for a particular sessionId. Returns an unsubscribe fn. + * Pass `null` to receive every event (used by diagnostics surfaces). + */ + subscribe(sessionId: string | null, listener: EventListener): () => void { + const entry: SubscriberEntry = { sessionId, listener } + this.subscribers.add(entry) + return () => { + this.subscribers.delete(entry) + } + } + + /** + * Respond to an OpenCode permission request. Wraps the SDK's + * `postSessionIdPermissionsPermissionId` call so callers don't need to + * worry about its long name. + */ + async respondPermission( + sessionId: string, + permissionId: string, + response: 'always' | 'once' | 'reject', + ): Promise { + const client = await this.getClient() + await (client as unknown as { + postSessionIdPermissionsPermissionId: (opts: { + path: { id: string; permissionID: string } + body: { response: 'always' | 'once' | 'reject' } + }) => Promise + }).postSessionIdPermissionsPermissionId({ + path: { id: sessionId, permissionID: permissionId }, + body: { response }, + }) + } + + getInfo(): OpenCodeServerInfoSnapshot | null { + if (!this.url || !this.serverProc) return null + return { + url: this.url, + mode: this.mode, + pid: this.serverProc.pid, + startedAt: this.startedAt, + } + } + + /** Update the explicit path used on next (re)start. Doesn't restart automatically. */ + setPath(explicitPath: string): void { + this.explicitPath = explicitPath + this.resolvedPath = null + } + + /** + * Restart the host: dispose current process and re-start. Subscribers are + * preserved across restarts (they'll receive events from the new server). + */ + async restart(): Promise { + await this.shutdownProcess('restart requested') + this.disposed = false + await this.start() + } + + async dispose(): Promise { + if (this.disposed) return + this.disposed = true + await this.shutdownProcess('disposed') + this.subscribers.clear() + } + + private async shutdownProcess(reason: string): Promise { + void reason + this.startPromise = null + if (this.sseAbort) { + try { + this.sseAbort.abort() + } catch { + /* ignore */ + } + this.sseAbort = null + } + if (this.sseTask) { + try { + await this.sseTask + } catch { + /* ignore */ + } + this.sseTask = null + } + if (this.serverProc) { + try { + this.serverProc.kill('SIGTERM') + } catch { + /* ignore */ + } + this.serverProc = null + } + this.client = null + this.url = null + } + + private handleServerExit(reason: string): void { + void reason + this.startPromise = null + this.serverProc = null + this.client = null + this.url = null + if (this.sseAbort) { + try { + this.sseAbort.abort() + } catch { + /* ignore */ + } + this.sseAbort = null + } + this.sseTask = null + } + + private resolveBinaryPath(): string | null { + if (this.resolvedPath) return this.resolvedPath + + if (this.explicitPath && existsSync(this.explicitPath)) { + return this.explicitPath + } + + const candidates: string[] = [] + if (app.isPackaged) { + candidates.push( + join(process.resourcesPath, 'app.asar.unpacked', 'node_modules', '.bin', 'opencode'), + ) + } + candidates.push( + join(app.getAppPath(), 'node_modules', '.bin', 'opencode'), + join(this.workspaceRoot, 'node_modules', '.bin', 'opencode'), + ) + + for (const candidate of candidates) { + if (existsSync(candidate)) return candidate + } + + try { + const result = execFileSync('which', ['opencode'], { encoding: 'utf-8' }).trim() + if (result) return result + } catch { + // not on PATH + } + return null + } +} + +async function reservePort(): Promise { + return await new Promise((resolve, reject) => { + const server = createServer() + server.once('error', reject) + server.listen(0, '127.0.0.1', () => { + const address = server.address() + const port = typeof address === 'object' && address ? address.port : null + server.close((error) => { + if (error) reject(error) + else if (typeof port === 'number') resolve(port) + else reject(new Error('Failed to reserve OpenCode port')) + }) + }) + }) +} + +async function waitForOpenCodeServer(proc: ChildProcess, url: string): Promise { + await new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + cleanup() + reject(new Error('Timeout waiting for OpenCode server to start')) + }, 8000) + + let output = '' + const onData = (chunk: Buffer) => { + output += chunk.toString() + if (output.includes(url) || output.includes('opencode server listening')) { + cleanup() + resolve() + } + } + const onExit = (code: number | null) => { + cleanup() + reject(new Error(`OpenCode server exited with code ${code}${output ? `\n${output}` : ''}`)) + } + const onError = (error: Error) => { + cleanup() + reject(error) + } + const cleanup = () => { + clearTimeout(timeout) + proc.stdout?.off('data', onData) + proc.stderr?.off('data', onData) + proc.off('exit', onExit) + proc.off('error', onError) + } + + proc.stdout?.on('data', onData) + proc.stderr?.on('data', onData) + proc.once('exit', onExit) + proc.once('error', onError) + }) +} diff --git a/packages/main/src/chat/permissionMatching.ts b/packages/main/src/chat/permissionMatching.ts new file mode 100644 index 0000000..41f6a95 --- /dev/null +++ b/packages/main/src/chat/permissionMatching.ts @@ -0,0 +1,129 @@ +/** + * Shared permission decision utilities. + * + * Lifted from `agentManager.ts` so both the built-in `AgentManager` and the + * `CliAgentManager` (for OpenCode permission events) can apply the same + * permission tier + autoApprove rules to tool/operation requests. + * + * The IDE's permission model lives in user settings: + * - `agent.permissionTier`: 'confirm' | 'auto-approve' | 'autopilot' + * - `agent.autoApprove`: Record + */ + +import type { PermissionTier, ToolPermissionConfig } from '@aide/shared' + +/** + * IDE-canonical "read-only" tools — auto-approved under the `auto-approve` + * tier. These are the tool names used by the built-in `AgentManager`. The + * OpenCode permission bridge maps SDK permission categories onto these names + * (see `openCodePermissionBridge.ts`). + */ +export const READ_ONLY_TOOLS: ReadonlySet = new Set([ + 'file_read', + 'file_list', + 'search_files', + 'git_status', + 'git_diff', + 'browser_read', +]) + +/** + * Decide whether a tool call should be auto-approved without prompting the user. + * + * Precedence (highest first): + * 1. autoApprove[toolName] === false → deny + * 2. autoApprove[toolName] === true → allow + * 3. autoApprove[toolName] is a pattern config → glob match against input + * 4. permissionTier: + * - 'autopilot' → allow everything + * - 'auto-approve' → allow read-only tools, prompt for the rest + * - 'confirm' → prompt for everything + */ +export function shouldAutoApprove( + toolName: string, + input: Record, + tier: PermissionTier, + autoApprove: Record, +): boolean { + const override = autoApprove[toolName] + if (override === true) return true + if (override === false) return false + if (typeof override === 'object' && override !== null) { + return matchesPatternConfig(override, toolName, input) + } + + switch (tier) { + case 'autopilot': + return true + case 'auto-approve': + return READ_ONLY_TOOLS.has(toolName) + case 'confirm': + default: + return false + } +} + +/** + * Same as {@link shouldAutoApprove} but also distinguishes "explicitly denied" + * (deny pattern matched) from "not matched, fall through". The OpenCode bridge + * needs the three-way distinction to map onto OpenCode's `'always' | 'once' + * | 'reject'` permission response. + */ +export function evaluatePermission( + toolName: string, + input: Record, + tier: PermissionTier, + autoApprove: Record, +): 'allow' | 'deny' | 'prompt' { + const override = autoApprove[toolName] + if (override === false) return 'deny' + if (override === true) return 'allow' + if (typeof override === 'object' && override !== null) { + const matchTarget = matchTargetFor(toolName, input) + if (override.denyPatterns?.some((p) => globMatch(matchTarget, p))) return 'deny' + if (override.allowPatterns && override.allowPatterns.length > 0) { + return override.allowPatterns.some((p) => globMatch(matchTarget, p)) ? 'allow' : 'prompt' + } + return 'prompt' + } + + switch (tier) { + case 'autopilot': + return 'allow' + case 'auto-approve': + return READ_ONLY_TOOLS.has(toolName) ? 'allow' : 'prompt' + case 'confirm': + default: + return 'prompt' + } +} + +export function matchesPatternConfig( + config: ToolPermissionConfig, + toolName: string, + input: Record, +): boolean { + const matchTarget = matchTargetFor(toolName, input) + + if (config.denyPatterns?.some((p) => globMatch(matchTarget, p))) { + return false + } + if (config.allowPatterns && config.allowPatterns.length > 0) { + return config.allowPatterns.some((p) => globMatch(matchTarget, p)) + } + return false +} + +function matchTargetFor(toolName: string, input: Record): string { + if (toolName === 'terminal_exec') { + return String(input.command ?? '') + } + return JSON.stringify(input) +} + +export function globMatch(text: string, pattern: string): boolean { + // Simple glob: * matches any sequence of characters. + const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, '\\$&') + const regex = new RegExp('^' + escaped.replace(/\*/g, '.*') + '$') + return regex.test(text) +} diff --git a/packages/main/src/index.ts b/packages/main/src/index.ts index 4ec9fac..1b9390a 100644 --- a/packages/main/src/index.ts +++ b/packages/main/src/index.ts @@ -70,6 +70,7 @@ import { BrowserPaneManager } from './browserPaneManager' import { registerGitDiffHandlers } from './git/gitDiff' import { AgentManager } from './chat/agentManager' import { CliAgentManager } from './chat/cliAgentManager' +import { ApprovalRouter } from './chat/approvalRouter' import { ConversationStore } from './chat/conversationStore' import { ClaudeNativeSessionWatcher } from './chat/claudeNativeSessionWatcher' import type { @@ -535,6 +536,7 @@ async function startRuntimeServices(runtime: WorkspaceRuntime): Promise { const resolved = resolveAppDefaults(store) const cliAgentManager = new CliAgentManager({ workspaceRoot: rootPath, + workspaceId: runtime.workspaceId, getWebContents: () => contentView?.webContents ?? null, claudeCodePath: resolved['agent.claudeCodePath'], opencodePath: resolved['agent.opencodePath'], @@ -542,14 +544,27 @@ async function startRuntimeServices(runtime: WorkspaceRuntime): Promise { conversationStore, loadClaudeHistory: async (claudeSessionId: string) => nativeSessionWatcher.loadMessages(claudeSessionId), + permissionTier: permConfig.permissionTier, + autoApprove: permConfig.autoApprove, + onWorkloadChanged: () => { + runtime.refreshWorkload() + }, }) + // ApprovalRouter dispatches CHAT_TOOL_APPROVE / CHAT_TOOL_REJECT to whichever + // manager owns the toolCallId, so OpenCode permission prompts and built-in + // chat approvals share a single approval surface. + const approvalRouter = new ApprovalRouter() + approvalRouter.register(agentManager) + approvalRouter.register(cliAgentManager) + runtime.setServices({ conversationStore, nativeSessionWatcher, nativeSessionCache, agentManager, cliAgentManager, + approvalRouter, }) const getWc = () => contentView?.webContents ?? null @@ -765,20 +780,27 @@ ipcMain.handle( ipcMain.handle( IpcChannels.CHAT_TOOL_APPROVE, async (_event, sessionId: string, toolCallId: string) => { - const runtime = findRuntimeWithBuiltInSession(sessionId) - const agentManager = getAgentManager(runtime) - agentManager?.approveToolCall(sessionId, toolCallId) - runtime?.refreshWorkload() + // Find the runtime that owns this toolCallId across both managers. + for (const runtime of runtimeRegistry.list()) { + const router = runtime.services.approvalRouter as ApprovalRouter | null + if (router?.approve(sessionId, toolCallId)) { + runtime.refreshWorkload() + return + } + } }, ) ipcMain.handle( IpcChannels.CHAT_TOOL_REJECT, async (_event, sessionId: string, toolCallId: string) => { - const runtime = findRuntimeWithBuiltInSession(sessionId) - const agentManager = getAgentManager(runtime) - agentManager?.rejectToolCall(sessionId, toolCallId) - runtime?.refreshWorkload() + for (const runtime of runtimeRegistry.list()) { + const router = runtime.services.approvalRouter as ApprovalRouter | null + if (router?.reject(sessionId, toolCallId)) { + runtime.refreshWorkload() + return + } + } }, ) @@ -879,6 +901,258 @@ ipcMain.on(IpcChannels.CLI_AGENT_STOP, (_event, sessionId: string) => { runtime?.refreshWorkload() }) +// ─── CLI Agent: per-session config + provider/agent/mode/tool listings ── + +function withCliManager( + sessionId: string, + fn: (mgr: CliAgentManager) => Promise, +): Promise { + const runtime = findRuntimeWithCliSession(sessionId) + const mgr = getCliAgentManager(runtime) + if (!mgr) return Promise.resolve({ error: 'No workspace open' } as const) + return fn(mgr).catch((error) => ({ + error: error instanceof Error ? error.message : String(error), + })) +} + +ipcMain.handle( + IpcChannels.CLI_AGENT_UPDATE_SESSION_CONFIG, + async (_event, sessionId: string, patch: Record) => { + return withCliManager(sessionId, (mgr) => mgr.updateSessionConfig(sessionId, patch)) + }, +) + +ipcMain.handle(IpcChannels.CLI_AGENT_LIST_PROVIDERS, async (_event, sessionId: string) => { + return withCliManager(sessionId, (mgr) => mgr.listOpenCodeProviders(sessionId)) +}) + +ipcMain.handle(IpcChannels.CLI_AGENT_LIST_AGENTS, async (_event, sessionId: string) => { + return withCliManager(sessionId, (mgr) => mgr.listOpenCodeAgents(sessionId)) +}) + +ipcMain.handle(IpcChannels.CLI_AGENT_LIST_MODES, async (_event, sessionId: string) => { + return withCliManager(sessionId, (mgr) => mgr.listOpenCodeModes(sessionId)) +}) + +ipcMain.handle( + IpcChannels.CLI_AGENT_LIST_TOOLS, + async (_event, sessionId: string, providerID: string, modelID: string) => { + return withCliManager(sessionId, (mgr) => + mgr.listOpenCodeTools(sessionId, providerID, modelID), + ) + }, +) + +// ─── CLI Agent: session ops ───────────────────────────────────────────── + +ipcMain.handle(IpcChannels.CLI_AGENT_SESSION_SHARE, async (_event, sessionId: string) => { + return withCliManager(sessionId, (mgr) => mgr.sessionShare(sessionId)) +}) + +ipcMain.handle(IpcChannels.CLI_AGENT_SESSION_UNSHARE, async (_event, sessionId: string) => { + return withCliManager(sessionId, (mgr) => mgr.sessionUnshare(sessionId)) +}) + +ipcMain.handle(IpcChannels.CLI_AGENT_SESSION_SUMMARIZE, async (_event, sessionId: string) => { + return withCliManager(sessionId, (mgr) => mgr.sessionSummarize(sessionId)) +}) + +ipcMain.handle( + IpcChannels.CLI_AGENT_SESSION_REVERT, + async (_event, sessionId: string, messageId: string) => { + return withCliManager(sessionId, (mgr) => mgr.sessionRevert(sessionId, messageId)) + }, +) + +ipcMain.handle(IpcChannels.CLI_AGENT_SESSION_UNREVERT, async (_event, sessionId: string) => { + return withCliManager(sessionId, (mgr) => mgr.sessionUnrevert(sessionId)) +}) + +ipcMain.handle( + IpcChannels.CLI_AGENT_SESSION_FORK, + async (_event, sessionId: string, messageId: string) => { + return withCliManager(sessionId, (mgr) => mgr.sessionFork(sessionId, messageId)) + }, +) + +ipcMain.handle(IpcChannels.CLI_AGENT_SESSION_ABORT, async (_event, sessionId: string) => { + return withCliManager(sessionId, (mgr) => mgr.sessionAbort(sessionId)) +}) + +ipcMain.handle(IpcChannels.CLI_AGENT_SESSION_DIFF, async (_event, sessionId: string) => { + return withCliManager(sessionId, (mgr) => mgr.sessionDiff(sessionId)) +}) + +ipcMain.handle(IpcChannels.CLI_AGENT_SESSION_TODO, async (_event, sessionId: string) => { + return withCliManager(sessionId, (mgr) => mgr.sessionTodo(sessionId)) +}) + +ipcMain.handle(IpcChannels.CLI_AGENT_SESSION_INIT, async (_event, sessionId: string) => { + return withCliManager(sessionId, (mgr) => mgr.sessionInit(sessionId)) +}) + +ipcMain.handle(IpcChannels.CLI_AGENT_SESSION_DELETE_REMOTE, async (_event, sessionId: string) => { + return withCliManager(sessionId, (mgr) => mgr.sessionDeleteRemote(sessionId)) +}) + +// ─── CLI Agent: workspace ops ─────────────────────────────────────────── + +ipcMain.handle( + IpcChannels.CLI_AGENT_FILE_LIST, + async (_event, sessionId: string, path: string) => { + return withCliManager(sessionId, (mgr) => mgr.fileList(sessionId, path)) + }, +) + +ipcMain.handle( + IpcChannels.CLI_AGENT_FILE_READ, + async (_event, sessionId: string, path: string) => { + return withCliManager(sessionId, (mgr) => mgr.fileRead(sessionId, path)) + }, +) + +ipcMain.handle(IpcChannels.CLI_AGENT_FILE_STATUS, async (_event, sessionId: string) => { + return withCliManager(sessionId, (mgr) => mgr.fileStatus(sessionId)) +}) + +ipcMain.handle( + IpcChannels.CLI_AGENT_FIND_TEXT, + async (_event, sessionId: string, query: string) => { + return withCliManager(sessionId, (mgr) => mgr.findText(sessionId, query)) + }, +) + +ipcMain.handle( + IpcChannels.CLI_AGENT_FIND_FILES, + async (_event, sessionId: string, pattern: string) => { + return withCliManager(sessionId, (mgr) => mgr.findFiles(sessionId, pattern)) + }, +) + +ipcMain.handle( + IpcChannels.CLI_AGENT_FIND_SYMBOLS, + async (_event, sessionId: string, query: string) => { + return withCliManager(sessionId, (mgr) => mgr.findSymbols(sessionId, query)) + }, +) + +ipcMain.handle( + IpcChannels.CLI_AGENT_SHELL_RUN, + async (_event, sessionId: string, command: string) => { + return withCliManager(sessionId, (mgr) => mgr.shellRun(sessionId, command)) + }, +) + +ipcMain.handle(IpcChannels.CLI_AGENT_LSP_STATUS, async (_event, sessionId: string) => { + return withCliManager(sessionId, (mgr) => mgr.lspStatus(sessionId)) +}) + +ipcMain.handle(IpcChannels.CLI_AGENT_FORMATTER_STATUS, async (_event, sessionId: string) => { + return withCliManager(sessionId, (mgr) => mgr.formatterStatus(sessionId)) +}) + +// ─── CLI Agent: config / auth / providers ─────────────────────────────── + +ipcMain.handle(IpcChannels.CLI_AGENT_CONFIG_GET, async (_event, sessionId: string) => { + return withCliManager(sessionId, (mgr) => mgr.configGet(sessionId)) +}) + +ipcMain.handle( + IpcChannels.CLI_AGENT_CONFIG_UPDATE, + async (_event, sessionId: string, patch: Record) => { + return withCliManager(sessionId, (mgr) => mgr.configUpdate(sessionId, patch)) + }, +) + +ipcMain.handle(IpcChannels.CLI_AGENT_CONFIG_PROVIDERS, async (_event, sessionId: string) => { + return withCliManager(sessionId, (mgr) => mgr.configProviders(sessionId)) +}) + +ipcMain.handle( + IpcChannels.CLI_AGENT_AUTH_SET, + async (_event, sessionId: string, key: string, value: string) => { + return withCliManager(sessionId, (mgr) => mgr.authSet(sessionId, key, value)) + }, +) + +ipcMain.handle(IpcChannels.CLI_AGENT_PROVIDER_LIST, async (_event, sessionId: string) => { + return withCliManager(sessionId, (mgr) => mgr.providerList(sessionId)) +}) + +ipcMain.handle( + IpcChannels.CLI_AGENT_PROVIDER_AUTH, + async (_event, sessionId: string, providerId: string) => { + return withCliManager(sessionId, (mgr) => mgr.providerAuth(sessionId, providerId)) + }, +) + +ipcMain.handle( + IpcChannels.CLI_AGENT_PROVIDER_OAUTH_AUTHORIZE, + async (_event, sessionId: string, providerId: string) => { + return withCliManager(sessionId, (mgr) => mgr.providerOauthAuthorize(sessionId, providerId)) + }, +) + +ipcMain.handle( + IpcChannels.CLI_AGENT_PROVIDER_OAUTH_CALLBACK, + async (_event, sessionId: string, code: string) => { + return withCliManager(sessionId, (mgr) => mgr.providerOauthCallback(sessionId, code)) + }, +) + +ipcMain.handle(IpcChannels.CLI_AGENT_PATH_GET, async (_event, sessionId: string) => { + return withCliManager(sessionId, (mgr) => mgr.pathGet(sessionId)) +}) + +ipcMain.handle( + IpcChannels.CLI_AGENT_LOG_WRITE, + async ( + _event, + sessionId: string, + message: string, + level?: 'DEBUG' | 'INFO' | 'WARN' | 'ERROR', + ) => { + return withCliManager(sessionId, (mgr) => mgr.logWrite(sessionId, message, level)) + }, +) + +ipcMain.handle(IpcChannels.CLI_AGENT_SERVER_INFO, async (_event, workspaceId: string) => { + const runtime = runtimeRegistry.get(workspaceId) + const mgr = getCliAgentManager(runtime) + return mgr?.serverInfo() ?? null +}) + +// ─── CLI Agent: TUI control ───────────────────────────────────────────── + +const TUI_HANDLERS: Array<{ + channel: string + method: + | 'appendPrompt' + | 'submitPrompt' + | 'clearPrompt' + | 'openHelp' + | 'openSessions' + | 'openThemes' + | 'openModels' + | 'executeCommand' + | 'showToast' +}> = [ + { channel: IpcChannels.CLI_AGENT_TUI_APPEND_PROMPT, method: 'appendPrompt' }, + { channel: IpcChannels.CLI_AGENT_TUI_SUBMIT_PROMPT, method: 'submitPrompt' }, + { channel: IpcChannels.CLI_AGENT_TUI_CLEAR_PROMPT, method: 'clearPrompt' }, + { channel: IpcChannels.CLI_AGENT_TUI_OPEN_HELP, method: 'openHelp' }, + { channel: IpcChannels.CLI_AGENT_TUI_OPEN_SESSIONS, method: 'openSessions' }, + { channel: IpcChannels.CLI_AGENT_TUI_OPEN_THEMES, method: 'openThemes' }, + { channel: IpcChannels.CLI_AGENT_TUI_OPEN_MODELS, method: 'openModels' }, + { channel: IpcChannels.CLI_AGENT_TUI_EXECUTE_COMMAND, method: 'executeCommand' }, + { channel: IpcChannels.CLI_AGENT_TUI_SHOW_TOAST, method: 'showToast' }, +] +for (const { channel, method } of TUI_HANDLERS) { + ipcMain.handle(channel, async (_event, sessionId: string, args?: Record) => { + return withCliManager(sessionId, (mgr) => mgr.tui(sessionId, method, args)) + }) +} + // ─── Conversation History IPC handlers ────────────────────────── ipcMain.handle(IpcChannels.CONVERSATION_LIST, async (_event, workspaceId: string) => { @@ -1100,6 +1374,12 @@ ipcMain.handle(IpcChannels.SETTINGS_SET_USER, async (_event, key: string, value: for (const runtime of runtimeRegistry.list()) { getAgentManager(runtime)?.updateConfig(config) getAgentManager(runtime)?.updatePermissions(permConfig.permissionTier, permConfig.autoApprove) + // Mirror permission updates into the CLI agent manager so OpenCode + // permission decisions stay in sync with live tier changes. + getCliAgentManager(runtime)?.updatePermissions( + permConfig.permissionTier, + permConfig.autoApprove, + ) runtime.refreshWorkload() } } diff --git a/packages/main/src/preload.ts b/packages/main/src/preload.ts index 07a884e..5db4424 100644 --- a/packages/main/src/preload.ts +++ b/packages/main/src/preload.ts @@ -523,6 +523,116 @@ const api: WindowApi = { ipcRenderer.on(IpcChannels.CLI_AGENT_RESULT, handler) return () => ipcRenderer.removeListener(IpcChannels.CLI_AGENT_RESULT, handler) }, + onCliAgentWorkspaceCost: (callback: (summary: unknown) => void) => { + const handler = (_event: Electron.IpcRendererEvent, summary: unknown) => callback(summary) + ipcRenderer.on(IpcChannels.CLI_AGENT_WORKSPACE_COST, handler) + return () => ipcRenderer.removeListener(IpcChannels.CLI_AGENT_WORKSPACE_COST, handler) + }, + + // ─── CLI Agent: per-session config + listings ── + cliAgentUpdateSessionConfig: (sessionId: string, patch: Record) => + ipcRenderer.invoke(IpcChannels.CLI_AGENT_UPDATE_SESSION_CONFIG, sessionId, patch), + cliAgentListProviders: (sessionId: string) => + ipcRenderer.invoke(IpcChannels.CLI_AGENT_LIST_PROVIDERS, sessionId), + cliAgentListAgents: (sessionId: string) => + ipcRenderer.invoke(IpcChannels.CLI_AGENT_LIST_AGENTS, sessionId), + cliAgentListModes: (sessionId: string) => + ipcRenderer.invoke(IpcChannels.CLI_AGENT_LIST_MODES, sessionId), + cliAgentListTools: (sessionId: string, providerID: string, modelID: string) => + ipcRenderer.invoke(IpcChannels.CLI_AGENT_LIST_TOOLS, sessionId, providerID, modelID), + + // ─── CLI Agent: session ops ──────────────────── + cliAgentSessionShare: (sessionId: string) => + ipcRenderer.invoke(IpcChannels.CLI_AGENT_SESSION_SHARE, sessionId), + cliAgentSessionUnshare: (sessionId: string) => + ipcRenderer.invoke(IpcChannels.CLI_AGENT_SESSION_UNSHARE, sessionId), + cliAgentSessionSummarize: (sessionId: string) => + ipcRenderer.invoke(IpcChannels.CLI_AGENT_SESSION_SUMMARIZE, sessionId), + cliAgentSessionRevert: (sessionId: string, messageId: string) => + ipcRenderer.invoke(IpcChannels.CLI_AGENT_SESSION_REVERT, sessionId, messageId), + cliAgentSessionUnrevert: (sessionId: string) => + ipcRenderer.invoke(IpcChannels.CLI_AGENT_SESSION_UNREVERT, sessionId), + cliAgentSessionFork: (sessionId: string, messageId: string) => + ipcRenderer.invoke(IpcChannels.CLI_AGENT_SESSION_FORK, sessionId, messageId), + cliAgentSessionAbort: (sessionId: string) => + ipcRenderer.invoke(IpcChannels.CLI_AGENT_SESSION_ABORT, sessionId), + cliAgentSessionDiff: (sessionId: string) => + ipcRenderer.invoke(IpcChannels.CLI_AGENT_SESSION_DIFF, sessionId), + cliAgentSessionTodo: (sessionId: string) => + ipcRenderer.invoke(IpcChannels.CLI_AGENT_SESSION_TODO, sessionId), + cliAgentSessionInit: (sessionId: string) => + ipcRenderer.invoke(IpcChannels.CLI_AGENT_SESSION_INIT, sessionId), + cliAgentSessionDeleteRemote: (sessionId: string) => + ipcRenderer.invoke(IpcChannels.CLI_AGENT_SESSION_DELETE_REMOTE, sessionId), + + // ─── CLI Agent: workspace ops ────────────────── + cliAgentFileList: (sessionId: string, path: string) => + ipcRenderer.invoke(IpcChannels.CLI_AGENT_FILE_LIST, sessionId, path), + cliAgentFileRead: (sessionId: string, path: string) => + ipcRenderer.invoke(IpcChannels.CLI_AGENT_FILE_READ, sessionId, path), + cliAgentFileStatus: (sessionId: string) => + ipcRenderer.invoke(IpcChannels.CLI_AGENT_FILE_STATUS, sessionId), + cliAgentFindText: (sessionId: string, query: string) => + ipcRenderer.invoke(IpcChannels.CLI_AGENT_FIND_TEXT, sessionId, query), + cliAgentFindFiles: (sessionId: string, pattern: string) => + ipcRenderer.invoke(IpcChannels.CLI_AGENT_FIND_FILES, sessionId, pattern), + cliAgentFindSymbols: (sessionId: string, query: string) => + ipcRenderer.invoke(IpcChannels.CLI_AGENT_FIND_SYMBOLS, sessionId, query), + cliAgentShellRun: (sessionId: string, command: string) => + ipcRenderer.invoke(IpcChannels.CLI_AGENT_SHELL_RUN, sessionId, command), + cliAgentLspStatus: (sessionId: string) => + ipcRenderer.invoke(IpcChannels.CLI_AGENT_LSP_STATUS, sessionId), + cliAgentFormatterStatus: (sessionId: string) => + ipcRenderer.invoke(IpcChannels.CLI_AGENT_FORMATTER_STATUS, sessionId), + + // ─── CLI Agent: config / auth / providers ────── + cliAgentConfigGet: (sessionId: string) => + ipcRenderer.invoke(IpcChannels.CLI_AGENT_CONFIG_GET, sessionId), + cliAgentConfigUpdate: (sessionId: string, patch: Record) => + ipcRenderer.invoke(IpcChannels.CLI_AGENT_CONFIG_UPDATE, sessionId, patch), + cliAgentConfigProviders: (sessionId: string) => + ipcRenderer.invoke(IpcChannels.CLI_AGENT_CONFIG_PROVIDERS, sessionId), + cliAgentAuthSet: (sessionId: string, key: string, value: string) => + ipcRenderer.invoke(IpcChannels.CLI_AGENT_AUTH_SET, sessionId, key, value), + cliAgentProviderList: (sessionId: string) => + ipcRenderer.invoke(IpcChannels.CLI_AGENT_PROVIDER_LIST, sessionId), + cliAgentProviderAuth: (sessionId: string, providerId: string) => + ipcRenderer.invoke(IpcChannels.CLI_AGENT_PROVIDER_AUTH, sessionId, providerId), + cliAgentProviderOauthAuthorize: (sessionId: string, providerId: string) => + ipcRenderer.invoke(IpcChannels.CLI_AGENT_PROVIDER_OAUTH_AUTHORIZE, sessionId, providerId), + cliAgentProviderOauthCallback: (sessionId: string, code: string) => + ipcRenderer.invoke(IpcChannels.CLI_AGENT_PROVIDER_OAUTH_CALLBACK, sessionId, code), + cliAgentPathGet: (sessionId: string) => + ipcRenderer.invoke(IpcChannels.CLI_AGENT_PATH_GET, sessionId), + cliAgentLogWrite: ( + sessionId: string, + message: string, + level?: 'DEBUG' | 'INFO' | 'WARN' | 'ERROR', + ) => ipcRenderer.invoke(IpcChannels.CLI_AGENT_LOG_WRITE, sessionId, message, level), + cliAgentServerInfo: (workspaceId: string) => + ipcRenderer.invoke(IpcChannels.CLI_AGENT_SERVER_INFO, workspaceId), + + // ─── CLI Agent: TUI control ──────────────────── + cliAgentTuiAppendPrompt: (sessionId: string, text: string) => + ipcRenderer.invoke(IpcChannels.CLI_AGENT_TUI_APPEND_PROMPT, sessionId, { text }), + cliAgentTuiSubmitPrompt: (sessionId: string) => + ipcRenderer.invoke(IpcChannels.CLI_AGENT_TUI_SUBMIT_PROMPT, sessionId), + cliAgentTuiClearPrompt: (sessionId: string) => + ipcRenderer.invoke(IpcChannels.CLI_AGENT_TUI_CLEAR_PROMPT, sessionId), + cliAgentTuiOpenHelp: (sessionId: string) => + ipcRenderer.invoke(IpcChannels.CLI_AGENT_TUI_OPEN_HELP, sessionId), + cliAgentTuiOpenSessions: (sessionId: string) => + ipcRenderer.invoke(IpcChannels.CLI_AGENT_TUI_OPEN_SESSIONS, sessionId), + cliAgentTuiOpenThemes: (sessionId: string) => + ipcRenderer.invoke(IpcChannels.CLI_AGENT_TUI_OPEN_THEMES, sessionId), + cliAgentTuiOpenModels: (sessionId: string) => + ipcRenderer.invoke(IpcChannels.CLI_AGENT_TUI_OPEN_MODELS, sessionId), + cliAgentTuiExecuteCommand: (sessionId: string, command: string) => + ipcRenderer.invoke(IpcChannels.CLI_AGENT_TUI_EXECUTE_COMMAND, sessionId, { command }), + cliAgentTuiShowToast: ( + sessionId: string, + args: { title?: string; message: string; variant: string }, + ) => ipcRenderer.invoke(IpcChannels.CLI_AGENT_TUI_SHOW_TOAST, sessionId, args), // ─── Conversation History ───────────────────── conversationList: (workspaceId: string): Promise => diff --git a/packages/main/src/workspace/WorkspaceRuntime.ts b/packages/main/src/workspace/WorkspaceRuntime.ts index 785219f..f54fd75 100644 --- a/packages/main/src/workspace/WorkspaceRuntime.ts +++ b/packages/main/src/workspace/WorkspaceRuntime.ts @@ -55,6 +55,7 @@ export class WorkspaceRuntime { conversationStore: null, nativeSessionWatcher: null, nativeSessionCache: null, + approvalRouter: null, fileWatcher: null, gitStatus: null, worktreeManager: null, @@ -223,13 +224,18 @@ export class WorkspaceRuntime { } | null const cliAgentManager = this.services.cliAgentManager as { getRunningSessionCount?: () => number + getPendingApprovalCount?: () => number } | null + const pendingApproval = + (agentManager?.getPendingApprovalCount?.() ?? 0) + + (cliAgentManager?.getPendingApprovalCount?.() ?? 0) + this.workload = { tasksRunning: Boolean(taskRunner?.getRunning?.().length), pendingUserInput: Boolean(taskRunner?.getPendingInputCount?.()), agentsRunning: Boolean(agentManager?.getActiveSessionCount?.() || cliAgentManager?.getRunningSessionCount?.()), - pendingApproval: Boolean(agentManager?.getPendingApprovalCount?.()), + pendingApproval: pendingApproval > 0, } this.emitSnapshotChanged() } diff --git a/packages/main/src/workspace/runtimeTypes.ts b/packages/main/src/workspace/runtimeTypes.ts index f3bd7e9..c014b6d 100644 --- a/packages/main/src/workspace/runtimeTypes.ts +++ b/packages/main/src/workspace/runtimeTypes.ts @@ -35,6 +35,8 @@ export interface WorkspaceRuntimeServiceSlots { conversationStore: unknown | null nativeSessionWatcher: unknown | null nativeSessionCache: unknown | null + /** Single approval surface across built-in + CLI agent managers (CHAT_TOOL_APPROVE/REJECT). */ + approvalRouter: unknown | null /** Reserved — FS watchers use `fileWatcher.startWatchers(workspaceId)` keyed by runtime id */ fileWatcher: unknown | null /** Reserved — git polling uses `gitStatus` module map keyed by workspaceId */ diff --git a/packages/renderer/src/components/cliAgent/CostTokenBadge.tsx b/packages/renderer/src/components/cliAgent/CostTokenBadge.tsx new file mode 100644 index 0000000..47c00da --- /dev/null +++ b/packages/renderer/src/components/cliAgent/CostTokenBadge.tsx @@ -0,0 +1,63 @@ +import type { CliAgentTokenUsage } from '@aide/shared' + +export interface CostTokenBadgeProps { + costUsd?: number | null + tokens?: CliAgentTokenUsage | null + compact?: boolean + /** + * Suppress the dollar-cost portion of the badge while still showing the + * token breakdown. Used for the Claude Code CLI harness, which is billed + * via subscription rather than per-token API pricing. + */ + hideCost?: boolean +} + +/** Small badge showing cumulative cost + tokens for a session or message. */ +export function CostTokenBadge({ costUsd, tokens, compact, hideCost }: CostTokenBadgeProps) { + const cost = typeof costUsd === 'number' ? costUsd : 0 + const hasCost = !hideCost && cost > 0 + const hasTokens = tokens && (tokens.input + tokens.output + tokens.cacheRead + tokens.cacheWrite > 0) + if (!hasCost && !hasTokens) return null + + const formatCost = (n: number) => { + if (n < 0.0001) return '<$0.0001' + if (n < 0.01) return `$${n.toFixed(4)}` + if (n < 1) return `$${n.toFixed(3)}` + return `$${n.toFixed(2)}` + } + + const formatTokens = (n: number) => { + if (n < 1000) return String(n) + if (n < 1_000_000) return `${(n / 1000).toFixed(1)}k` + return `${(n / 1_000_000).toFixed(2)}M` + } + + return ( + + {hasCost && {formatCost(cost)}} + {hasTokens && tokens && ( + + {formatTokens(tokens.input)}↑ {formatTokens(tokens.output)}↓ + {tokens.cacheRead > 0 ? ` ${formatTokens(tokens.cacheRead)}c` : ''} + + )} + + ) +} diff --git a/packages/renderer/src/components/cliAgent/DiagnosticsPanel.tsx b/packages/renderer/src/components/cliAgent/DiagnosticsPanel.tsx new file mode 100644 index 0000000..106005a --- /dev/null +++ b/packages/renderer/src/components/cliAgent/DiagnosticsPanel.tsx @@ -0,0 +1,106 @@ +import { useEffect, useState } from 'react' +import type { OpenCodePathInfo, OpenCodeServerInfo } from '@aide/shared' + +/** + * Read-only diagnostics for an active OpenCode session: server info, + * resolved paths, LSP/formatter status. Hidden behind a disclosure to + * keep the pane header tidy. + */ +export function DiagnosticsPanel({ + workspaceId, + sessionId, +}: { + workspaceId: string | null + sessionId: string | null +}) { + const [open, setOpen] = useState(false) + const [server, setServer] = useState(null) + const [paths, setPaths] = useState(null) + const [lsp, setLsp] = useState(null) + const [formatter, setFormatter] = useState(null) + const [error, setError] = useState(null) + + useEffect(() => { + if (!open || !sessionId || !workspaceId) return + let cancelled = false + void (async () => { + try { + const s = (await window.api.cliAgentServerInfo(workspaceId)) as OpenCodeServerInfo | null + if (!cancelled) setServer(s) + const p = await window.api.cliAgentPathGet(sessionId) + if (!cancelled && !p.error) setPaths((p.paths as OpenCodePathInfo) ?? null) + const l = await window.api.cliAgentLspStatus(sessionId) + if (!cancelled) setLsp(l.status) + const f = await window.api.cliAgentFormatterStatus(sessionId) + if (!cancelled) setFormatter(f.status) + } catch (e) { + if (!cancelled) setError(e instanceof Error ? e.message : String(e)) + } + })() + return () => { + cancelled = true + } + }, [open, sessionId, workspaceId]) + + if (!sessionId) return null + + return ( +
setOpen((e.target as HTMLDetailsElement).open)} + style={{ + margin: '6px 0', + padding: '6px 8px', + background: 'var(--color-surface-2, rgba(255,255,255,0.04))', + borderRadius: 4, + fontSize: 11, + }} + > + Diagnostics + {open && ( +
+ {error &&
{error}
} + {server && ( + + {server.url} ({server.mode}) pid={server.pid} + + )} + {paths?.config && {paths.config}} + {paths?.data && {paths.data}} + {paths?.cache && {paths.cache}} + {paths?.log && {paths.log}} + {lsp != null && ( + + {JSON.stringify(lsp).slice(0, 200)} + + )} + {formatter != null && ( + + {JSON.stringify(formatter).slice(0, 200)} + + )} + +
+ )} +
+ ) +} + +function Field({ label, children }: { label: string; children: React.ReactNode }) { + return ( +
+ {label}: + {children} +
+ ) +} diff --git a/packages/renderer/src/components/cliAgent/RichPartRenderer.tsx b/packages/renderer/src/components/cliAgent/RichPartRenderer.tsx new file mode 100644 index 0000000..ed32b0e --- /dev/null +++ b/packages/renderer/src/components/cliAgent/RichPartRenderer.tsx @@ -0,0 +1,270 @@ +import { useMemo, useState } from 'react' +import type { CliAgentMessage } from '@aide/shared' +import { renderMarkdown } from '../../lib/markdownRenderer' +import { CostTokenBadge } from './CostTokenBadge' + +/** + * Render a rich CliAgentMessage variant. Handles every part type the OpenCode + * adapter can emit (reasoning / patch / step / snapshot / retry / compaction / + * agent_change / subtask / file_attachment) plus a fallback for the original + * built-in message types so the existing CliAgentPane bubble can defer here. + */ +export function RichPartRenderer({ + message, + onRevertMessage, +}: { + message: CliAgentMessage + onRevertMessage?: (messageId: string) => void +}) { + switch (message.type) { + case 'reasoning': + return + case 'patch': + return + case 'step': + return + case 'snapshot': + return + case 'retry': + return + case 'compaction': + return + case 'agent_change': + return + case 'subtask': + return + case 'file_attachment': + return + default: + return null + } +} + +function ReasoningBubble({ message }: { message: CliAgentMessage }) { + const [open, setOpen] = useState(!message.reasoningCollapsed) + const html = useMemo(() => renderMarkdown(message.content || ''), [message.content]) + return ( +
setOpen((e.target as HTMLDetailsElement).open)} + className="cli-agent-msg cli-agent-msg--reasoning" + style={{ + margin: '4px 0', + padding: '4px 8px', + borderLeft: '2px solid var(--color-accent-muted, #888)', + opacity: 0.85, + fontSize: 12, + }} + > + + Reasoning + +
+
+ ) +} + +function PatchBubble({ message }: { message: CliAgentMessage }) { + const files = message.patchFiles ?? [] + return ( +
+ Patch + {message.patchHash && ( + {message.patchHash.slice(0, 8)} + )} +
    + {files.map((f) => ( +
  • + {f} +
  • + ))} +
+
+ ) +} + +function StepBubble({ message }: { message: CliAgentMessage }) { + const isFinish = message.stepPhase === 'finish' + return ( +
+ {isFinish ? '◼' : '▶'} + {message.stepReason ?? message.content} + {isFinish && ( + + )} +
+ ) +} + +function SnapshotBubble({ + message, + onRevert, +}: { + message: CliAgentMessage + onRevert?: (messageId: string) => void +}) { + return ( +
+ 📌 Snapshot + {message.snapshotHash && ( + {message.snapshotHash.slice(0, 8)} + )} + {onRevert && ( + + )} +
+ ) +} + +function RetryBubble({ message }: { message: CliAgentMessage }) { + return ( +
+ ⚠ {message.content} +
+ ) +} + +function CompactionBubble({ message }: { message: CliAgentMessage }) { + return ( +
+ ─── {message.content} {message.compactionAuto ? '(auto)' : ''} ─── +
+ ) +} + +function AgentChangeBubble({ message }: { message: CliAgentMessage }) { + return ( +
+ 🤖 Agent → {message.agentName ?? message.content} +
+ ) +} + +function SubtaskBubble({ message }: { message: CliAgentMessage }) { + return ( +
+ Subtask + {message.subtaskAgent && ({message.subtaskAgent})} +
{message.subtaskDescription ?? message.subtaskPrompt ?? message.content}
+
+ ) +} + +function FileAttachmentBubble({ message }: { message: CliAgentMessage }) { + return ( +
+ 📎 {message.fileName ?? message.fileUrl ?? message.content} + {message.fileMime && ({message.fileMime})} +
+ ) +} + +export function isRichPartType(type: CliAgentMessage['type']): boolean { + return ( + type === 'reasoning' || + type === 'patch' || + type === 'step' || + type === 'snapshot' || + type === 'retry' || + type === 'compaction' || + type === 'agent_change' || + type === 'subtask' || + type === 'file_attachment' + ) +} diff --git a/packages/renderer/src/components/cliAgent/SessionMenu.tsx b/packages/renderer/src/components/cliAgent/SessionMenu.tsx new file mode 100644 index 0000000..fdf4e32 --- /dev/null +++ b/packages/renderer/src/components/cliAgent/SessionMenu.tsx @@ -0,0 +1,148 @@ +import { useState } from 'react' + +/** + * Kebab menu in the CliAgentPane header for OpenCode session ops: + * share, summarize, revert, unrevert, abort, fork, view diff, todos, + * initialize project, delete remote. + * + * Each action calls the corresponding window.api.cliAgentSession* IPC + * and surfaces success / error in a small toast. + */ +export function SessionMenu({ + sessionId, + disabled, + onMessage, +}: { + sessionId: string | null + disabled?: boolean + onMessage: (text: string, variant?: 'success' | 'error') => void +}) { + const [open, setOpen] = useState(false) + const [busy, setBusy] = useState(false) + + const wrap = async (fn: () => Promise<{ error?: string } | { url?: string } | { newSessionId?: string }>) => { + if (!sessionId) return + setBusy(true) + setOpen(false) + try { + const result = await fn() + if ('error' in result && result.error) { + onMessage(result.error, 'error') + } else if ('url' in result && result.url) { + try { + await navigator.clipboard.writeText(result.url) + onMessage(`Shared · URL copied to clipboard`, 'success') + } catch { + onMessage(`Shared · ${result.url}`, 'success') + } + } else if ('newSessionId' in result && result.newSessionId) { + onMessage(`Forked → ${result.newSessionId.slice(0, 8)}`, 'success') + } else { + onMessage('Done', 'success') + } + } catch (e) { + onMessage(e instanceof Error ? e.message : String(e), 'error') + } finally { + setBusy(false) + } + } + + if (!sessionId) return null + + return ( +
+ + {open && ( +
+ wrap(() => window.api.cliAgentSessionShare(sessionId))}> + Share session + + wrap(() => window.api.cliAgentSessionUnshare(sessionId))}> + Unshare + + wrap(() => window.api.cliAgentSessionSummarize(sessionId))}> + Summarize + + wrap(() => window.api.cliAgentSessionUnrevert(sessionId))}> + Unrevert + + wrap(() => window.api.cliAgentSessionAbort(sessionId))}> + Abort + + wrap(() => window.api.cliAgentSessionInit(sessionId))}> + Initialize AGENTS.md + + wrap(() => window.api.cliAgentSessionDeleteRemote(sessionId))} + danger + > + Delete remote session + +
+ )} +
+ ) +} + +function MenuItem({ + children, + onClick, + danger, +}: { + children: React.ReactNode + onClick: () => void + danger?: boolean +}) { + return ( + + ) +} diff --git a/packages/renderer/src/components/cliAgent/SessionSettingsPanel.tsx b/packages/renderer/src/components/cliAgent/SessionSettingsPanel.tsx new file mode 100644 index 0000000..bc2df05 --- /dev/null +++ b/packages/renderer/src/components/cliAgent/SessionSettingsPanel.tsx @@ -0,0 +1,338 @@ +import { useEffect, useState } from 'react' +import type { + CliAgentBackendState, + OpenCodeAgentSummary, + OpenCodeProviderSummary, + OpenCodeToolSummary, +} from '@aide/shared' + +/** + * Collapsible per-session settings panel for OpenCode sessions. + * Wraps provider/model picker, agent picker, mode picker, system prompt + * editor, and tool toggle list. Each sub-component lazily fetches its + * options the first time the panel is opened. + */ +export function SessionSettingsPanel({ + sessionId, + backendState, + onPatch, +}: { + sessionId: string | null + backendState: CliAgentBackendState + onPatch: (patch: Partial) => Promise +}) { + const [open, setOpen] = useState(false) + if (!sessionId) return null + + return ( +
setOpen((e.target as HTMLDetailsElement).open)} + className="cli-agent-session-settings" + style={{ + margin: '6px 0', + padding: '6px 8px', + background: 'var(--color-surface-2, rgba(255,255,255,0.04))', + borderRadius: 4, + fontSize: 12, + }} + > + + Session settings + + {open && ( +
+ + + + +
+ )} +
+ ) +} + +function ProviderModelPicker({ + sessionId, + state, + onPatch, +}: { + sessionId: string + state: CliAgentBackendState + onPatch: (patch: Partial) => Promise +}) { + const [providers, setProviders] = useState([]) + const [loading, setLoading] = useState(false) + const [error, setError] = useState(null) + + useEffect(() => { + let cancelled = false + setLoading(true) + void (async () => { + try { + const result = (await window.api.cliAgentListProviders(sessionId)) as + | OpenCodeProviderSummary[] + | { error: string } + | undefined + if (cancelled) return + if (Array.isArray(result)) setProviders(result) + else if (result && typeof result === 'object' && 'error' in result) { + setError(result.error ?? 'Failed to load providers') + } + } catch (e) { + if (!cancelled) setError(e instanceof Error ? e.message : String(e)) + } finally { + if (!cancelled) setLoading(false) + } + })() + return () => { + cancelled = true + } + }, [sessionId]) + + const provider = providers.find((p) => p.id === state.providerID) + return ( +
+ +
+ + +
+ {error &&
{error}
} +
+ ) +} + +function AgentModePicker({ + sessionId, + state, + onPatch, +}: { + sessionId: string + state: CliAgentBackendState + onPatch: (patch: Partial) => Promise +}) { + const [agents, setAgents] = useState([]) + const [modes, setModes] = useState([]) + + useEffect(() => { + let cancelled = false + void (async () => { + try { + const [a, m] = await Promise.all([ + window.api.cliAgentListAgents(sessionId), + window.api.cliAgentListModes(sessionId), + ]) + if (cancelled) return + if (Array.isArray(a)) setAgents(a as OpenCodeAgentSummary[]) + if (Array.isArray(m)) setModes(m as string[]) + } catch { + /* ignore */ + } + })() + return () => { + cancelled = true + } + }, [sessionId]) + + return ( +
+
+ + +
+
+ + +
+
+ ) +} + +function SystemPromptEditor({ + state, + onPatch, +}: { + state: CliAgentBackendState + onPatch: (patch: Partial) => Promise +}) { + const [value, setValue] = useState(state.systemPromptOverride ?? '') + useEffect(() => setValue(state.systemPromptOverride ?? ''), [state.systemPromptOverride]) + return ( +
+ +