agent-orchestrator/ui-electrobun/src/mainview/App.svelte
Hibryda f97ea95373 feat(electrobun): add xterm.js terminal with image addon (Sixel/iTerm2)
- Terminal.svelte component with @xterm/xterm + Canvas + Fit + Image addons
- Catppuccin Mocha terminal theme matching main app
- Sixel, iTerm2 inline image protocol support via xterm-addon-image
- ResizeObserver for responsive terminal sizing
- Demo cargo test output in terminal section below agent messages
2026-03-20 01:40:24 +01:00

259 lines
11 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 Terminal from './Terminal.svelte';
// ── Types ────────────────────────────────────────────────────
type AgentStatus = 'running' | 'idle' | 'stalled';
type MsgRole = 'user' | 'assistant' | 'tool-call' | 'tool-result';
type TabId = 'model' | 'docs' | 'files';
interface AgentMessage {
id: number;
role: MsgRole;
content: string;
}
interface Project {
id: string;
name: string;
cwd: string;
accent: string;
status: AgentStatus;
costUsd: number;
tokens: number;
messages: AgentMessage[];
}
// ── Demo data ────────────────────────────────────────────────
const PROJECTS: Project[] = [
{
id: 'p1',
name: 'agent-orchestrator',
cwd: '~/code/ai/agent-orchestrator',
accent: 'var(--ctp-mauve)',
status: 'running',
costUsd: 0.034,
tokens: 18420,
messages: [
{ id: 1, role: 'user', content: 'Add a wake scheduler for Manager agents that wakes them when review queue depth > 3.' },
{ id: 2, role: 'assistant', content: 'Reading existing wake-scheduler.svelte.ts to understand the 3-strategy pattern...' },
{ id: 3, role: 'tool-call', content: 'Read("src/lib/stores/wake-scheduler.svelte.ts")' },
{ id: 4, role: 'tool-result', content: '// 312 lines\nexport type WakeStrategy = "persistent" | "on-demand" | "smart";\n...' },
{ id: 5, role: 'assistant', content: 'Found the WakeSignal enum. I\'ll add ReviewBacklog(0.6) and hook into bttask polling. Writing wake-scorer.ts update now.' },
{ id: 6, role: 'tool-call', content: 'Edit("src/lib/utils/wake-scorer.ts", old="// signals", new="// signals\n ReviewBacklog = 0.6,")' },
{ id: 7, role: 'assistant', content: 'Done. ReviewBacklog signal added at priority 0.6 (between TaskQueuePressure 0.7 and PeriodicFloor 0.1). Run `npm test` to verify 24 scorer tests still pass.' },
],
},
{
id: 'p2',
name: 'quanta-discord-bot',
cwd: '~/code/quanta/discord-bot',
accent: 'var(--ctp-sapphire)',
status: 'idle',
costUsd: 0.011,
tokens: 6830,
messages: [
{ id: 1, role: 'user', content: 'Why is the QRAG MCP server returning 504s on large semantic search queries?' },
{ id: 2, role: 'assistant', content: 'Checking the MCP HTTP handler timeout config and Qdrant query path...' },
{ id: 3, role: 'tool-call', content: 'Read("src/mcp/server.ts", offset=120, limit=40)' },
{ id: 4, role: 'tool-result', content: 'const QUERY_TIMEOUT_MS = 5000; // default\n...' },
{ id: 5, role: 'assistant', content: 'Found it. The MCP handler has a hardcoded 5s timeout but Qdrant HNSW search on 500k vectors takes 69s at ef=256. Two options:\n1. Raise timeout to 15s (quick fix)\n2. Lower ef to 128 for faster approximate search (~3s, slight recall drop)\n\nRecommend option 2 for latency-sensitive bot responses.' },
{ id: 6, role: 'user', content: 'Go with option 2.' },
{ id: 7, role: 'assistant', content: 'Updated `ef_searching: 128` in qdrant-config.ts and raised timeout to 8s as safety margin. Restarted service on port 9320.' },
],
},
];
// ── Reactive state ───────────────────────────────────────────
let activeTab = $state<Record<string, TabId>>({ p1: 'model', p2: 'model' });
let settingsOpen = $state(false);
// ── Derived status bar aggregates ───────────────────────────
let runningCount = $derived(PROJECTS.filter(p => p.status === 'running').length);
let idleCount = $derived(PROJECTS.filter(p => p.status === 'idle').length);
let totalCost = $derived(PROJECTS.reduce((s, p) => s + p.costUsd, 0));
let totalTokens = $derived(PROJECTS.reduce((s, p) => s + p.tokens, 0));
function setTab(projectId: string, tab: TabId) {
activeTab = { ...activeTab, [projectId]: tab };
}
// Blink state — JS timer toggles class, NO CSS animation (0% CPU)
let blinkVisible = $state(true);
$effect(() => {
const id = setInterval(() => { blinkVisible = !blinkVisible; }, 500);
return () => clearInterval(id);
});
function fmtTokens(n: number): string {
return n >= 1000 ? `${(n / 1000).toFixed(1)}k` : String(n);
}
function fmtCost(n: number): string {
return `$${n.toFixed(3)}`;
}
</script>
<div class="app-shell">
<!-- ── Sidebar icon rail ──────────────────────────────────── -->
<aside class="sidebar" role="navigation" aria-label="Primary navigation">
<div class="sidebar-spacer"></div>
<button
class="sidebar-icon"
class:active={settingsOpen}
onclick={() => settingsOpen = !settingsOpen}
aria-label="Settings"
title="Settings"
>
<!-- gear icon -->
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<circle cx="12" cy="12" r="3"/>
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/>
</svg>
</button>
</aside>
<!-- ── Main workspace ───────────────────────────────────────── -->
<main class="workspace">
<div class="project-grid">
{#each PROJECTS as project (project.id)}
<article
class="project-card"
style="--accent: {project.accent}"
aria-label="Project: {project.name}"
>
<!-- Project header -->
<header class="project-header">
<!-- Status dot — wgpu surface placeholder -->
<div class="status-dot-wrap" aria-label="Status: {project.status}">
<!--
Future: replace inner div with <electrobun-wgpu id="wgpu-surface-{project.id}">
for GPU-rendered animation. CSS pulse is the WebView fallback.
-->
<div
class="status-dot {project.status}"
class:blink-off={project.status === 'running' && !blinkVisible}
role="img"
aria-label="{project.status}"
></div>
</div>
<span class="project-name">{project.name}</span>
<span class="project-cwd" title={project.cwd}>{project.cwd}</span>
</header>
<!-- Tab bar -->
<div class="tab-bar" role="tablist" aria-label="{project.name} tabs">
{#each (['model', 'docs', 'files'] as TabId[]) as tab}
<button
class="tab-btn"
class:active={activeTab[project.id] === tab}
role="tab"
aria-selected={activeTab[project.id] === tab}
aria-controls="tabpanel-{project.id}-{tab}"
onclick={() => setTab(project.id, tab)}
>
{tab.charAt(0).toUpperCase() + tab.slice(1)}
</button>
{/each}
</div>
<!-- Tab content -->
<div class="tab-content">
<!-- Model tab: agent messages -->
<div
id="tabpanel-{project.id}-model"
class="tab-pane"
class:active={activeTab[project.id] === 'model'}
role="tabpanel"
aria-label="Model"
>
<div class="agent-messages">
{#each project.messages as msg (msg.id)}
<div class="msg">
<span class="msg-role {msg.role.split('-')[0]}">{msg.role}</span>
<div
class="msg-body"
class:tool-call={msg.role === 'tool-call'}
class:tool-result={msg.role === 'tool-result'}
>{msg.content}</div>
</div>
{/each}
</div>
<!-- Terminal section -->
<div class="terminal-section">
<Terminal />
</div>
</div>
<!-- Docs tab placeholder -->
<div
id="tabpanel-{project.id}-docs"
class="tab-pane"
class:active={activeTab[project.id] === 'docs'}
role="tabpanel"
aria-label="Docs"
>
<div class="placeholder-pane">No markdown files open</div>
</div>
<!-- Files tab placeholder -->
<div
id="tabpanel-{project.id}-files"
class="tab-pane"
class:active={activeTab[project.id] === 'files'}
role="tabpanel"
aria-label="Files"
>
<div class="placeholder-pane">File browser — coming soon</div>
</div>
</div>
</article>
{/each}
</div>
</main>
</div>
<!-- ── Status bar ─────────────────────────────────────────────── -->
<footer class="status-bar" role="status" aria-live="polite" aria-label="System status">
{#if runningCount > 0}
<span class="status-segment">
<span class="status-dot-sm green" aria-hidden="true"></span>
<span class="status-value">{runningCount}</span>
<span>running</span>
</span>
{/if}
{#if idleCount > 0}
<span class="status-segment">
<span class="status-dot-sm gray" aria-hidden="true"></span>
<span class="status-value">{idleCount}</span>
<span>idle</span>
</span>
{/if}
<span class="status-bar-spacer"></span>
<span class="status-segment" title="Total tokens used">
<span>tokens</span>
<span class="status-value">{fmtTokens(totalTokens)}</span>
</span>
<span class="status-segment" title="Total session cost">
<span>cost</span>
<span class="status-value">{fmtCost(totalCost)}</span>
</span>
</footer>
<style>
/* Component-scoped overrides only — base styles live in app.css */
:global(body) {
overflow: hidden;
}
:global(#app) {
display: flex;
flex-direction: column;
height: 100vh;
}
.app-shell {
flex: 1;
min-height: 0;
}
</style>