diff --git a/v2/src/App.svelte b/v2/src/App.svelte index d412de3..7e0c724 100644 --- a/v2/src/App.svelte +++ b/v2/src/App.svelte @@ -1,8 +1,9 @@ diff --git a/v2/src/lib/adapters/agent-bridge.ts b/v2/src/lib/adapters/agent-bridge.ts new file mode 100644 index 0000000..4eb261b --- /dev/null +++ b/v2/src/lib/adapters/agent-bridge.ts @@ -0,0 +1,49 @@ +// Agent Bridge — Tauri IPC adapter for sidecar communication +// Mirrors pty-bridge.ts pattern: invoke for commands, listen for events + +import { invoke } from '@tauri-apps/api/core'; +import { listen, type UnlistenFn } from '@tauri-apps/api/event'; + +export interface AgentQueryOptions { + session_id: string; + prompt: string; + cwd?: string; + max_turns?: number; + max_budget_usd?: number; + resume_session_id?: string; +} + +export async function queryAgent(options: AgentQueryOptions): Promise { + return invoke('agent_query', { options }); +} + +export async function stopAgent(sessionId: string): Promise { + return invoke('agent_stop', { sessionId }); +} + +export async function isAgentReady(): Promise { + return invoke('agent_ready'); +} + +export interface SidecarMessage { + type: string; + sessionId?: string; + event?: Record; + message?: string; + exitCode?: number | null; + signal?: string | null; +} + +export async function onSidecarMessage( + callback: (msg: SidecarMessage) => void, +): Promise { + return listen('sidecar-message', (event) => { + callback(event.payload as SidecarMessage); + }); +} + +export async function onSidecarExited(callback: () => void): Promise { + return listen('sidecar-exited', () => { + callback(); + }); +} diff --git a/v2/src/lib/adapters/sdk-messages.ts b/v2/src/lib/adapters/sdk-messages.ts index fc85e13..e27509e 100644 --- a/v2/src/lib/adapters/sdk-messages.ts +++ b/v2/src/lib/adapters/sdk-messages.ts @@ -1,25 +1,234 @@ // SDK Message Adapter — insulates UI from Claude Agent SDK wire format changes // This is the ONLY place that knows SDK internals. -// Phase 3: full implementation + +export type AgentMessageType = + | 'init' + | 'text' + | 'thinking' + | 'tool_call' + | 'tool_result' + | 'status' + | 'cost' + | 'error' + | 'unknown'; export interface AgentMessage { id: string; - type: 'text' | 'tool_call' | 'tool_result' | 'subagent_spawn' | 'subagent_stop' | 'status' | 'cost' | 'unknown'; + type: AgentMessageType; parentId?: string; content: unknown; timestamp: number; } +export interface InitContent { + sessionId: string; + model: string; + cwd: string; + tools: string[]; +} + +export interface TextContent { + text: string; +} + +export interface ThinkingContent { + text: string; +} + +export interface ToolCallContent { + toolUseId: string; + name: string; + input: unknown; +} + +export interface ToolResultContent { + toolUseId: string; + output: unknown; +} + +export interface StatusContent { + subtype: string; + message?: string; +} + +export interface CostContent { + totalCostUsd: number; + durationMs: number; + inputTokens: number; + outputTokens: number; + numTurns: number; + isError: boolean; + result?: string; + errors?: string[]; +} + +export interface ErrorContent { + message: string; +} + /** - * Adapt a raw SDK message to our internal format. + * Adapt a raw SDK stream-json message to our internal format. * When SDK changes wire format, only this function needs updating. */ -export function adaptSDKMessage(raw: Record): AgentMessage { - // Phase 3: implement based on actual SDK message types - return { - id: (raw.id as string) ?? crypto.randomUUID(), - type: 'unknown', - content: raw, - timestamp: Date.now(), - }; +export function adaptSDKMessage(raw: Record): AgentMessage[] { + const uuid = (raw.uuid as string) ?? crypto.randomUUID(); + const timestamp = Date.now(); + const parentId = raw.parent_tool_use_id as string | undefined; + + switch (raw.type) { + case 'system': + return adaptSystemMessage(raw, uuid, timestamp); + case 'assistant': + return adaptAssistantMessage(raw, uuid, timestamp, parentId); + case 'user': + return adaptUserMessage(raw, uuid, timestamp, parentId); + case 'result': + return adaptResultMessage(raw, uuid, timestamp); + default: + return [{ + id: uuid, + type: 'unknown', + content: raw, + timestamp, + }]; + } +} + +function adaptSystemMessage( + raw: Record, + uuid: string, + timestamp: number, +): AgentMessage[] { + const subtype = raw.subtype as string; + + if (subtype === 'init') { + return [{ + id: uuid, + type: 'init', + content: { + sessionId: raw.session_id as string, + model: raw.model as string, + cwd: raw.cwd as string, + tools: (raw.tools as string[]) ?? [], + } satisfies InitContent, + timestamp, + }]; + } + + return [{ + id: uuid, + type: 'status', + content: { + subtype, + message: raw.status as string | undefined, + } satisfies StatusContent, + timestamp, + }]; +} + +function adaptAssistantMessage( + raw: Record, + uuid: string, + timestamp: number, + parentId?: string, +): AgentMessage[] { + const messages: AgentMessage[] = []; + const msg = raw.message as Record | undefined; + if (!msg) return messages; + + const content = msg.content as Array> | undefined; + if (!Array.isArray(content)) return messages; + + for (const block of content) { + switch (block.type) { + case 'text': + messages.push({ + id: `${uuid}-text-${messages.length}`, + type: 'text', + parentId, + content: { text: block.text as string } satisfies TextContent, + timestamp, + }); + break; + case 'thinking': + messages.push({ + id: `${uuid}-think-${messages.length}`, + type: 'thinking', + parentId, + content: { text: (block.thinking ?? block.text) as string } satisfies ThinkingContent, + timestamp, + }); + break; + case 'tool_use': + messages.push({ + id: `${uuid}-tool-${messages.length}`, + type: 'tool_call', + parentId, + content: { + toolUseId: block.id as string, + name: block.name as string, + input: block.input, + } satisfies ToolCallContent, + timestamp, + }); + break; + } + } + + return messages; +} + +function adaptUserMessage( + raw: Record, + uuid: string, + timestamp: number, + parentId?: string, +): AgentMessage[] { + const messages: AgentMessage[] = []; + const msg = raw.message as Record | undefined; + if (!msg) return messages; + + const content = msg.content as Array> | undefined; + if (!Array.isArray(content)) return messages; + + for (const block of content) { + if (block.type === 'tool_result') { + messages.push({ + id: `${uuid}-result-${messages.length}`, + type: 'tool_result', + parentId, + content: { + toolUseId: block.tool_use_id as string, + output: block.content ?? raw.tool_use_result, + } satisfies ToolResultContent, + timestamp, + }); + } + } + + return messages; +} + +function adaptResultMessage( + raw: Record, + uuid: string, + timestamp: number, +): AgentMessage[] { + const usage = raw.usage as Record | undefined; + + return [{ + id: uuid, + type: 'cost', + content: { + totalCostUsd: (raw.total_cost_usd as number) ?? 0, + durationMs: (raw.duration_ms as number) ?? 0, + inputTokens: usage?.input_tokens ?? 0, + outputTokens: usage?.output_tokens ?? 0, + numTurns: (raw.num_turns as number) ?? 0, + isError: (raw.is_error as boolean) ?? false, + result: raw.result as string | undefined, + errors: raw.errors as string[] | undefined, + } satisfies CostContent, + timestamp, + }]; } diff --git a/v2/src/lib/agent-dispatcher.ts b/v2/src/lib/agent-dispatcher.ts new file mode 100644 index 0000000..e694cb7 --- /dev/null +++ b/v2/src/lib/agent-dispatcher.ts @@ -0,0 +1,90 @@ +// Agent Dispatcher — connects sidecar bridge events to agent store +// Single listener that routes sidecar messages to the correct agent session + +import { onSidecarMessage, type SidecarMessage } from './adapters/agent-bridge'; +import { adaptSDKMessage } from './adapters/sdk-messages'; +import type { InitContent, CostContent } from './adapters/sdk-messages'; +import { + updateAgentStatus, + setAgentSdkSessionId, + setAgentModel, + appendAgentMessages, + updateAgentCost, +} from './stores/agents'; + +let unlistenFn: (() => void) | null = null; + +export async function startAgentDispatcher(): Promise { + if (unlistenFn) return; + + unlistenFn = await onSidecarMessage((msg: SidecarMessage) => { + const sessionId = msg.sessionId; + if (!sessionId) return; + + switch (msg.type) { + case 'agent_started': + updateAgentStatus(sessionId, 'running'); + break; + + case 'agent_event': + handleAgentEvent(sessionId, msg.event!); + break; + + case 'agent_stopped': + updateAgentStatus(sessionId, 'done'); + break; + + case 'agent_error': + updateAgentStatus(sessionId, 'error', msg.message); + break; + + case 'agent_log': + // Debug logging — could route to a log panel later + break; + } + }); +} + +function handleAgentEvent(sessionId: string, event: Record): void { + const messages = adaptSDKMessage(event); + + for (const msg of messages) { + switch (msg.type) { + case 'init': { + const init = msg.content as InitContent; + setAgentSdkSessionId(sessionId, init.sessionId); + setAgentModel(sessionId, init.model); + break; + } + + case 'cost': { + const cost = msg.content as CostContent; + updateAgentCost(sessionId, { + costUsd: cost.totalCostUsd, + inputTokens: cost.inputTokens, + outputTokens: cost.outputTokens, + numTurns: cost.numTurns, + durationMs: cost.durationMs, + }); + if (cost.isError) { + updateAgentStatus(sessionId, 'error', cost.errors?.join('; ')); + } else { + updateAgentStatus(sessionId, 'done'); + } + break; + } + } + } + + // Append all messages to the session history + if (messages.length > 0) { + appendAgentMessages(sessionId, messages); + } +} + +export function stopAgentDispatcher(): void { + if (unlistenFn) { + unlistenFn(); + unlistenFn = null; + } +} diff --git a/v2/src/lib/components/Agent/AgentPane.svelte b/v2/src/lib/components/Agent/AgentPane.svelte new file mode 100644 index 0000000..1a6383c --- /dev/null +++ b/v2/src/lib/components/Agent/AgentPane.svelte @@ -0,0 +1,419 @@ + + +
+ {#if !session || session.messages.length === 0} +
+
+ + +
+
+ {:else} +
+ {#each session.messages as msg (msg.id)} +
+ {#if msg.type === 'init'} +
+ Session started + {(msg.content as import('../../adapters/sdk-messages').InitContent).model} +
+ {:else if msg.type === 'text'} +
{(msg.content as TextContent).text}
+ {:else if msg.type === 'thinking'} +
+ Thinking... +
{(msg.content as ThinkingContent).text}
+
+ {:else if msg.type === 'tool_call'} + {@const tc = msg.content as ToolCallContent} +
+ + {tc.name} + {truncate(tc.toolUseId, 12)} + +
{formatToolInput(tc.input)}
+
+ {:else if msg.type === 'tool_result'} + {@const tr = msg.content as ToolResultContent} +
+ Tool result +
{formatToolInput(tr.output)}
+
+ {:else if msg.type === 'cost'} + {@const cost = msg.content as CostContent} +
+ ${cost.totalCostUsd.toFixed(4)} + {cost.inputTokens + cost.outputTokens} tokens + {cost.numTurns} turns + {(cost.durationMs / 1000).toFixed(1)}s +
+ {:else if msg.type === 'error'} +
{(msg.content as ErrorContent).message}
+ {:else if msg.type === 'status'} +
{JSON.stringify(msg.content)}
+ {/if} +
+ {/each} +
+ + + {/if} +
+ + diff --git a/v2/src/lib/components/Layout/TilingGrid.svelte b/v2/src/lib/components/Layout/TilingGrid.svelte index dbe8ced..a5c9c8d 100644 --- a/v2/src/lib/components/Layout/TilingGrid.svelte +++ b/v2/src/lib/components/Layout/TilingGrid.svelte @@ -1,6 +1,7 @@

Sessions

- +
+ + +
@@ -45,14 +58,14 @@ {#if panes.length === 0}

No sessions yet.

-

Click + or press Ctrl+N

+

Ctrl+N terminal / Ctrl+Shift+N agent

{:else}
    {#each panes as pane (pane.id)}
  • @@ -82,6 +95,11 @@ color: var(--text-primary); } + .header-buttons { + display: flex; + gap: 4px; + } + .new-btn { background: var(--bg-surface); border: 1px solid var(--border); @@ -90,7 +108,8 @@ height: 24px; border-radius: var(--border-radius); cursor: pointer; - font-size: 16px; + font-size: 12px; + font-weight: 600; display: flex; align-items: center; justify-content: center; diff --git a/v2/src/lib/stores/agents.ts b/v2/src/lib/stores/agents.ts index 26dba97..a9003ae 100644 --- a/v2/src/lib/stores/agents.ts +++ b/v2/src/lib/stores/agents.ts @@ -1,34 +1,91 @@ // Agent tracking state — Svelte 5 runes -// Phase 3: SDK agent lifecycle, subagent tree +// Manages agent session lifecycle and message history -export type AgentStatus = 'idle' | 'running' | 'thinking' | 'waiting' | 'done' | 'error'; +import type { AgentMessage } from '../adapters/sdk-messages'; -export interface AgentState { +export type AgentStatus = 'idle' | 'starting' | 'running' | 'done' | 'error'; + +export interface AgentSession { id: string; - sessionId: string; - parentId?: string; + sdkSessionId?: string; status: AgentStatus; model?: string; - costUsd?: number; - tokensIn?: number; - tokensOut?: number; + prompt: string; + messages: AgentMessage[]; + costUsd: number; + inputTokens: number; + outputTokens: number; + numTurns: number; + durationMs: number; + error?: string; } -let agents = $state([]); +let sessions = $state([]); -export function getAgents() { - return agents; +export function getAgentSessions(): AgentSession[] { + return sessions; } -export function getAgentTree(rootId: string): AgentState[] { - const result: AgentState[] = []; - const root = agents.find(a => a.id === rootId); - if (!root) return result; - - result.push(root); - const children = agents.filter(a => a.parentId === rootId); - for (const child of children) { - result.push(...getAgentTree(child.id)); - } - return result; +export function getAgentSession(id: string): AgentSession | undefined { + return sessions.find(s => s.id === id); +} + +export function createAgentSession(id: string, prompt: string): void { + sessions.push({ + id, + status: 'starting', + prompt, + messages: [], + costUsd: 0, + inputTokens: 0, + outputTokens: 0, + numTurns: 0, + durationMs: 0, + }); +} + +export function updateAgentStatus(id: string, status: AgentStatus, error?: string): void { + const session = sessions.find(s => s.id === id); + if (!session) return; + session.status = status; + if (error) session.error = error; +} + +export function setAgentSdkSessionId(id: string, sdkSessionId: string): void { + const session = sessions.find(s => s.id === id); + if (session) session.sdkSessionId = sdkSessionId; +} + +export function setAgentModel(id: string, model: string): void { + const session = sessions.find(s => s.id === id); + if (session) session.model = model; +} + +export function appendAgentMessage(id: string, message: AgentMessage): void { + const session = sessions.find(s => s.id === id); + if (!session) return; + session.messages.push(message); +} + +export function appendAgentMessages(id: string, messages: AgentMessage[]): void { + const session = sessions.find(s => s.id === id); + if (!session) return; + session.messages.push(...messages); +} + +export function updateAgentCost( + id: string, + cost: { costUsd: number; inputTokens: number; outputTokens: number; numTurns: number; durationMs: number }, +): void { + const session = sessions.find(s => s.id === id); + if (!session) return; + session.costUsd = cost.costUsd; + session.inputTokens = cost.inputTokens; + session.outputTokens = cost.outputTokens; + session.numTurns = cost.numTurns; + session.durationMs = cost.durationMs; +} + +export function removeAgentSession(id: string): void { + sessions = sessions.filter(s => s.id !== id); }