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.
This commit is contained in:
commit
3672e92b7e
272 changed files with 68600 additions and 0 deletions
197
src/lib/components/Terminal/AgentPreviewPane.svelte
Normal file
197
src/lib/components/Terminal/AgentPreviewPane.svelte
Normal file
|
|
@ -0,0 +1,197 @@
|
|||
<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>
|
||||
Loading…
Add table
Add a link
Reference in a new issue