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:
parent
931bc1b94c
commit
b11a856b72
11 changed files with 2001 additions and 220 deletions
368
ui-electrobun/src/mainview/AgentPane.svelte
Normal file
368
ui-electrobun/src/mainview/AgentPane.svelte
Normal 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>
|
||||
Loading…
Add table
Add a link
Reference in a new issue