From 314c6d77aafc3d146da9ef55c70195f267f1bd6e Mon Sep 17 00:00:00 2001 From: Hibryda Date: Fri, 6 Mar 2026 01:01:56 +0100 Subject: [PATCH] feat(v2): add agent pane with SDK message adapter and dispatcher Implement full agent session frontend: SDK message adapter parsing stream-json into 9 typed message types, agent bridge for Tauri IPC, dispatcher routing sidecar events to store, agent session store with cost tracking, and AgentPane component with prompt input, message rendering (text, thinking, tool calls, results, cost), and stop button. Add Ctrl+Shift+N shortcut and sidebar agent button. --- v2/src/App.svelte | 27 +- v2/src/lib/adapters/agent-bridge.ts | 49 ++ v2/src/lib/adapters/sdk-messages.ts | 231 +++++++++- v2/src/lib/agent-dispatcher.ts | 90 ++++ v2/src/lib/components/Agent/AgentPane.svelte | 419 ++++++++++++++++++ .../lib/components/Layout/TilingGrid.svelte | 9 +- .../lib/components/Sidebar/SessionList.svelte | 29 +- v2/src/lib/stores/agents.ts | 101 ++++- 8 files changed, 914 insertions(+), 41 deletions(-) create mode 100644 v2/src/lib/adapters/agent-bridge.ts create mode 100644 v2/src/lib/agent-dispatcher.ts create mode 100644 v2/src/lib/components/Agent/AgentPane.svelte 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); }