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.
197 lines
5.9 KiB
Svelte
197 lines
5.9 KiB
Svelte
<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>
|