feat(electrobun): full UI — terminal tabs, agent pane, settings, palette

Extracted into 6 components:
- ProjectCard.svelte: header with badges, tab bar, content area
- AgentPane.svelte: collapsible tool calls, status strip, prompt input
- TerminalTabs.svelte: add/close shell tabs, active highlighting
- SettingsDrawer.svelte: theme, fonts, providers
- CommandPalette.svelte: Ctrl+K search overlay
- Terminal.svelte: xterm.js with Canvas + Image addons

Status bar: running/idle/stalled counts, attention queue, session
duration, notification bell, Ctrl+K hint. All ARIA labeled.
This commit is contained in:
Hibryda 2026-03-20 01:55:24 +01:00
parent 931bc1b94c
commit b11a856b72
11 changed files with 2001 additions and 220 deletions

View file

@ -0,0 +1,364 @@
<script lang="ts">
import AgentPane from './AgentPane.svelte';
import TerminalTabs from './TerminalTabs.svelte';
type AgentStatus = 'running' | 'idle' | 'stalled';
type MsgRole = 'user' | 'assistant' | 'tool-call' | 'tool-result';
type ProjectTab = 'model' | 'docs' | 'files';
interface AgentMessage {
id: number;
role: MsgRole;
content: string;
}
interface Props {
id: string;
name: string;
cwd: string;
accent: string;
status: AgentStatus;
costUsd: number;
tokens: number;
messages: AgentMessage[];
provider?: string;
profile?: string;
model?: string;
contextPct?: number;
burnRate?: number;
blinkVisible?: boolean;
}
let {
id,
name,
cwd,
accent,
status,
costUsd,
tokens,
messages: initialMessages,
provider = 'claude',
profile,
model = 'claude-opus-4-5',
contextPct = 0,
burnRate = 0,
blinkVisible = true,
}: Props = $props();
let activeTab = $state<ProjectTab>('model');
// Capture snapshot of initial prop value — messages is stable mount-time demo data
// svelte-ignore state_referenced_locally
const seedMessages = initialMessages.slice();
let messages = $state(seedMessages);
// Track which project tabs have been activated (PERSISTED-LAZY pattern)
let activatedTabs = $state<Set<ProjectTab>>(new Set(['model']));
function setTab(tab: ProjectTab) {
activeTab = tab;
activatedTabs = new Set([...activatedTabs, tab]);
}
function handleSend(text: string) {
const newMsg: AgentMessage = {
id: messages.length + 1,
role: 'user',
content: text,
};
messages = [...messages, newMsg];
// Simulate assistant echo (demo only)
setTimeout(() => {
messages = [...messages, {
id: messages.length + 1,
role: 'assistant',
content: `(demo) Received: "${text}"`,
}];
}, 400);
}
</script>
<article
class="project-card"
style="--accent: {accent}"
aria-label="Project: {name}"
>
<!-- Header -->
<header class="project-header">
<div class="status-dot-wrap" aria-label="Status: {status}">
<div
class="status-dot {status}"
class:blink-off={status === 'running' && !blinkVisible}
role="img"
aria-label={status}
></div>
</div>
<span class="project-name" title={name}>{name}</span>
<span class="project-cwd" title={cwd}>{cwd}</span>
<!-- Provider badge -->
<span class="provider-badge" title="Provider: {provider}">{provider}</span>
<!-- Profile (if set) -->
{#if profile}
<span class="profile-badge" title="Profile: {profile}">{profile}</span>
{/if}
<!-- Context pressure badge -->
{#if contextPct > 50}
<span
class="ctx-badge"
class:ctx-warn={contextPct >= 75}
class:ctx-danger={contextPct >= 90}
title="Context window {contextPct}% used"
>{contextPct}%</span>
{/if}
<!-- Burn rate -->
{#if burnRate > 0}
<span class="burn-badge" title="Burn rate">${burnRate.toFixed(2)}/hr</span>
{/if}
</header>
<!-- Project tab bar -->
<div class="tab-bar" role="tablist" aria-label="{name} tabs">
{#each (['model', 'docs', 'files'] as ProjectTab[]) as tab}
<button
class="tab-btn"
class:active={activeTab === tab}
role="tab"
aria-selected={activeTab === tab}
aria-controls="tabpanel-{id}-{tab}"
onclick={() => setTab(tab)}
>
{tab.charAt(0).toUpperCase() + tab.slice(1)}
</button>
{/each}
</div>
<!-- Tab content: display:flex/none to keep agent session mounted -->
<div class="tab-content">
<!-- Model tab: agent pane + terminal tabs -->
<div
id="tabpanel-{id}-model"
class="tab-pane"
style:display={activeTab === 'model' ? 'flex' : 'none'}
role="tabpanel"
aria-label="Model"
>
<AgentPane
{messages}
{status}
{costUsd}
{tokens}
{model}
{provider}
{profile}
{contextPct}
{burnRate}
onSend={handleSend}
/>
<TerminalTabs projectId={id} {accent} />
</div>
<!-- Docs tab -->
{#if activatedTabs.has('docs')}
<div
id="tabpanel-{id}-docs"
class="tab-pane"
style:display={activeTab === 'docs' ? 'flex' : 'none'}
role="tabpanel"
aria-label="Docs"
>
<div class="placeholder-pane">No markdown files open</div>
</div>
{/if}
<!-- Files tab -->
{#if activatedTabs.has('files')}
<div
id="tabpanel-{id}-files"
class="tab-pane"
style:display={activeTab === 'files' ? 'flex' : 'none'}
role="tabpanel"
aria-label="Files"
>
<div class="placeholder-pane">File browser — coming soon</div>
</div>
{/if}
</div>
</article>
<style>
.project-card {
background: var(--ctp-base);
border: 1px solid var(--ctp-surface0);
border-radius: 0.5rem;
display: flex;
flex-direction: column;
overflow: hidden;
min-height: 0;
position: relative;
}
/* Accent stripe on left edge */
.project-card::before {
content: '';
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 3px;
background: var(--accent, var(--ctp-mauve));
border-radius: 0.5rem 0 0 0.5rem;
}
/* Header */
.project-header {
height: 2.5rem;
background: var(--ctp-mantle);
border-bottom: 1px solid var(--ctp-surface0);
display: flex;
align-items: center;
gap: 0.375rem;
padding: 0 0.625rem 0 0.875rem;
flex-shrink: 0;
overflow: hidden;
}
.status-dot-wrap {
flex-shrink: 0;
width: 0.625rem;
height: 0.625rem;
position: relative;
}
.status-dot {
width: 100%;
height: 100%;
border-radius: 50%;
background: var(--ctp-overlay0);
}
.status-dot.running { background: var(--ctp-green); }
.status-dot.idle { background: var(--ctp-overlay1); }
.status-dot.stalled { background: var(--ctp-peach); }
.status-dot.blink-off { opacity: 0.3; }
.project-name {
font-weight: 600;
color: var(--ctp-text);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
flex-shrink: 1;
min-width: 0;
}
.project-cwd {
font-size: 0.6875rem;
color: var(--ctp-subtext0);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
direction: rtl;
max-width: 8rem;
flex-shrink: 2;
}
/* Badges */
.provider-badge,
.profile-badge,
.ctx-badge,
.burn-badge {
flex-shrink: 0;
padding: 0.1rem 0.375rem;
border-radius: 0.25rem;
font-size: 0.6rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.03em;
white-space: nowrap;
}
.provider-badge {
background: color-mix(in srgb, var(--accent, var(--ctp-mauve)) 15%, transparent);
color: var(--accent, var(--ctp-mauve));
}
.profile-badge {
background: color-mix(in srgb, var(--ctp-blue) 12%, transparent);
color: var(--ctp-blue);
}
.ctx-badge {
background: color-mix(in srgb, var(--ctp-yellow) 15%, transparent);
color: var(--ctp-yellow);
}
.ctx-badge.ctx-warn { color: var(--ctp-peach); background: color-mix(in srgb, var(--ctp-peach) 15%, transparent); }
.ctx-badge.ctx-danger { color: var(--ctp-red); background: color-mix(in srgb, var(--ctp-red) 15%, transparent); }
.burn-badge {
background: color-mix(in srgb, var(--ctp-peach) 10%, transparent);
color: var(--ctp-peach);
}
/* Tab bar */
.tab-bar {
height: 2rem;
background: var(--ctp-mantle);
border-bottom: 1px solid var(--ctp-surface0);
display: flex;
align-items: stretch;
flex-shrink: 0;
padding: 0 0.25rem;
gap: 0.125rem;
}
.tab-btn {
padding: 0 0.75rem;
background: transparent;
border: none;
border-bottom: 2px solid transparent;
color: var(--ctp-subtext0);
font-family: var(--ui-font-family);
font-size: 0.8125rem;
cursor: pointer;
white-space: nowrap;
transition: color 0.12s, border-color 0.12s;
margin-bottom: -1px;
}
.tab-btn:hover { color: var(--ctp-text); }
.tab-btn.active {
color: var(--ctp-text);
border-bottom-color: var(--accent, var(--ctp-mauve));
}
/* Tab content */
.tab-content {
flex: 1;
min-height: 0;
overflow: hidden;
display: flex;
flex-direction: column;
}
.tab-pane {
flex: 1;
min-height: 0;
flex-direction: column;
overflow: hidden;
}
.placeholder-pane {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
color: var(--ctp-overlay0);
font-size: 0.8125rem;
font-style: italic;
}
</style>