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
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
|
|
@ -4,8 +4,8 @@
|
|||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Svelte App</title>
|
||||
<script type="module" crossorigin src="/assets/index-CNV--bTA.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-Dim5hfvk.css">
|
||||
<script type="module" crossorigin src="/assets/index-6SIDSUhF.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-Dzsk_7I4.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
|
|
|||
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>
|
||||
|
|
@ -1,10 +1,11 @@
|
|||
<script lang="ts">
|
||||
import Terminal from './Terminal.svelte';
|
||||
import ProjectCard from './ProjectCard.svelte';
|
||||
import SettingsDrawer from './SettingsDrawer.svelte';
|
||||
import CommandPalette from './CommandPalette.svelte';
|
||||
|
||||
// ── Types ────────────────────────────────────────────────────
|
||||
// ── Types ─────────────────────────────────────────────────────
|
||||
type AgentStatus = 'running' | 'idle' | 'stalled';
|
||||
type MsgRole = 'user' | 'assistant' | 'tool-call' | 'tool-result';
|
||||
type TabId = 'model' | 'docs' | 'files';
|
||||
|
||||
interface AgentMessage {
|
||||
id: number;
|
||||
|
|
@ -21,9 +22,14 @@
|
|||
costUsd: number;
|
||||
tokens: number;
|
||||
messages: AgentMessage[];
|
||||
provider?: string;
|
||||
profile?: string;
|
||||
model?: string;
|
||||
contextPct?: number;
|
||||
burnRate?: number;
|
||||
}
|
||||
|
||||
// ── Demo data ────────────────────────────────────────────────
|
||||
// ── Demo data ──────────────────────────────────────────────────
|
||||
const PROJECTS: Project[] = [
|
||||
{
|
||||
id: 'p1',
|
||||
|
|
@ -33,6 +39,11 @@
|
|||
status: 'running',
|
||||
costUsd: 0.034,
|
||||
tokens: 18420,
|
||||
provider: 'claude',
|
||||
profile: 'dev',
|
||||
model: 'claude-opus-4-5',
|
||||
contextPct: 78,
|
||||
burnRate: 0.12,
|
||||
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...' },
|
||||
|
|
@ -51,6 +62,9 @@
|
|||
status: 'idle',
|
||||
costUsd: 0.011,
|
||||
tokens: 6830,
|
||||
provider: 'claude',
|
||||
model: 'claude-sonnet-4-5',
|
||||
contextPct: 32,
|
||||
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...' },
|
||||
|
|
@ -63,27 +77,58 @@
|
|||
},
|
||||
];
|
||||
|
||||
// ── Reactive state ───────────────────────────────────────────
|
||||
let activeTab = $state<Record<string, TabId>>({ p1: 'model', p2: 'model' });
|
||||
let settingsOpen = $state(false);
|
||||
// ── Reactive state ─────────────────────────────────────────────
|
||||
let settingsOpen = $state(false);
|
||||
let paletteOpen = $state(false);
|
||||
let notifCount = $state(2); // demo unread
|
||||
let sessionStart = $state(Date.now());
|
||||
|
||||
// ── 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)
|
||||
// Blink state — JS timer, no CSS animation (0% CPU overhead)
|
||||
let blinkVisible = $state(true);
|
||||
$effect(() => {
|
||||
const id = setInterval(() => { blinkVisible = !blinkVisible; }, 500);
|
||||
return () => clearInterval(id);
|
||||
});
|
||||
|
||||
// Session duration string (updates every 10s)
|
||||
let sessionDuration = $state('0m');
|
||||
$effect(() => {
|
||||
function update() {
|
||||
const mins = Math.floor((Date.now() - sessionStart) / 60000);
|
||||
sessionDuration = mins < 60 ? `${mins}m` : `${Math.floor(mins / 60)}h ${mins % 60}m`;
|
||||
}
|
||||
update();
|
||||
const id = setInterval(update, 10000);
|
||||
return () => clearInterval(id);
|
||||
});
|
||||
|
||||
// ── Global keyboard shortcuts ──────────────────────────────────
|
||||
$effect(() => {
|
||||
function onKeydown(e: KeyboardEvent) {
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 'k') {
|
||||
e.preventDefault();
|
||||
paletteOpen = !paletteOpen;
|
||||
}
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === ',') {
|
||||
e.preventDefault();
|
||||
settingsOpen = !settingsOpen;
|
||||
}
|
||||
}
|
||||
window.addEventListener('keydown', onKeydown);
|
||||
return () => window.removeEventListener('keydown', onKeydown);
|
||||
});
|
||||
|
||||
// ── Status bar aggregates ──────────────────────────────────────
|
||||
let runningCount = $derived(PROJECTS.filter(p => p.status === 'running').length);
|
||||
let idleCount = $derived(PROJECTS.filter(p => p.status === 'idle').length);
|
||||
let stalledCount = $derived(PROJECTS.filter(p => p.status === 'stalled').length);
|
||||
let totalCost = $derived(PROJECTS.reduce((s, p) => s + p.costUsd, 0));
|
||||
let totalTokens = $derived(PROJECTS.reduce((s, p) => s + p.tokens, 0));
|
||||
// Attention queue: projects with high context or stalled
|
||||
let attentionItems = $derived(
|
||||
PROJECTS.filter(p => p.status === 'stalled' || (p.contextPct ?? 0) >= 75)
|
||||
);
|
||||
|
||||
function fmtTokens(n: number): string {
|
||||
return n >= 1000 ? `${(n / 1000).toFixed(1)}k` : String(n);
|
||||
}
|
||||
|
|
@ -93,18 +138,20 @@
|
|||
}
|
||||
</script>
|
||||
|
||||
<SettingsDrawer open={settingsOpen} onClose={() => settingsOpen = false} />
|
||||
<CommandPalette open={paletteOpen} onClose={() => paletteOpen = false} />
|
||||
|
||||
<div class="app-shell">
|
||||
<!-- ── Sidebar icon rail ──────────────────────────────────── -->
|
||||
<!-- 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"
|
||||
aria-label="Settings (Ctrl+,)"
|
||||
title="Settings (Ctrl+,)"
|
||||
>
|
||||
<!-- 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"/>
|
||||
|
|
@ -112,111 +159,34 @@
|
|||
</button>
|
||||
</aside>
|
||||
|
||||
<!-- ── Main workspace ───────────────────────────────────────── -->
|
||||
<!-- 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 — stress test: 6 terminals to verify Canvas limit -->
|
||||
{#each [1,2,3,4,5,6] as termIdx (termIdx)}
|
||||
<div class="terminal-section">
|
||||
<Terminal />
|
||||
</div>
|
||||
{/each}
|
||||
</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>
|
||||
<ProjectCard
|
||||
id={project.id}
|
||||
name={project.name}
|
||||
cwd={project.cwd}
|
||||
accent={project.accent}
|
||||
status={project.status}
|
||||
costUsd={project.costUsd}
|
||||
tokens={project.tokens}
|
||||
messages={project.messages}
|
||||
provider={project.provider}
|
||||
profile={project.profile}
|
||||
model={project.model}
|
||||
contextPct={project.contextPct}
|
||||
burnRate={project.burnRate}
|
||||
{blinkVisible}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<!-- ── Status bar ─────────────────────────────────────────────── -->
|
||||
<!-- Status bar -->
|
||||
<footer class="status-bar" role="status" aria-live="polite" aria-label="System status">
|
||||
<!-- Agent states -->
|
||||
{#if runningCount > 0}
|
||||
<span class="status-segment">
|
||||
<span class="status-dot-sm green" aria-hidden="true"></span>
|
||||
|
|
@ -231,7 +201,34 @@
|
|||
<span>idle</span>
|
||||
</span>
|
||||
{/if}
|
||||
{#if stalledCount > 0}
|
||||
<span class="status-segment">
|
||||
<span class="status-dot-sm orange" aria-hidden="true"></span>
|
||||
<span class="status-value">{stalledCount}</span>
|
||||
<span>stalled</span>
|
||||
</span>
|
||||
{/if}
|
||||
|
||||
<!-- Attention queue -->
|
||||
{#if attentionItems.length > 0}
|
||||
<span class="status-segment attn-badge" title="Needs attention: {attentionItems.map(p=>p.name).join(', ')}">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true" class="attn-icon">
|
||||
<path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/>
|
||||
</svg>
|
||||
<span class="status-value">{attentionItems.length}</span>
|
||||
<span>attention</span>
|
||||
</span>
|
||||
{/if}
|
||||
|
||||
<span class="status-bar-spacer"></span>
|
||||
|
||||
<!-- Session duration -->
|
||||
<span class="status-segment" title="Session duration">
|
||||
<span>session</span>
|
||||
<span class="status-value">{sessionDuration}</span>
|
||||
</span>
|
||||
|
||||
<!-- Tokens + cost -->
|
||||
<span class="status-segment" title="Total tokens used">
|
||||
<span>tokens</span>
|
||||
<span class="status-value">{fmtTokens(totalTokens)}</span>
|
||||
|
|
@ -240,22 +237,180 @@
|
|||
<span>cost</span>
|
||||
<span class="status-value">{fmtCost(totalCost)}</span>
|
||||
</span>
|
||||
|
||||
<!-- Notification bell -->
|
||||
<button
|
||||
class="notif-btn"
|
||||
onclick={() => notifCount = 0}
|
||||
aria-label="{notifCount > 0 ? `${notifCount} unread notifications` : 'Notifications'}"
|
||||
title="Notifications"
|
||||
>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.73 21a2 2 0 0 1-3.46 0"/>
|
||||
</svg>
|
||||
{#if notifCount > 0}
|
||||
<span class="notif-badge" aria-hidden="true">{notifCount}</span>
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
<!-- Palette hint -->
|
||||
<kbd class="palette-hint" title="Open command palette">Ctrl+K</kbd>
|
||||
</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;
|
||||
}
|
||||
:global(body) { overflow: hidden; }
|
||||
:global(#app) { display: flex; flex-direction: column; height: 100vh; }
|
||||
|
||||
.app-shell {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Sidebar icon rail */
|
||||
.sidebar {
|
||||
width: var(--sidebar-width);
|
||||
flex-shrink: 0;
|
||||
background: var(--ctp-mantle);
|
||||
border-right: 1px solid var(--ctp-surface0);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 0.5rem 0;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.sidebar-spacer { flex: 1; }
|
||||
|
||||
.sidebar-icon {
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
border-radius: 0.375rem;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--ctp-overlay1);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: background 0.15s, color 0.15s;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.sidebar-icon:hover { background: var(--ctp-surface0); color: var(--ctp-text); }
|
||||
.sidebar-icon.active { background: var(--ctp-surface1); color: var(--ctp-mauve); }
|
||||
.sidebar-icon svg { width: 1rem; height: 1rem; }
|
||||
|
||||
/* Workspace */
|
||||
.workspace {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.project-grid {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem;
|
||||
background: var(--ctp-crust);
|
||||
}
|
||||
|
||||
/* Status bar */
|
||||
.status-bar {
|
||||
height: var(--status-bar-height);
|
||||
background: var(--ctp-crust);
|
||||
border-top: 1px solid var(--ctp-surface0);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.875rem;
|
||||
padding: 0 0.625rem;
|
||||
flex-shrink: 0;
|
||||
font-size: 0.6875rem;
|
||||
color: var(--ctp-subtext0);
|
||||
}
|
||||
|
||||
.status-segment {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.status-dot-sm {
|
||||
width: 0.4375rem;
|
||||
height: 0.4375rem;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.status-dot-sm.green { background: var(--ctp-green); }
|
||||
.status-dot-sm.gray { background: var(--ctp-overlay0); }
|
||||
.status-dot-sm.orange { background: var(--ctp-peach); }
|
||||
|
||||
.status-value { color: var(--ctp-text); font-weight: 500; }
|
||||
.status-bar-spacer { flex: 1; }
|
||||
|
||||
/* Attention badge */
|
||||
.attn-badge { color: var(--ctp-yellow); }
|
||||
.attn-icon { width: 0.75rem; height: 0.75rem; stroke: var(--ctp-yellow); }
|
||||
|
||||
/* Notification bell */
|
||||
.notif-btn {
|
||||
position: relative;
|
||||
width: 1.5rem;
|
||||
height: 1.5rem;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--ctp-subtext0);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 0.25rem;
|
||||
transition: color 0.12s;
|
||||
padding: 0;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.notif-btn:hover { color: var(--ctp-text); }
|
||||
.notif-btn svg { width: 0.875rem; height: 0.875rem; }
|
||||
|
||||
.notif-badge {
|
||||
position: absolute;
|
||||
top: 0.125rem;
|
||||
right: 0.125rem;
|
||||
min-width: 0.875rem;
|
||||
height: 0.875rem;
|
||||
background: var(--ctp-red);
|
||||
color: var(--ctp-base);
|
||||
border-radius: 0.4375rem;
|
||||
font-size: 0.5rem;
|
||||
font-weight: 700;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0 0.2rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
/* Palette shortcut hint */
|
||||
.palette-hint {
|
||||
padding: 0.1rem 0.3rem;
|
||||
background: var(--ctp-surface0);
|
||||
border: 1px solid var(--ctp-surface1);
|
||||
border-radius: 0.2rem;
|
||||
font-size: 0.6rem;
|
||||
color: var(--ctp-overlay0);
|
||||
font-family: var(--ui-font-family);
|
||||
cursor: pointer;
|
||||
transition: color 0.1s;
|
||||
}
|
||||
|
||||
.palette-hint:hover { color: var(--ctp-subtext0); }
|
||||
</style>
|
||||
|
|
|
|||
285
ui-electrobun/src/mainview/CommandPalette.svelte
Normal file
285
ui-electrobun/src/mainview/CommandPalette.svelte
Normal file
|
|
@ -0,0 +1,285 @@
|
|||
<script lang="ts">
|
||||
import { tick } from 'svelte';
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
let { open, onClose }: Props = $props();
|
||||
|
||||
interface Command {
|
||||
id: string;
|
||||
label: string;
|
||||
description?: string;
|
||||
shortcut?: string;
|
||||
action: () => void;
|
||||
}
|
||||
|
||||
const COMMANDS: Command[] = [
|
||||
{ id: 'new-terminal', label: 'New Terminal Tab', shortcut: 'Ctrl+`', action: () => {} },
|
||||
{ id: 'settings', label: 'Open Settings', shortcut: 'Ctrl+,', action: () => {} },
|
||||
{ id: 'search', label: 'Search Messages', shortcut: 'Ctrl+F', action: () => {} },
|
||||
{ id: 'new-project', label: 'Add Project', description: 'Open a project directory', action: () => {} },
|
||||
{ id: 'clear-agent', label: 'Clear Agent Context', description: 'Reset agent session', action: () => {} },
|
||||
{ id: 'copy-cost', label: 'Copy Session Cost', action: () => {} },
|
||||
{ id: 'docs', label: 'Open Documentation', shortcut: 'F1', action: () => {} },
|
||||
{ id: 'theme', label: 'Change Theme', description: 'Currently: Catppuccin Mocha', action: () => {} },
|
||||
{ id: 'split-h', label: 'Split Horizontally', shortcut: 'Ctrl+\\', action: () => {} },
|
||||
{ id: 'split-v', label: 'Split Vertically', shortcut: 'Ctrl+Shift+\\', action: () => {} },
|
||||
];
|
||||
|
||||
let query = $state('');
|
||||
let selectedIdx = $state(0);
|
||||
let inputEl = $state<HTMLInputElement | undefined>(undefined);
|
||||
|
||||
let filtered = $derived(
|
||||
query.trim() === ''
|
||||
? COMMANDS
|
||||
: COMMANDS.filter(c =>
|
||||
c.label.toLowerCase().includes(query.toLowerCase()) ||
|
||||
c.description?.toLowerCase().includes(query.toLowerCase())
|
||||
)
|
||||
);
|
||||
|
||||
$effect(() => {
|
||||
if (open) {
|
||||
query = '';
|
||||
selectedIdx = 0;
|
||||
tick().then(() => inputEl?.focus());
|
||||
}
|
||||
});
|
||||
|
||||
// Clamp selection when filtered list changes
|
||||
$effect(() => {
|
||||
const len = filtered.length;
|
||||
if (selectedIdx >= len) selectedIdx = Math.max(0, len - 1);
|
||||
});
|
||||
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') { onClose(); return; }
|
||||
if (e.key === 'ArrowDown') { e.preventDefault(); selectedIdx = Math.min(selectedIdx + 1, filtered.length - 1); return; }
|
||||
if (e.key === 'ArrowUp') { e.preventDefault(); selectedIdx = Math.max(selectedIdx - 1, 0); return; }
|
||||
if (e.key === 'Enter' && filtered[selectedIdx]) {
|
||||
filtered[selectedIdx].action();
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
|
||||
function executeCommand(cmd: Command) {
|
||||
cmd.action();
|
||||
onClose();
|
||||
}
|
||||
|
||||
function handleBackdropClick(e: MouseEvent) {
|
||||
if (e.target === e.currentTarget) onClose();
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if open}
|
||||
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
|
||||
<div
|
||||
class="palette-backdrop"
|
||||
onclick={handleBackdropClick}
|
||||
onkeydown={handleKeydown}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Command Palette"
|
||||
tabindex="-1"
|
||||
>
|
||||
<div class="palette-panel">
|
||||
<div class="palette-input-row">
|
||||
<svg class="palette-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
|
||||
<circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/>
|
||||
</svg>
|
||||
<input
|
||||
class="palette-input"
|
||||
type="text"
|
||||
role="combobox"
|
||||
placeholder="Type a command..."
|
||||
bind:this={inputEl}
|
||||
bind:value={query}
|
||||
onkeydown={handleKeydown}
|
||||
aria-label="Command search"
|
||||
aria-autocomplete="list"
|
||||
aria-expanded="true"
|
||||
aria-controls="palette-list"
|
||||
aria-activedescendant={filtered[selectedIdx] ? `cmd-${filtered[selectedIdx].id}` : undefined}
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
/>
|
||||
<kbd class="palette-esc-hint">Esc</kbd>
|
||||
</div>
|
||||
|
||||
<ul
|
||||
id="palette-list"
|
||||
class="palette-list"
|
||||
role="listbox"
|
||||
aria-label="Commands"
|
||||
>
|
||||
{#each filtered as cmd, i (cmd.id)}
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||
<li
|
||||
id="cmd-{cmd.id}"
|
||||
class="palette-item"
|
||||
class:selected={i === selectedIdx}
|
||||
role="option"
|
||||
aria-selected={i === selectedIdx}
|
||||
onclick={() => executeCommand(cmd)}
|
||||
onmouseenter={() => selectedIdx = i}
|
||||
>
|
||||
<span class="cmd-label">{cmd.label}</span>
|
||||
{#if cmd.description}
|
||||
<span class="cmd-desc">{cmd.description}</span>
|
||||
{/if}
|
||||
{#if cmd.shortcut}
|
||||
<kbd class="cmd-shortcut">{cmd.shortcut}</kbd>
|
||||
{/if}
|
||||
</li>
|
||||
{/each}
|
||||
{#if filtered.length === 0}
|
||||
<li class="palette-empty" role="option" aria-selected="false">No commands found</li>
|
||||
{/if}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.palette-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 300;
|
||||
background: color-mix(in srgb, var(--ctp-crust) 70%, transparent);
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: center;
|
||||
padding-top: 6rem;
|
||||
}
|
||||
|
||||
.palette-panel {
|
||||
width: 36rem;
|
||||
max-width: 92vw;
|
||||
background: var(--ctp-mantle);
|
||||
border: 1px solid var(--ctp-surface1);
|
||||
border-radius: 0.625rem;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 1.25rem 3rem color-mix(in srgb, var(--ctp-crust) 60%, transparent);
|
||||
animation: palette-appear 0.12s ease-out;
|
||||
}
|
||||
|
||||
@keyframes palette-appear {
|
||||
from { transform: translateY(-0.5rem) scale(0.98); opacity: 0; }
|
||||
to { transform: translateY(0) scale(1); opacity: 1; }
|
||||
}
|
||||
|
||||
/* Input row */
|
||||
.palette-input-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0 0.75rem;
|
||||
border-bottom: 1px solid var(--ctp-surface0);
|
||||
height: 3rem;
|
||||
}
|
||||
|
||||
.palette-icon {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
color: var(--ctp-overlay1);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.palette-input {
|
||||
flex: 1;
|
||||
background: transparent;
|
||||
border: none;
|
||||
outline: none;
|
||||
color: var(--ctp-text);
|
||||
font-family: var(--ui-font-family);
|
||||
font-size: 0.9375rem;
|
||||
caret-color: var(--ctp-mauve);
|
||||
}
|
||||
|
||||
.palette-input::placeholder { color: var(--ctp-overlay0); }
|
||||
|
||||
.palette-esc-hint {
|
||||
padding: 0.15rem 0.35rem;
|
||||
background: var(--ctp-surface0);
|
||||
border: 1px solid var(--ctp-surface1);
|
||||
border-radius: 0.25rem;
|
||||
font-size: 0.6875rem;
|
||||
color: var(--ctp-overlay1);
|
||||
font-family: var(--ui-font-family);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Command list */
|
||||
.palette-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0.375rem;
|
||||
max-height: 22rem;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.0625rem;
|
||||
}
|
||||
|
||||
.palette-list::-webkit-scrollbar { width: 0.375rem; }
|
||||
.palette-list::-webkit-scrollbar-track { background: transparent; }
|
||||
.palette-list::-webkit-scrollbar-thumb { background: var(--ctp-surface1); border-radius: 0.25rem; }
|
||||
|
||||
.palette-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 0.625rem;
|
||||
border-radius: 0.375rem;
|
||||
cursor: pointer;
|
||||
transition: background 0.08s;
|
||||
}
|
||||
|
||||
.palette-item.selected {
|
||||
background: var(--ctp-surface0);
|
||||
}
|
||||
|
||||
.palette-item:hover {
|
||||
background: var(--ctp-surface0);
|
||||
}
|
||||
|
||||
.cmd-label {
|
||||
flex: 1;
|
||||
font-size: 0.875rem;
|
||||
color: var(--ctp-text);
|
||||
}
|
||||
|
||||
.cmd-desc {
|
||||
font-size: 0.75rem;
|
||||
color: var(--ctp-subtext0);
|
||||
max-width: 12rem;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.cmd-shortcut {
|
||||
padding: 0.1rem 0.3rem;
|
||||
background: var(--ctp-surface1);
|
||||
border: 1px solid var(--ctp-surface2);
|
||||
border-radius: 0.2rem;
|
||||
font-size: 0.6875rem;
|
||||
color: var(--ctp-subtext0);
|
||||
font-family: var(--ui-font-family);
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.palette-empty {
|
||||
padding: 1.5rem;
|
||||
text-align: center;
|
||||
font-size: 0.875rem;
|
||||
color: var(--ctp-overlay0);
|
||||
font-style: italic;
|
||||
}
|
||||
</style>
|
||||
364
ui-electrobun/src/mainview/ProjectCard.svelte
Normal file
364
ui-electrobun/src/mainview/ProjectCard.svelte
Normal 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>
|
||||
329
ui-electrobun/src/mainview/SettingsDrawer.svelte
Normal file
329
ui-electrobun/src/mainview/SettingsDrawer.svelte
Normal file
|
|
@ -0,0 +1,329 @@
|
|||
<script lang="ts">
|
||||
interface Props {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
let { open, onClose }: Props = $props();
|
||||
|
||||
// Local settings state (demo — no persistence in prototype)
|
||||
let theme = $state('Catppuccin Mocha');
|
||||
let uiFontSize = $state(14);
|
||||
let termFontSize = $state(13);
|
||||
|
||||
interface Provider {
|
||||
id: string;
|
||||
label: string;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
let providers = $state<Provider[]>([
|
||||
{ id: 'claude', label: 'Claude (Anthropic)', enabled: true },
|
||||
{ id: 'codex', label: 'Codex (OpenAI)', enabled: false },
|
||||
{ id: 'ollama', label: 'Ollama (local)', enabled: false },
|
||||
]);
|
||||
|
||||
function toggleProvider(id: string) {
|
||||
providers = providers.map(p => p.id === id ? { ...p, enabled: !p.enabled } : p);
|
||||
}
|
||||
|
||||
function handleBackdropClick(e: MouseEvent) {
|
||||
if (e.target === e.currentTarget) onClose();
|
||||
}
|
||||
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') onClose();
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- Backdrop -->
|
||||
{#if open}
|
||||
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
|
||||
<div
|
||||
class="drawer-backdrop"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Settings"
|
||||
tabindex="-1"
|
||||
onclick={handleBackdropClick}
|
||||
onkeydown={handleKeydown}
|
||||
>
|
||||
<aside class="drawer-panel">
|
||||
<header class="drawer-header">
|
||||
<h2 class="drawer-title">Settings</h2>
|
||||
<button class="drawer-close" onclick={onClose} aria-label="Close settings">×</button>
|
||||
</header>
|
||||
|
||||
<div class="drawer-body">
|
||||
<!-- Appearance -->
|
||||
<section class="settings-section">
|
||||
<h3 class="section-heading">Appearance</h3>
|
||||
|
||||
<div class="setting-row">
|
||||
<label class="setting-label" for="theme-select">Theme</label>
|
||||
<div class="setting-value theme-pill">{theme}</div>
|
||||
</div>
|
||||
|
||||
<div class="setting-row">
|
||||
<span class="setting-label" id="ui-font-label">UI font size</span>
|
||||
<div class="font-stepper" role="group" aria-labelledby="ui-font-label">
|
||||
<button
|
||||
class="stepper-btn"
|
||||
onclick={() => uiFontSize = Math.max(10, uiFontSize - 1)}
|
||||
aria-label="Decrease UI font size"
|
||||
>−</button>
|
||||
<span class="stepper-value">{uiFontSize}px</span>
|
||||
<button
|
||||
class="stepper-btn"
|
||||
onclick={() => uiFontSize = Math.min(24, uiFontSize + 1)}
|
||||
aria-label="Increase UI font size"
|
||||
>+</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="setting-row">
|
||||
<span class="setting-label" id="term-font-label">Terminal font size</span>
|
||||
<div class="font-stepper" role="group" aria-labelledby="term-font-label">
|
||||
<button
|
||||
class="stepper-btn"
|
||||
onclick={() => termFontSize = Math.max(8, termFontSize - 1)}
|
||||
aria-label="Decrease terminal font size"
|
||||
>−</button>
|
||||
<span class="stepper-value">{termFontSize}px</span>
|
||||
<button
|
||||
class="stepper-btn"
|
||||
onclick={() => termFontSize = Math.min(24, termFontSize + 1)}
|
||||
aria-label="Increase terminal font size"
|
||||
>+</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Providers -->
|
||||
<section class="settings-section">
|
||||
<h3 class="section-heading">Providers</h3>
|
||||
{#each providers as prov (prov.id)}
|
||||
<div class="setting-row">
|
||||
<label class="setting-label" for="prov-{prov.id}">{prov.label}</label>
|
||||
<button
|
||||
id="prov-{prov.id}"
|
||||
class="toggle-btn"
|
||||
class:enabled={prov.enabled}
|
||||
onclick={() => toggleProvider(prov.id)}
|
||||
role="switch"
|
||||
aria-checked={prov.enabled}
|
||||
aria-label="{prov.label} {prov.enabled ? 'enabled' : 'disabled'}"
|
||||
>
|
||||
<span class="toggle-track">
|
||||
<span class="toggle-thumb"></span>
|
||||
</span>
|
||||
<span class="toggle-label">{prov.enabled ? 'on' : 'off'}</span>
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
</section>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.drawer-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 200;
|
||||
background: color-mix(in srgb, var(--ctp-crust) 60%, transparent);
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.drawer-panel {
|
||||
width: 18rem;
|
||||
max-width: 90vw;
|
||||
background: var(--ctp-mantle);
|
||||
border-right: 1px solid var(--ctp-surface0);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
animation: slide-in 0.18s ease-out;
|
||||
}
|
||||
|
||||
@keyframes slide-in {
|
||||
from { transform: translateX(-100%); opacity: 0; }
|
||||
to { transform: translateX(0); opacity: 1; }
|
||||
}
|
||||
|
||||
.drawer-header {
|
||||
height: 3rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 0.875rem;
|
||||
border-bottom: 1px solid var(--ctp-surface0);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.drawer-title {
|
||||
flex: 1;
|
||||
margin: 0;
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 600;
|
||||
color: var(--ctp-text);
|
||||
}
|
||||
|
||||
.drawer-close {
|
||||
width: 1.75rem;
|
||||
height: 1.75rem;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 0.3rem;
|
||||
color: var(--ctp-overlay1);
|
||||
font-size: 1.125rem;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: background 0.12s, color 0.12s;
|
||||
}
|
||||
|
||||
.drawer-close:hover {
|
||||
background: var(--ctp-surface0);
|
||||
color: var(--ctp-text);
|
||||
}
|
||||
|
||||
.drawer-body {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 0.75rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.25rem;
|
||||
}
|
||||
|
||||
.drawer-body::-webkit-scrollbar { width: 0.375rem; }
|
||||
.drawer-body::-webkit-scrollbar-track { background: transparent; }
|
||||
.drawer-body::-webkit-scrollbar-thumb { background: var(--ctp-surface1); border-radius: 0.25rem; }
|
||||
|
||||
/* Sections */
|
||||
.settings-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.section-heading {
|
||||
margin: 0 0 0.25rem;
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--ctp-overlay0);
|
||||
}
|
||||
|
||||
.setting-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
padding: 0.25rem 0;
|
||||
}
|
||||
|
||||
.setting-label {
|
||||
font-size: 0.8125rem;
|
||||
color: var(--ctp-subtext1);
|
||||
}
|
||||
|
||||
.setting-value {
|
||||
font-size: 0.8125rem;
|
||||
color: var(--ctp-text);
|
||||
}
|
||||
|
||||
.theme-pill {
|
||||
background: var(--ctp-surface0);
|
||||
padding: 0.2rem 0.5rem;
|
||||
border-radius: 0.3rem;
|
||||
font-size: 0.75rem;
|
||||
color: var(--ctp-mauve);
|
||||
}
|
||||
|
||||
/* Font stepper */
|
||||
.font-stepper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
}
|
||||
|
||||
.stepper-btn {
|
||||
width: 1.375rem;
|
||||
height: 1.375rem;
|
||||
background: var(--ctp-surface0);
|
||||
border: 1px solid var(--ctp-surface1);
|
||||
border-radius: 0.25rem;
|
||||
color: var(--ctp-text);
|
||||
font-size: 0.875rem;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: background 0.1s;
|
||||
}
|
||||
|
||||
.stepper-btn:hover { background: var(--ctp-surface1); }
|
||||
|
||||
.stepper-value {
|
||||
font-size: 0.8125rem;
|
||||
color: var(--ctp-text);
|
||||
min-width: 2.5rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* Toggle switch */
|
||||
.toggle-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.toggle-track {
|
||||
width: 2rem;
|
||||
height: 1.125rem;
|
||||
background: var(--ctp-surface1);
|
||||
border-radius: 0.5625rem;
|
||||
position: relative;
|
||||
transition: background 0.15s;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.toggle-btn.enabled .toggle-track {
|
||||
background: var(--ctp-mauve);
|
||||
}
|
||||
|
||||
.toggle-thumb {
|
||||
position: absolute;
|
||||
top: 0.1875rem;
|
||||
left: 0.1875rem;
|
||||
width: 0.75rem;
|
||||
height: 0.75rem;
|
||||
background: var(--ctp-base);
|
||||
border-radius: 50%;
|
||||
transition: transform 0.15s;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.toggle-btn.enabled .toggle-thumb {
|
||||
transform: translateX(0.875rem);
|
||||
}
|
||||
|
||||
.toggle-label {
|
||||
font-size: 0.75rem;
|
||||
color: var(--ctp-subtext0);
|
||||
min-width: 1.5rem;
|
||||
}
|
||||
|
||||
.toggle-btn.enabled .toggle-label {
|
||||
color: var(--ctp-mauve);
|
||||
}
|
||||
</style>
|
||||
280
ui-electrobun/src/mainview/TerminalTabs.svelte
Normal file
280
ui-electrobun/src/mainview/TerminalTabs.svelte
Normal file
|
|
@ -0,0 +1,280 @@
|
|||
<script lang="ts">
|
||||
import Terminal from './Terminal.svelte';
|
||||
|
||||
interface Props {
|
||||
projectId: string;
|
||||
accent?: string;
|
||||
}
|
||||
|
||||
let { projectId, accent = 'var(--ctp-mauve)' }: Props = $props();
|
||||
|
||||
interface TermTab {
|
||||
id: string;
|
||||
title: string;
|
||||
}
|
||||
|
||||
// Capture stable initial value — projectId is a mount-time constant, not reactive
|
||||
// svelte-ignore state_referenced_locally
|
||||
const initialId = projectId;
|
||||
const firstTabId = `${initialId}-t1`;
|
||||
|
||||
let tabs = $state<TermTab[]>([{ id: firstTabId, title: 'shell 1' }]);
|
||||
let activeTabId = $state<string>(firstTabId);
|
||||
let collapsed = $state(false);
|
||||
let counter = $state(2);
|
||||
// Track which tabs have been mounted at least once (for lazy init)
|
||||
let mounted = $state<Set<string>>(new Set([firstTabId]));
|
||||
|
||||
function addTab() {
|
||||
const id = `${projectId}-t${counter}`;
|
||||
tabs = [...tabs, { id, title: `shell ${counter}` }];
|
||||
counter++;
|
||||
activeTabId = id;
|
||||
mounted = new Set([...mounted, id]);
|
||||
}
|
||||
|
||||
function closeTab(id: string, e: MouseEvent) {
|
||||
e.stopPropagation();
|
||||
const idx = tabs.findIndex(t => t.id === id);
|
||||
tabs = tabs.filter(t => t.id !== id);
|
||||
if (activeTabId === id) {
|
||||
// activate neighbor
|
||||
const next = tabs[Math.min(idx, tabs.length - 1)];
|
||||
activeTabId = next?.id ?? '';
|
||||
}
|
||||
// Remove from mounted to free resources
|
||||
const m = new Set(mounted);
|
||||
m.delete(id);
|
||||
mounted = m;
|
||||
}
|
||||
|
||||
function activateTab(id: string) {
|
||||
activeTabId = id;
|
||||
// Mount on first activation
|
||||
if (!mounted.has(id)) {
|
||||
mounted = new Set([...mounted, id]);
|
||||
}
|
||||
}
|
||||
|
||||
function toggleCollapse() {
|
||||
collapsed = !collapsed;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="terminal-section" style="--accent: {accent}">
|
||||
<!-- Section header: collapse toggle + terminal tabs -->
|
||||
<div class="term-section-header">
|
||||
<button
|
||||
class="collapse-btn"
|
||||
onclick={toggleCollapse}
|
||||
aria-label={collapsed ? 'Expand terminals' : 'Collapse terminals'}
|
||||
aria-expanded={!collapsed}
|
||||
title={collapsed ? 'Expand' : 'Collapse'}
|
||||
>
|
||||
<svg
|
||||
class="chevron"
|
||||
class:rotated={collapsed}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<polyline points="6 9 12 15 18 9"/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<!-- Tab pills -->
|
||||
<div class="term-tabs" role="tablist" aria-label="Terminal tabs">
|
||||
{#each tabs as tab (tab.id)}
|
||||
<button
|
||||
class="term-tab"
|
||||
class:active={activeTabId === tab.id}
|
||||
role="tab"
|
||||
aria-selected={activeTabId === tab.id}
|
||||
onclick={() => activateTab(tab.id)}
|
||||
title={tab.title}
|
||||
>
|
||||
<span class="term-tab-title">{tab.title}</span>
|
||||
{#if tabs.length > 1}
|
||||
<span
|
||||
class="term-tab-close"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
aria-label="Close {tab.title}"
|
||||
onclick={(e) => closeTab(tab.id, e)}
|
||||
onkeydown={(e) => e.key === 'Enter' && closeTab(tab.id, e as unknown as MouseEvent)}
|
||||
>×</span>
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
|
||||
<button
|
||||
class="term-tab-add"
|
||||
onclick={addTab}
|
||||
aria-label="Add terminal tab"
|
||||
title="New terminal"
|
||||
>+</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Terminal panes: display:flex/none to preserve xterm state -->
|
||||
{#if !collapsed}
|
||||
<div class="term-panes">
|
||||
{#each tabs as tab (tab.id)}
|
||||
{#if mounted.has(tab.id)}
|
||||
<div
|
||||
class="term-pane"
|
||||
style:display={activeTabId === tab.id ? 'flex' : 'none'}
|
||||
role="tabpanel"
|
||||
aria-label={tab.title}
|
||||
>
|
||||
<Terminal />
|
||||
</div>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.terminal-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-top: 1px solid var(--ctp-surface0);
|
||||
flex-shrink: 0;
|
||||
background: var(--ctp-crust);
|
||||
}
|
||||
|
||||
/* Section header: collapse + tabs in one row */
|
||||
.term-section-header {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
height: 1.875rem;
|
||||
background: var(--ctp-mantle);
|
||||
border-bottom: 1px solid var(--ctp-surface0);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.collapse-btn {
|
||||
width: 2rem;
|
||||
flex-shrink: 0;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-right: 1px solid var(--ctp-surface0);
|
||||
color: var(--ctp-overlay1);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: color 0.12s;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.collapse-btn:hover { color: var(--ctp-text); }
|
||||
|
||||
.collapse-btn svg {
|
||||
width: 0.875rem;
|
||||
height: 0.875rem;
|
||||
transition: transform 0.15s;
|
||||
}
|
||||
|
||||
.chevron.rotated {
|
||||
transform: rotate(-90deg);
|
||||
}
|
||||
|
||||
/* Tab pills */
|
||||
.term-tabs {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
flex: 1;
|
||||
overflow-x: auto;
|
||||
scrollbar-width: none;
|
||||
gap: 0.125rem;
|
||||
padding: 0 0.25rem;
|
||||
}
|
||||
|
||||
.term-tabs::-webkit-scrollbar { display: none; }
|
||||
|
||||
.term-tab {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
padding: 0 0.5rem;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-bottom: 2px solid transparent;
|
||||
color: var(--ctp-subtext0);
|
||||
font-family: var(--ui-font-family);
|
||||
font-size: 0.75rem;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
transition: color 0.1s, border-color 0.1s;
|
||||
margin-bottom: -1px;
|
||||
}
|
||||
|
||||
.term-tab:hover { color: var(--ctp-text); }
|
||||
|
||||
.term-tab.active {
|
||||
color: var(--ctp-text);
|
||||
border-bottom-color: var(--accent, var(--ctp-mauve));
|
||||
}
|
||||
|
||||
.term-tab-title { pointer-events: none; }
|
||||
|
||||
.term-tab-close {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
border-radius: 0.2rem;
|
||||
font-size: 0.75rem;
|
||||
color: var(--ctp-overlay0);
|
||||
transition: background 0.1s, color 0.1s;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.term-tab-close:hover {
|
||||
background: var(--ctp-surface1);
|
||||
color: var(--ctp-red);
|
||||
}
|
||||
|
||||
.term-tab-add {
|
||||
align-self: center;
|
||||
width: 1.375rem;
|
||||
height: 1.375rem;
|
||||
flex-shrink: 0;
|
||||
background: transparent;
|
||||
border: 1px solid var(--ctp-surface1);
|
||||
border-radius: 0.25rem;
|
||||
color: var(--ctp-overlay1);
|
||||
font-size: 0.875rem;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: background 0.1s, color 0.1s;
|
||||
margin-left: 0.125rem;
|
||||
}
|
||||
|
||||
.term-tab-add:hover {
|
||||
background: var(--ctp-surface0);
|
||||
color: var(--ctp-text);
|
||||
}
|
||||
|
||||
/* Terminal pane container */
|
||||
.term-panes {
|
||||
height: 12rem;
|
||||
min-height: 8rem;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.term-pane {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
flex-direction: column;
|
||||
}
|
||||
</style>
|
||||
Loading…
Add table
Add a link
Reference in a new issue