agent-orchestrator/src/lib/components/Terminal/AgentPreviewPane.svelte
DexterFromLab 3672e92b7e feat: Agent Orchestrator — multi-project agent dashboard
Tauri + Svelte 5 + Rust application for orchestrating multiple AI coding agents.
Includes Claude, Aider, Codex, and Ollama provider support, multi-agent
communication (btmsg/bttask), session anchors, plugin sandbox, FTS5 search,
Landlock sandboxing, and 507 vitest + 110 cargo tests.
2026-03-15 15:45:27 +01:00

197 lines
5.9 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { Terminal } from '@xterm/xterm';
import { CanvasAddon } from '@xterm/addon-canvas';
import { FitAddon } from '@xterm/addon-fit';
import { getXtermTheme, onThemeChange } from '../../stores/theme.svelte';
import { getAgentSession } from '../../stores/agents.svelte';
import type {
ToolCallContent,
ToolResultContent,
InitContent,
CostContent,
ErrorContent,
TextContent,
AgentMessage,
} from '../../adapters/claude-messages';
import '@xterm/xterm/css/xterm.css';
interface Props {
sessionId: string;
}
let { sessionId }: Props = $props();
let terminalEl: HTMLDivElement;
let term: Terminal;
let fitAddon: FitAddon;
let resizeObserver: ResizeObserver | null = null;
let unsubTheme: (() => void) | null = null;
/** Track how many messages we've already rendered */
let renderedCount = 0;
let session = $derived(getAgentSession(sessionId));
// Watch for new messages and render them
$effect(() => {
if (!session || !term) return;
const msgs = session.messages;
if (msgs.length <= renderedCount) return;
const newMsgs = msgs.slice(renderedCount);
for (const msg of newMsgs) {
renderMessage(msg);
}
renderedCount = msgs.length;
});
// Reset when sessionId changes
$effect(() => {
// Access sessionId to track it
void sessionId;
renderedCount = 0;
if (term) {
term.clear();
term.write('\x1b[90m● Watching agent activity...\x1b[0m\r\n');
}
});
function renderMessage(msg: AgentMessage) {
switch (msg.type) {
case 'init': {
const c = msg.content as InitContent;
term.write(`\x1b[32m● Session started\x1b[0m \x1b[90m(${c.model})\x1b[0m\r\n`);
break;
}
case 'tool_call': {
const tc = msg.content as ToolCallContent;
if (tc.name === 'Bash') {
const cmd = (tc.input as { command?: string })?.command ?? '';
term.write(`\r\n\x1b[36m ${escapeForTerminal(cmd)}\x1b[0m\r\n`);
} else if (tc.name === 'Read' || tc.name === 'Write' || tc.name === 'Edit') {
const input = tc.input as { file_path?: string };
const path = input?.file_path ?? '';
term.write(`\x1b[33m[${tc.name}]\x1b[0m \x1b[90m${escapeForTerminal(path)}\x1b[0m\r\n`);
} else if (tc.name === 'Grep' || tc.name === 'Glob') {
const input = tc.input as { pattern?: string };
const pattern = input?.pattern ?? '';
term.write(`\x1b[33m[${tc.name}]\x1b[0m \x1b[90m${escapeForTerminal(pattern)}\x1b[0m\r\n`);
} else {
term.write(`\x1b[33m[${tc.name}]\x1b[0m\r\n`);
}
break;
}
case 'tool_result': {
const tr = msg.content as ToolResultContent;
const output = typeof tr.output === 'string'
? tr.output
: JSON.stringify(tr.output, null, 2);
if (output) {
// Truncate long outputs (show first 80 lines)
const lines = output.split('\n');
const truncated = lines.length > 80;
const display = truncated ? lines.slice(0, 80).join('\n') : output;
term.write(escapeForTerminal(display));
if (!display.endsWith('\n')) term.write('\r\n');
if (truncated) {
term.write(`\x1b[90m... (${lines.length - 80} more lines)\x1b[0m\r\n`);
}
}
break;
}
case 'text': {
const tc = msg.content as TextContent;
// Show brief text indicator (first line only)
const firstLine = tc.text.split('\n')[0].slice(0, 120);
term.write(`\x1b[37m${escapeForTerminal(firstLine)}\x1b[0m\r\n`);
break;
}
case 'error': {
const ec = msg.content as ErrorContent;
term.write(`\x1b[31m✗ ${escapeForTerminal(ec.message)}\x1b[0m\r\n`);
break;
}
case 'cost': {
const cc = msg.content as CostContent;
const cost = cc.totalCostUsd.toFixed(4);
const dur = (cc.durationMs / 1000).toFixed(1);
term.write(`\r\n\x1b[90m● Session complete ($${cost}, ${dur}s, ${cc.numTurns} turns)\x1b[0m\r\n`);
break;
}
// Skip thinking, status, unknown
}
}
/** Escape text for xterm — convert \n to \r\n */
function escapeForTerminal(text: string): string {
return text.replace(/\r?\n/g, '\r\n');
}
onMount(() => {
term = new Terminal({
theme: getXtermTheme(),
fontFamily: "'JetBrains Mono', 'Fira Code', 'Cascadia Code', monospace",
fontSize: 13,
lineHeight: 1.2,
cursorBlink: false,
cursorStyle: 'underline',
scrollback: 10000,
allowProposedApi: true,
disableStdin: true,
});
fitAddon = new FitAddon();
term.loadAddon(fitAddon);
term.loadAddon(new CanvasAddon());
term.open(terminalEl);
fitAddon.fit();
term.write('\x1b[90m● Watching agent activity...\x1b[0m\r\n');
// If session already has messages, render them
const s = getAgentSession(sessionId);
if (s && s.messages.length > 0) {
for (const msg of s.messages) {
renderMessage(msg);
}
renderedCount = s.messages.length;
}
// Resize handling with debounce
let resizeTimer: ReturnType<typeof setTimeout>;
resizeObserver = new ResizeObserver(() => {
clearTimeout(resizeTimer);
resizeTimer = setTimeout(() => {
fitAddon.fit();
}, 100);
});
resizeObserver.observe(terminalEl);
// Hot-swap theme
unsubTheme = onThemeChange(() => {
term.options.theme = getXtermTheme();
});
});
onDestroy(() => {
resizeObserver?.disconnect();
unsubTheme?.();
term?.dispose();
});
</script>
<div class="agent-preview-container" bind:this={terminalEl}></div>
<style>
.agent-preview-container {
width: 100%;
height: 100%;
overflow: hidden;
}
.agent-preview-container :global(.xterm) {
height: 100%;
padding: 0.25rem;
}
</style>