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.
This commit is contained in:
parent
f928501075
commit
314c6d77aa
8 changed files with 914 additions and 41 deletions
|
|
@ -1,8 +1,9 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import SessionList from './lib/components/Sidebar/SessionList.svelte';
|
||||
import TilingGrid from './lib/components/Layout/TilingGrid.svelte';
|
||||
import { addPane, focusPaneByIndex, getPanes } from './lib/stores/layout';
|
||||
import { startAgentDispatcher, stopAgentDispatcher } from './lib/agent-dispatcher';
|
||||
|
||||
function newTerminal() {
|
||||
const id = crypto.randomUUID();
|
||||
|
|
@ -14,7 +15,19 @@
|
|||
});
|
||||
}
|
||||
|
||||
function newAgent() {
|
||||
const id = crypto.randomUUID();
|
||||
const num = getPanes().filter(p => p.type === 'agent').length + 1;
|
||||
addPane({
|
||||
id,
|
||||
type: 'agent',
|
||||
title: `Agent ${num}`,
|
||||
});
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
startAgentDispatcher();
|
||||
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
// Ctrl+N — new terminal
|
||||
if (e.ctrlKey && !e.shiftKey && e.key === 'n') {
|
||||
|
|
@ -23,6 +36,13 @@
|
|||
return;
|
||||
}
|
||||
|
||||
// Ctrl+Shift+N — new agent
|
||||
if (e.ctrlKey && e.shiftKey && e.key === 'N') {
|
||||
e.preventDefault();
|
||||
newAgent();
|
||||
return;
|
||||
}
|
||||
|
||||
// Ctrl+1-4 — focus pane by index
|
||||
if (e.ctrlKey && !e.shiftKey && e.key >= '1' && e.key <= '4') {
|
||||
e.preventDefault();
|
||||
|
|
@ -32,7 +52,10 @@
|
|||
}
|
||||
|
||||
window.addEventListener('keydown', handleKeydown);
|
||||
return () => window.removeEventListener('keydown', handleKeydown);
|
||||
return () => {
|
||||
window.removeEventListener('keydown', handleKeydown);
|
||||
stopAgentDispatcher();
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
|
|
|
|||
49
v2/src/lib/adapters/agent-bridge.ts
Normal file
49
v2/src/lib/adapters/agent-bridge.ts
Normal file
|
|
@ -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<void> {
|
||||
return invoke('agent_query', { options });
|
||||
}
|
||||
|
||||
export async function stopAgent(sessionId: string): Promise<void> {
|
||||
return invoke('agent_stop', { sessionId });
|
||||
}
|
||||
|
||||
export async function isAgentReady(): Promise<boolean> {
|
||||
return invoke<boolean>('agent_ready');
|
||||
}
|
||||
|
||||
export interface SidecarMessage {
|
||||
type: string;
|
||||
sessionId?: string;
|
||||
event?: Record<string, unknown>;
|
||||
message?: string;
|
||||
exitCode?: number | null;
|
||||
signal?: string | null;
|
||||
}
|
||||
|
||||
export async function onSidecarMessage(
|
||||
callback: (msg: SidecarMessage) => void,
|
||||
): Promise<UnlistenFn> {
|
||||
return listen<SidecarMessage>('sidecar-message', (event) => {
|
||||
callback(event.payload as SidecarMessage);
|
||||
});
|
||||
}
|
||||
|
||||
export async function onSidecarExited(callback: () => void): Promise<UnlistenFn> {
|
||||
return listen('sidecar-exited', () => {
|
||||
callback();
|
||||
});
|
||||
}
|
||||
|
|
@ -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<string, unknown>): 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<string, unknown>): 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<string, unknown>,
|
||||
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<string, unknown>,
|
||||
uuid: string,
|
||||
timestamp: number,
|
||||
parentId?: string,
|
||||
): AgentMessage[] {
|
||||
const messages: AgentMessage[] = [];
|
||||
const msg = raw.message as Record<string, unknown> | undefined;
|
||||
if (!msg) return messages;
|
||||
|
||||
const content = msg.content as Array<Record<string, unknown>> | 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<string, unknown>,
|
||||
uuid: string,
|
||||
timestamp: number,
|
||||
parentId?: string,
|
||||
): AgentMessage[] {
|
||||
const messages: AgentMessage[] = [];
|
||||
const msg = raw.message as Record<string, unknown> | undefined;
|
||||
if (!msg) return messages;
|
||||
|
||||
const content = msg.content as Array<Record<string, unknown>> | 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<string, unknown>,
|
||||
uuid: string,
|
||||
timestamp: number,
|
||||
): AgentMessage[] {
|
||||
const usage = raw.usage as Record<string, number> | 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,
|
||||
}];
|
||||
}
|
||||
|
|
|
|||
90
v2/src/lib/agent-dispatcher.ts
Normal file
90
v2/src/lib/agent-dispatcher.ts
Normal file
|
|
@ -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<void> {
|
||||
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<string, unknown>): 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;
|
||||
}
|
||||
}
|
||||
419
v2/src/lib/components/Agent/AgentPane.svelte
Normal file
419
v2/src/lib/components/Agent/AgentPane.svelte
Normal file
|
|
@ -0,0 +1,419 @@
|
|||
<script lang="ts">
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import { queryAgent, stopAgent, isAgentReady } from '../../adapters/agent-bridge';
|
||||
import {
|
||||
getAgentSession,
|
||||
createAgentSession,
|
||||
removeAgentSession,
|
||||
type AgentSession,
|
||||
} from '../../stores/agents';
|
||||
import type {
|
||||
AgentMessage,
|
||||
TextContent,
|
||||
ThinkingContent,
|
||||
ToolCallContent,
|
||||
ToolResultContent,
|
||||
CostContent,
|
||||
ErrorContent,
|
||||
} from '../../adapters/sdk-messages';
|
||||
|
||||
interface Props {
|
||||
sessionId: string;
|
||||
prompt?: string;
|
||||
cwd?: string;
|
||||
onExit?: () => void;
|
||||
}
|
||||
|
||||
let { sessionId, prompt: initialPrompt = '', cwd, onExit }: Props = $props();
|
||||
|
||||
let session = $derived(getAgentSession(sessionId));
|
||||
let inputPrompt = $state(initialPrompt);
|
||||
let scrollContainer: HTMLDivElement | undefined = $state();
|
||||
let autoScroll = $state(true);
|
||||
|
||||
onMount(async () => {
|
||||
if (initialPrompt) {
|
||||
await startQuery(initialPrompt);
|
||||
}
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
if (session?.status === 'running' || session?.status === 'starting') {
|
||||
stopAgent(sessionId).catch(() => {});
|
||||
}
|
||||
});
|
||||
|
||||
async function startQuery(text: string) {
|
||||
if (!text.trim()) return;
|
||||
|
||||
const ready = await isAgentReady();
|
||||
if (!ready) {
|
||||
createAgentSession(sessionId, text);
|
||||
const { updateAgentStatus } = await import('../../stores/agents');
|
||||
updateAgentStatus(sessionId, 'error', 'Sidecar not ready — agent features unavailable');
|
||||
return;
|
||||
}
|
||||
|
||||
createAgentSession(sessionId, text);
|
||||
await queryAgent({
|
||||
session_id: sessionId,
|
||||
prompt: text,
|
||||
cwd,
|
||||
max_turns: 50,
|
||||
});
|
||||
inputPrompt = '';
|
||||
}
|
||||
|
||||
function handleSubmit(e: Event) {
|
||||
e.preventDefault();
|
||||
startQuery(inputPrompt);
|
||||
}
|
||||
|
||||
function handleStop() {
|
||||
stopAgent(sessionId).catch(() => {});
|
||||
}
|
||||
|
||||
function scrollToBottom() {
|
||||
if (autoScroll && scrollContainer) {
|
||||
scrollContainer.scrollTop = scrollContainer.scrollHeight;
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-scroll when new messages arrive
|
||||
$effect(() => {
|
||||
if (session?.messages.length) {
|
||||
scrollToBottom();
|
||||
}
|
||||
});
|
||||
|
||||
function formatToolInput(input: unknown): string {
|
||||
if (typeof input === 'string') return input;
|
||||
try {
|
||||
return JSON.stringify(input, null, 2);
|
||||
} catch {
|
||||
return String(input);
|
||||
}
|
||||
}
|
||||
|
||||
function truncate(text: string, maxLen: number): string {
|
||||
if (text.length <= maxLen) return text;
|
||||
return text.slice(0, maxLen) + '...';
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="agent-pane">
|
||||
{#if !session || session.messages.length === 0}
|
||||
<div class="prompt-area">
|
||||
<form onsubmit={handleSubmit} class="prompt-form">
|
||||
<textarea
|
||||
bind:value={inputPrompt}
|
||||
placeholder="Ask Claude something..."
|
||||
class="prompt-input"
|
||||
rows="3"
|
||||
onkeydown={(e) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
startQuery(inputPrompt);
|
||||
}
|
||||
}}
|
||||
></textarea>
|
||||
<button type="submit" class="send-btn" disabled={!inputPrompt.trim()}>Send</button>
|
||||
</form>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="messages" bind:this={scrollContainer}>
|
||||
{#each session.messages as msg (msg.id)}
|
||||
<div class="message msg-{msg.type}">
|
||||
{#if msg.type === 'init'}
|
||||
<div class="msg-init">
|
||||
<span class="label">Session started</span>
|
||||
<span class="model">{(msg.content as import('../../adapters/sdk-messages').InitContent).model}</span>
|
||||
</div>
|
||||
{:else if msg.type === 'text'}
|
||||
<div class="msg-text">{(msg.content as TextContent).text}</div>
|
||||
{:else if msg.type === 'thinking'}
|
||||
<details class="msg-thinking">
|
||||
<summary>Thinking...</summary>
|
||||
<pre>{(msg.content as ThinkingContent).text}</pre>
|
||||
</details>
|
||||
{:else if msg.type === 'tool_call'}
|
||||
{@const tc = msg.content as ToolCallContent}
|
||||
<details class="msg-tool-call">
|
||||
<summary>
|
||||
<span class="tool-name">{tc.name}</span>
|
||||
<span class="tool-id">{truncate(tc.toolUseId, 12)}</span>
|
||||
</summary>
|
||||
<pre class="tool-input">{formatToolInput(tc.input)}</pre>
|
||||
</details>
|
||||
{:else if msg.type === 'tool_result'}
|
||||
{@const tr = msg.content as ToolResultContent}
|
||||
<details class="msg-tool-result">
|
||||
<summary>Tool result</summary>
|
||||
<pre class="tool-output">{formatToolInput(tr.output)}</pre>
|
||||
</details>
|
||||
{:else if msg.type === 'cost'}
|
||||
{@const cost = msg.content as CostContent}
|
||||
<div class="msg-cost">
|
||||
<span>${cost.totalCostUsd.toFixed(4)}</span>
|
||||
<span>{cost.inputTokens + cost.outputTokens} tokens</span>
|
||||
<span>{cost.numTurns} turns</span>
|
||||
<span>{(cost.durationMs / 1000).toFixed(1)}s</span>
|
||||
</div>
|
||||
{:else if msg.type === 'error'}
|
||||
<div class="msg-error">{(msg.content as ErrorContent).message}</div>
|
||||
{:else if msg.type === 'status'}
|
||||
<div class="msg-status">{JSON.stringify(msg.content)}</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<div class="footer">
|
||||
{#if session.status === 'running' || session.status === 'starting'}
|
||||
<div class="running-indicator">
|
||||
<span class="pulse"></span>
|
||||
<span>Running...</span>
|
||||
<button class="stop-btn" onclick={handleStop}>Stop</button>
|
||||
</div>
|
||||
{:else if session.status === 'done'}
|
||||
<div class="done-bar">
|
||||
<span class="cost">${session.costUsd.toFixed(4)}</span>
|
||||
<span class="tokens">{session.inputTokens + session.outputTokens} tokens</span>
|
||||
<span class="duration">{(session.durationMs / 1000).toFixed(1)}s</span>
|
||||
</div>
|
||||
{:else if session.status === 'error'}
|
||||
<div class="error-bar">
|
||||
<span>Error: {session.error ?? 'Unknown'}</span>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.agent-pane {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.prompt-area {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.prompt-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
max-width: 600px;
|
||||
}
|
||||
|
||||
.prompt-input {
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--border-radius);
|
||||
color: var(--text-primary);
|
||||
font-family: inherit;
|
||||
font-size: 13px;
|
||||
padding: 10px;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.prompt-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.send-btn {
|
||||
align-self: flex-end;
|
||||
background: var(--accent);
|
||||
color: var(--ctp-crust);
|
||||
border: none;
|
||||
border-radius: var(--border-radius);
|
||||
padding: 6px 16px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.send-btn:hover { opacity: 0.9; }
|
||||
.send-btn:disabled { opacity: 0.4; cursor: not-allowed; }
|
||||
|
||||
.messages {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 8px 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.message { padding: 4px 0; }
|
||||
|
||||
.msg-init {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: var(--text-muted);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.msg-init .model {
|
||||
background: var(--bg-surface);
|
||||
padding: 1px 6px;
|
||||
border-radius: 3px;
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
.msg-text {
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.msg-thinking {
|
||||
color: var(--ctp-overlay1);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.msg-thinking summary {
|
||||
cursor: pointer;
|
||||
color: var(--ctp-mauve);
|
||||
}
|
||||
|
||||
.msg-thinking pre {
|
||||
margin: 4px 0 0 12px;
|
||||
white-space: pre-wrap;
|
||||
font-size: 11px;
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.msg-tool-call, .msg-tool-result {
|
||||
border-left: 2px solid var(--ctp-blue);
|
||||
padding-left: 8px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.msg-tool-call summary, .msg-tool-result summary {
|
||||
cursor: pointer;
|
||||
color: var(--ctp-blue);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.tool-name {
|
||||
font-weight: 600;
|
||||
color: var(--ctp-green);
|
||||
}
|
||||
|
||||
.tool-id {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.tool-input, .tool-output {
|
||||
margin: 4px 0 0 0;
|
||||
white-space: pre-wrap;
|
||||
font-size: 11px;
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
background: var(--bg-surface);
|
||||
padding: 6px 8px;
|
||||
border-radius: 3px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.msg-tool-result {
|
||||
border-left-color: var(--ctp-teal);
|
||||
}
|
||||
|
||||
.msg-tool-result summary {
|
||||
color: var(--ctp-teal);
|
||||
}
|
||||
|
||||
.msg-cost {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
padding: 4px 8px;
|
||||
background: var(--bg-surface);
|
||||
border-radius: 3px;
|
||||
font-size: 11px;
|
||||
color: var(--ctp-yellow);
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
.msg-error {
|
||||
color: var(--ctp-red);
|
||||
background: color-mix(in srgb, var(--ctp-red) 10%, transparent);
|
||||
padding: 6px 8px;
|
||||
border-radius: 3px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.msg-status {
|
||||
color: var(--text-muted);
|
||||
font-size: 11px;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.footer {
|
||||
border-top: 1px solid var(--border);
|
||||
padding: 6px 12px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.running-indicator {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 12px;
|
||||
color: var(--ctp-blue);
|
||||
}
|
||||
|
||||
.pulse {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--ctp-blue);
|
||||
animation: pulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.3; }
|
||||
}
|
||||
|
||||
.stop-btn {
|
||||
margin-left: auto;
|
||||
background: var(--ctp-red);
|
||||
color: var(--ctp-crust);
|
||||
border: none;
|
||||
border-radius: 3px;
|
||||
padding: 2px 10px;
|
||||
font-size: 11px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.stop-btn:hover { opacity: 0.9; }
|
||||
|
||||
.done-bar, .error-bar {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
font-size: 11px;
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
.done-bar { color: var(--ctp-green); }
|
||||
.error-bar { color: var(--ctp-red); }
|
||||
</style>
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
<script lang="ts">
|
||||
import PaneContainer from './PaneContainer.svelte';
|
||||
import TerminalPane from '../Terminal/TerminalPane.svelte';
|
||||
import AgentPane from '../Agent/AgentPane.svelte';
|
||||
import {
|
||||
getPanes,
|
||||
getGridTemplate,
|
||||
|
|
@ -47,9 +48,15 @@
|
|||
args={pane.args}
|
||||
onExit={() => removePane(pane.id)}
|
||||
/>
|
||||
{:else if pane.type === 'agent'}
|
||||
<AgentPane
|
||||
sessionId={pane.id}
|
||||
cwd={pane.cwd}
|
||||
onExit={() => removePane(pane.id)}
|
||||
/>
|
||||
{:else}
|
||||
<div class="placeholder">
|
||||
<p>{pane.type} pane — coming in Phase 3/4</p>
|
||||
<p>{pane.type} pane — coming in Phase 4</p>
|
||||
</div>
|
||||
{/if}
|
||||
</PaneContainer>
|
||||
|
|
|
|||
|
|
@ -16,19 +16,32 @@
|
|||
|
||||
function newTerminal() {
|
||||
const id = crypto.randomUUID();
|
||||
const num = panes.length + 1;
|
||||
const num = panes.filter(p => p.type === 'terminal').length + 1;
|
||||
addPane({
|
||||
id,
|
||||
type: 'terminal',
|
||||
title: `Terminal ${num}`,
|
||||
});
|
||||
}
|
||||
|
||||
function newAgent() {
|
||||
const id = crypto.randomUUID();
|
||||
const num = panes.filter(p => p.type === 'agent').length + 1;
|
||||
addPane({
|
||||
id,
|
||||
type: 'agent',
|
||||
title: `Agent ${num}`,
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="session-list">
|
||||
<div class="header">
|
||||
<h2>Sessions</h2>
|
||||
<button class="new-btn" onclick={newTerminal} title="New terminal (Ctrl+N)">+</button>
|
||||
<div class="header-buttons">
|
||||
<button class="new-btn" onclick={newAgent} title="New agent (Ctrl+Shift+N)">A</button>
|
||||
<button class="new-btn" onclick={newTerminal} title="New terminal (Ctrl+N)">+</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="layout-presets">
|
||||
|
|
@ -45,14 +58,14 @@
|
|||
{#if panes.length === 0}
|
||||
<div class="empty-state">
|
||||
<p>No sessions yet.</p>
|
||||
<p class="hint">Click + or press Ctrl+N</p>
|
||||
<p class="hint">Ctrl+N terminal / Ctrl+Shift+N agent</p>
|
||||
</div>
|
||||
{:else}
|
||||
<ul class="pane-list">
|
||||
{#each panes as pane (pane.id)}
|
||||
<li class="pane-item" class:focused={pane.focused}>
|
||||
<button class="pane-btn" onclick={() => focusPane(pane.id)}>
|
||||
<span class="pane-icon">{pane.type === 'terminal' ? '>' : '#'}</span>
|
||||
<span class="pane-icon">{pane.type === 'terminal' ? '>' : pane.type === 'agent' ? '*' : '#'}</span>
|
||||
<span class="pane-name">{pane.title}</span>
|
||||
</button>
|
||||
<button class="remove-btn" onclick={() => removePane(pane.id)}>×</button>
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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<AgentState[]>([]);
|
||||
let sessions = $state<AgentSession[]>([]);
|
||||
|
||||
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);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue