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,368 @@
<script lang="ts">
import { tick } from 'svelte';
type MsgRole = 'user' | 'assistant' | 'tool-call' | 'tool-result';
interface AgentMessage {
id: number;
role: MsgRole;
content: string;
}
interface Props {
messages: AgentMessage[];
status: 'running' | 'idle' | 'stalled';
costUsd: number;
tokens: number;
model?: string;
provider?: string;
profile?: string;
contextPct?: number;
burnRate?: number;
onSend?: (text: string) => void;
}
let {
messages,
status,
costUsd,
tokens,
model = 'claude-opus-4-5',
provider = 'claude',
profile,
contextPct = 0,
burnRate = 0,
onSend,
}: Props = $props();
let scrollEl: HTMLDivElement;
let promptText = $state('');
// Auto-scroll to bottom on new messages
$effect(() => {
// track messages length as dependency
const _ = messages.length;
tick().then(() => {
if (scrollEl) scrollEl.scrollTop = scrollEl.scrollHeight;
});
});
function handleSend() {
const text = promptText.trim();
if (!text) return;
promptText = '';
onSend?.(text);
}
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSend();
}
}
function fmtTokens(n: number): string {
return n >= 1000 ? `${(n / 1000).toFixed(1)}k` : String(n);
}
function fmtCost(n: number): string {
return `$${n.toFixed(3)}`;
}
// Pair tool-calls with their results for collapsible display
function isToolMsg(msg: AgentMessage) {
return msg.role === 'tool-call' || msg.role === 'tool-result';
}
</script>
<div class="agent-pane">
<!-- Messages -->
<div class="agent-messages" bind:this={scrollEl}>
{#each messages as msg (msg.id)}
{#if msg.role === 'tool-call' || msg.role === 'tool-result'}
<details class="tool-group" open={msg.role === 'tool-call'}>
<summary class="tool-summary">
<span class="tool-icon" aria-hidden="true">{msg.role === 'tool-call' ? '⚙' : '↩'}</span>
<span class="tool-label">{msg.role === 'tool-call' ? 'Tool call' : 'Tool result'}</span>
</summary>
<div
class="msg-body"
class:tool-call={msg.role === 'tool-call'}
class:tool-result={msg.role === 'tool-result'}
>{msg.content}</div>
</details>
{:else}
<div class="msg">
<span class="msg-role {msg.role === 'assistant' ? 'assistant' : 'user'}">{msg.role}</span>
<div class="msg-body">{msg.content}</div>
</div>
{/if}
{/each}
</div>
<!-- Status strip -->
<div class="agent-status-strip">
<span
class="status-badge"
class:badge-running={status === 'running'}
class:badge-idle={status === 'idle'}
class:badge-stalled={status === 'stalled'}
>{status}</span>
<span class="strip-model" title={model}>{model}</span>
<span class="strip-spacer"></span>
{#if contextPct > 0}
<span
class="strip-ctx"
class:ctx-warn={contextPct >= 75}
class:ctx-danger={contextPct >= 90}
title="Context window {contextPct}% used"
>{contextPct}%</span>
{/if}
{#if burnRate > 0}
<span class="strip-burn" title="Burn rate">${burnRate.toFixed(2)}/hr</span>
{/if}
<span class="strip-tokens" title="Tokens used">{fmtTokens(tokens)}</span>
<span class="strip-cost">{fmtCost(costUsd)}</span>
</div>
<!-- Prompt input -->
<div class="agent-prompt">
<textarea
class="prompt-input"
placeholder="Ask Claude anything..."
rows="2"
bind:value={promptText}
onkeydown={handleKeydown}
aria-label="Message input"
></textarea>
<button
class="prompt-send"
onclick={handleSend}
disabled={!promptText.trim()}
aria-label="Send message"
>Send</button>
</div>
</div>
<style>
.agent-pane {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
overflow: hidden;
}
/* Messages scroll area */
.agent-messages {
flex: 1;
min-height: 0;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 0.375rem;
padding: 0.5rem 0.625rem;
}
.agent-messages::-webkit-scrollbar { width: 0.375rem; }
.agent-messages::-webkit-scrollbar-track { background: transparent; }
.agent-messages::-webkit-scrollbar-thumb { background: var(--ctp-surface1); border-radius: 0.25rem; }
/* Regular messages */
.msg {
display: flex;
flex-direction: column;
gap: 0.125rem;
}
.msg-role {
font-size: 0.6875rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--ctp-overlay1);
}
.msg-role.user { color: var(--ctp-blue); }
.msg-role.assistant { color: var(--ctp-mauve); }
.msg-body {
background: var(--ctp-surface0);
border-radius: 0.3125rem;
padding: 0.375rem 0.5rem;
font-size: 0.8125rem;
line-height: 1.5;
color: var(--ctp-text);
white-space: pre-wrap;
word-break: break-word;
}
.msg-body.tool-call {
background: color-mix(in srgb, var(--ctp-peach) 8%, var(--ctp-surface0));
border-left: 2px solid var(--ctp-peach);
font-family: var(--term-font-family);
font-size: 0.75rem;
}
.msg-body.tool-result {
background: color-mix(in srgb, var(--ctp-teal) 6%, var(--ctp-surface0));
border-left: 2px solid var(--ctp-teal);
font-family: var(--term-font-family);
font-size: 0.75rem;
color: var(--ctp-subtext1);
}
/* Tool call collapsible */
.tool-group {
border-radius: 0.3125rem;
overflow: hidden;
}
.tool-summary {
display: flex;
align-items: center;
gap: 0.375rem;
padding: 0.25rem 0.5rem;
background: color-mix(in srgb, var(--ctp-peach) 8%, var(--ctp-surface0));
border-left: 2px solid var(--ctp-peach);
cursor: pointer;
list-style: none;
font-size: 0.75rem;
color: var(--ctp-subtext1);
border-radius: 0.3125rem 0.3125rem 0 0;
user-select: none;
}
.tool-summary::-webkit-details-marker { display: none; }
.tool-summary:hover { color: var(--ctp-text); }
.tool-icon {
font-size: 0.6875rem;
color: var(--ctp-peach);
}
.tool-label {
font-weight: 500;
font-family: var(--term-font-family);
}
.tool-group[open] .tool-summary {
border-radius: 0.3125rem 0.3125rem 0 0;
}
.tool-group .msg-body {
border-radius: 0 0 0.3125rem 0.3125rem;
border-left: 2px solid var(--ctp-peach);
border-top: none;
}
.tool-group .msg-body.tool-result {
border-left-color: var(--ctp-teal);
}
/* Status strip */
.agent-status-strip {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.25rem 0.625rem;
background: var(--ctp-mantle);
border-top: 1px solid var(--ctp-surface0);
font-size: 0.6875rem;
color: var(--ctp-subtext0);
flex-shrink: 0;
}
.status-badge {
padding: 0.125rem 0.4rem;
border-radius: 0.25rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
font-size: 0.625rem;
}
.badge-running { background: color-mix(in srgb, var(--ctp-green) 20%, transparent); color: var(--ctp-green); }
.badge-idle { background: color-mix(in srgb, var(--ctp-overlay0) 20%, transparent); color: var(--ctp-overlay1); }
.badge-stalled { background: color-mix(in srgb, var(--ctp-peach) 20%, transparent); color: var(--ctp-peach); }
.strip-model {
color: var(--ctp-overlay1);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 8rem;
}
.strip-spacer { flex: 1; }
.strip-ctx {
padding: 0.1rem 0.35rem;
border-radius: 0.2rem;
background: color-mix(in srgb, var(--ctp-yellow) 15%, transparent);
color: var(--ctp-yellow);
font-weight: 500;
}
.strip-ctx.ctx-danger {
background: color-mix(in srgb, var(--ctp-red) 15%, transparent);
color: var(--ctp-red);
}
.strip-burn { color: var(--ctp-peach); }
.strip-tokens { color: var(--ctp-subtext1); }
.strip-cost {
color: var(--ctp-text);
font-weight: 500;
}
/* Prompt area */
.agent-prompt {
display: flex;
align-items: flex-end;
gap: 0.375rem;
padding: 0.375rem 0.5rem;
background: var(--ctp-mantle);
border-top: 1px solid var(--ctp-surface0);
flex-shrink: 0;
}
.prompt-input {
flex: 1;
background: var(--ctp-surface0);
border: 1px solid var(--ctp-surface1);
border-radius: 0.375rem;
color: var(--ctp-text);
font-family: var(--ui-font-family);
font-size: 0.8125rem;
padding: 0.375rem 0.5rem;
resize: none;
line-height: 1.4;
outline: none;
transition: border-color 0.12s;
}
.prompt-input:focus {
border-color: var(--accent, var(--ctp-mauve));
}
.prompt-input::placeholder { color: var(--ctp-overlay0); }
.prompt-send {
padding: 0.4rem 0.75rem;
background: var(--accent, var(--ctp-mauve));
color: var(--ctp-base);
border: none;
border-radius: 0.375rem;
font-family: var(--ui-font-family);
font-size: 0.8125rem;
font-weight: 600;
cursor: pointer;
white-space: nowrap;
transition: opacity 0.12s;
}
.prompt-send:hover:not(:disabled) { opacity: 0.85; }
.prompt-send:disabled { opacity: 0.4; cursor: not-allowed; }
</style>