feat(electrobun): fixes + 7 new features (terminal input, file browser, memory, toasts)
Fixes: - Terminal accepts keyboard input (echo mode with prompt) - Terminal drawer collapses properly (display:none preserves xterm state) Features: - 6 project tabs: Model | Docs | Context | Files | SSH | Memory - FileBrowser.svelte: recursive tree with expand/collapse + file preview - MemoryTab.svelte: memory cards with trust badges (human/agent/auto) - Subagent tree in AgentPane (demo: search-agent, test-runner) - Drag resize handle between agent pane and terminal - Theme dropdown in Settings (4 Catppuccin flavors) - ToastContainer.svelte: auto-dismiss notifications
This commit is contained in:
parent
b11a856b72
commit
4ae558af17
14 changed files with 1168 additions and 196 deletions
|
|
@ -9,6 +9,12 @@
|
|||
content: string;
|
||||
}
|
||||
|
||||
interface SubAgent {
|
||||
id: string;
|
||||
name: string;
|
||||
status: 'running' | 'done' | 'error';
|
||||
}
|
||||
|
||||
interface Props {
|
||||
messages: AgentMessage[];
|
||||
status: 'running' | 'idle' | 'stalled';
|
||||
|
|
@ -28,20 +34,25 @@
|
|||
costUsd,
|
||||
tokens,
|
||||
model = 'claude-opus-4-5',
|
||||
provider = 'claude',
|
||||
profile,
|
||||
provider: _provider = 'claude',
|
||||
profile: _profile,
|
||||
contextPct = 0,
|
||||
burnRate = 0,
|
||||
onSend,
|
||||
}: Props = $props();
|
||||
|
||||
// Demo subagents — in production these come from agent messages
|
||||
const SUBAGENTS: SubAgent[] = [
|
||||
{ id: 'sa1', name: 'search-agent', status: 'done' },
|
||||
{ id: 'sa2', name: 'test-runner', status: 'running' },
|
||||
];
|
||||
|
||||
let scrollEl: HTMLDivElement;
|
||||
let promptText = $state('');
|
||||
|
||||
// Auto-scroll to bottom on new messages
|
||||
$effect(() => {
|
||||
// track messages length as dependency
|
||||
const _ = messages.length;
|
||||
void messages.length;
|
||||
tick().then(() => {
|
||||
if (scrollEl) scrollEl.scrollTop = scrollEl.scrollHeight;
|
||||
});
|
||||
|
|
@ -69,13 +80,41 @@
|
|||
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';
|
||||
// ── Drag-resize between messages and terminal ──────────────
|
||||
// The "terminal" section is a sibling rendered by ProjectCard.
|
||||
// We expose a flex-basis on .agent-pane via CSS variable and update it on drag.
|
||||
let agentPaneEl: HTMLDivElement;
|
||||
let isDragging = $state(false);
|
||||
|
||||
function onResizeMouseDown(e: MouseEvent) {
|
||||
e.preventDefault();
|
||||
isDragging = true;
|
||||
|
||||
const startY = e.clientY;
|
||||
const startH = agentPaneEl?.getBoundingClientRect().height ?? 300;
|
||||
|
||||
function onMove(ev: MouseEvent) {
|
||||
if (!agentPaneEl) return;
|
||||
const delta = ev.clientY - startY;
|
||||
const newH = Math.max(120, startH + delta);
|
||||
// Apply as explicit height on the agent-pane element
|
||||
agentPaneEl.style.flexBasis = `${newH}px`;
|
||||
agentPaneEl.style.flexGrow = '0';
|
||||
agentPaneEl.style.flexShrink = '0';
|
||||
}
|
||||
|
||||
function onUp() {
|
||||
isDragging = false;
|
||||
window.removeEventListener('mousemove', onMove);
|
||||
window.removeEventListener('mouseup', onUp);
|
||||
}
|
||||
|
||||
window.addEventListener('mousemove', onMove);
|
||||
window.addEventListener('mouseup', onUp);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="agent-pane">
|
||||
<div class="agent-pane" bind:this={agentPaneEl}>
|
||||
<!-- Messages -->
|
||||
<div class="agent-messages" bind:this={scrollEl}>
|
||||
{#each messages as msg (msg.id)}
|
||||
|
|
@ -125,6 +164,26 @@
|
|||
<span class="strip-cost">{fmtCost(costUsd)}</span>
|
||||
</div>
|
||||
|
||||
<!-- Subagents tree -->
|
||||
{#if SUBAGENTS.length > 0}
|
||||
<div class="subagents-section" aria-label="Subagents">
|
||||
<div class="subagents-label">Subagents</div>
|
||||
<ul class="subagents-list">
|
||||
{#each SUBAGENTS as sa (sa.id)}
|
||||
<li class="subagent-row">
|
||||
<span class="subagent-indent">└</span>
|
||||
<span
|
||||
class="subagent-dot dot-{sa.status}"
|
||||
aria-label={sa.status}
|
||||
></span>
|
||||
<span class="subagent-name">{sa.name}</span>
|
||||
<span class="subagent-status">{sa.status}</span>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Prompt input -->
|
||||
<div class="agent-prompt">
|
||||
<textarea
|
||||
|
|
@ -144,6 +203,16 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Drag-resize handle — sits between agent pane and terminal section -->
|
||||
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
|
||||
<div
|
||||
class="resize-handle"
|
||||
class:dragging={isDragging}
|
||||
role="separator"
|
||||
aria-label="Drag to resize agent pane"
|
||||
onmousedown={onResizeMouseDown}
|
||||
></div>
|
||||
|
||||
<style>
|
||||
.agent-pane {
|
||||
display: flex;
|
||||
|
|
@ -213,10 +282,7 @@
|
|||
}
|
||||
|
||||
/* Tool call collapsible */
|
||||
.tool-group {
|
||||
border-radius: 0.3125rem;
|
||||
overflow: hidden;
|
||||
}
|
||||
.tool-group { border-radius: 0.3125rem; overflow: hidden; }
|
||||
|
||||
.tool-summary {
|
||||
display: flex;
|
||||
|
|
@ -235,30 +301,12 @@
|
|||
|
||||
.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-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);
|
||||
}
|
||||
.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 {
|
||||
|
|
@ -286,14 +334,7 @@
|
|||
.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-model { color: var(--ctp-overlay1); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 8rem; }
|
||||
.strip-spacer { flex: 1; }
|
||||
|
||||
.strip-ctx {
|
||||
|
|
@ -304,17 +345,85 @@
|
|||
font-weight: 500;
|
||||
}
|
||||
|
||||
.strip-ctx.ctx-danger {
|
||||
background: color-mix(in srgb, var(--ctp-red) 15%, transparent);
|
||||
color: var(--ctp-red);
|
||||
.strip-ctx.ctx-warn { background: color-mix(in srgb, var(--ctp-peach) 15%, transparent); color: var(--ctp-peach); }
|
||||
.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; }
|
||||
|
||||
/* Subagents section */
|
||||
.subagents-section {
|
||||
border-top: 1px solid var(--ctp-surface0);
|
||||
background: var(--ctp-mantle);
|
||||
padding: 0.3rem 0.625rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.strip-burn { color: var(--ctp-peach); }
|
||||
.strip-tokens { color: var(--ctp-subtext1); }
|
||||
.subagents-label {
|
||||
font-size: 0.625rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--ctp-overlay0);
|
||||
margin-bottom: 0.2rem;
|
||||
}
|
||||
|
||||
.strip-cost {
|
||||
color: var(--ctp-text);
|
||||
font-weight: 500;
|
||||
.subagents-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.15rem;
|
||||
}
|
||||
|
||||
.subagent-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
font-size: 0.75rem;
|
||||
color: var(--ctp-subtext1);
|
||||
}
|
||||
|
||||
.subagent-indent {
|
||||
color: var(--ctp-overlay0);
|
||||
font-family: var(--term-font-family);
|
||||
font-size: 0.75rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.subagent-dot {
|
||||
width: 0.4rem;
|
||||
height: 0.4rem;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.dot-running { background: var(--ctp-green); }
|
||||
.dot-done { background: var(--ctp-overlay1); }
|
||||
.dot-error { background: var(--ctp-red); }
|
||||
|
||||
.subagent-name { flex: 1; font-family: var(--term-font-family); }
|
||||
|
||||
.subagent-status {
|
||||
font-size: 0.625rem;
|
||||
color: var(--ctp-overlay0);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
|
||||
/* Drag-resize handle */
|
||||
.resize-handle {
|
||||
height: 4px;
|
||||
background: transparent;
|
||||
cursor: row-resize;
|
||||
flex-shrink: 0;
|
||||
transition: background 0.12s;
|
||||
}
|
||||
|
||||
.resize-handle:hover,
|
||||
.resize-handle.dragging {
|
||||
background: var(--ctp-surface1);
|
||||
}
|
||||
|
||||
/* Prompt area */
|
||||
|
|
@ -343,10 +452,7 @@
|
|||
transition: border-color 0.12s;
|
||||
}
|
||||
|
||||
.prompt-input:focus {
|
||||
border-color: var(--accent, var(--ctp-mauve));
|
||||
}
|
||||
|
||||
.prompt-input:focus { border-color: var(--accent, var(--ctp-mauve)); }
|
||||
.prompt-input::placeholder { color: var(--ctp-overlay0); }
|
||||
|
||||
.prompt-send {
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
import ProjectCard from './ProjectCard.svelte';
|
||||
import SettingsDrawer from './SettingsDrawer.svelte';
|
||||
import CommandPalette from './CommandPalette.svelte';
|
||||
import ToastContainer from './ToastContainer.svelte';
|
||||
|
||||
// ── Types ─────────────────────────────────────────────────────
|
||||
type AgentStatus = 'running' | 'idle' | 'stalled';
|
||||
|
|
@ -140,6 +141,7 @@
|
|||
|
||||
<SettingsDrawer open={settingsOpen} onClose={() => settingsOpen = false} />
|
||||
<CommandPalette open={paletteOpen} onClose={() => paletteOpen = false} />
|
||||
<ToastContainer />
|
||||
|
||||
<div class="app-shell">
|
||||
<!-- Sidebar icon rail -->
|
||||
|
|
|
|||
217
ui-electrobun/src/mainview/FileBrowser.svelte
Normal file
217
ui-electrobun/src/mainview/FileBrowser.svelte
Normal file
|
|
@ -0,0 +1,217 @@
|
|||
<script lang="ts">
|
||||
interface FileNode {
|
||||
name: string;
|
||||
type: 'file' | 'dir';
|
||||
children?: FileNode[];
|
||||
}
|
||||
|
||||
// Demo directory tree
|
||||
const TREE: FileNode[] = [
|
||||
{
|
||||
name: 'src', type: 'dir', children: [
|
||||
{
|
||||
name: 'lib', type: 'dir', children: [
|
||||
{
|
||||
name: 'stores', type: 'dir', children: [
|
||||
{ name: 'workspace.svelte.ts', type: 'file' },
|
||||
{ name: 'agents.svelte.ts', type: 'file' },
|
||||
{ name: 'health.svelte.ts', type: 'file' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'adapters', type: 'dir', children: [
|
||||
{ name: 'claude-messages.ts', type: 'file' },
|
||||
{ name: 'agent-bridge.ts', type: 'file' },
|
||||
],
|
||||
},
|
||||
{ name: 'agent-dispatcher.ts', type: 'file' },
|
||||
],
|
||||
},
|
||||
{ name: 'App.svelte', type: 'file' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'src-tauri', type: 'dir', children: [
|
||||
{
|
||||
name: 'src', type: 'dir', children: [
|
||||
{ name: 'lib.rs', type: 'file' },
|
||||
{ name: 'btmsg.rs', type: 'file' },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{ name: 'Cargo.toml', type: 'file' },
|
||||
{ name: 'package.json', type: 'file' },
|
||||
{ name: 'vite.config.ts', type: 'file' },
|
||||
];
|
||||
|
||||
let openDirs = $state<Set<string>>(new Set(['src', 'src/lib', 'src/lib/stores']));
|
||||
let selectedFile = $state<string | null>(null);
|
||||
|
||||
function toggleDir(path: string) {
|
||||
const s = new Set(openDirs);
|
||||
if (s.has(path)) s.delete(path);
|
||||
else s.add(path);
|
||||
openDirs = s;
|
||||
}
|
||||
|
||||
function selectFile(path: string) {
|
||||
selectedFile = path;
|
||||
}
|
||||
|
||||
function fileIcon(name: string): string {
|
||||
if (name.endsWith('.ts') || name.endsWith('.svelte.ts')) return '⟨/⟩';
|
||||
if (name.endsWith('.svelte')) return '◈';
|
||||
if (name.endsWith('.rs')) return '⊕';
|
||||
if (name.endsWith('.toml')) return '⚙';
|
||||
if (name.endsWith('.json')) return '{}';
|
||||
return '·';
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="file-browser">
|
||||
<div class="fb-tree">
|
||||
{#snippet renderNode(node: FileNode, path: string, depth: number)}
|
||||
{#if node.type === 'dir'}
|
||||
<button
|
||||
class="fb-row fb-dir"
|
||||
style:padding-left="{0.5 + depth * 0.875}rem"
|
||||
onclick={() => toggleDir(path)}
|
||||
aria-expanded={openDirs.has(path)}
|
||||
>
|
||||
<span class="fb-chevron" class:open={openDirs.has(path)}>›</span>
|
||||
<span class="fb-icon dir-icon">📁</span>
|
||||
<span class="fb-name">{node.name}</span>
|
||||
</button>
|
||||
{#if openDirs.has(path) && node.children}
|
||||
{#each node.children as child}
|
||||
{@render renderNode(child, `${path}/${child.name}`, depth + 1)}
|
||||
{/each}
|
||||
{/if}
|
||||
{:else}
|
||||
<button
|
||||
class="fb-row fb-file"
|
||||
class:selected={selectedFile === path}
|
||||
style:padding-left="{0.5 + depth * 0.875}rem"
|
||||
onclick={() => selectFile(path)}
|
||||
title={path}
|
||||
>
|
||||
<span class="fb-icon file-type">{fileIcon(node.name)}</span>
|
||||
<span class="fb-name">{node.name}</span>
|
||||
</button>
|
||||
{/if}
|
||||
{/snippet}
|
||||
|
||||
{#each TREE as node}
|
||||
{@render renderNode(node, node.name, 0)}
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
{#if selectedFile}
|
||||
<div class="fb-preview">
|
||||
<div class="fb-preview-label">{selectedFile}</div>
|
||||
<div class="fb-preview-content">(click to open in editor)</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.file-browser {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
.fb-tree {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 0.25rem 0;
|
||||
}
|
||||
|
||||
.fb-tree::-webkit-scrollbar { width: 0.25rem; }
|
||||
.fb-tree::-webkit-scrollbar-track { background: transparent; }
|
||||
.fb-tree::-webkit-scrollbar-thumb { background: var(--ctp-surface1); border-radius: 0.25rem; }
|
||||
|
||||
.fb-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
width: 100%;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--ctp-text);
|
||||
font-family: var(--ui-font-family);
|
||||
font-size: 0.8125rem;
|
||||
padding-top: 0.2rem;
|
||||
padding-bottom: 0.2rem;
|
||||
padding-right: 0.5rem;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
white-space: nowrap;
|
||||
transition: background 0.08s;
|
||||
}
|
||||
|
||||
.fb-row:hover { background: var(--ctp-surface0); }
|
||||
|
||||
.fb-file.selected {
|
||||
background: color-mix(in srgb, var(--accent, var(--ctp-mauve)) 15%, transparent);
|
||||
color: var(--accent, var(--ctp-mauve));
|
||||
}
|
||||
|
||||
.fb-chevron {
|
||||
display: inline-block;
|
||||
width: 0.875rem;
|
||||
font-size: 0.875rem;
|
||||
color: var(--ctp-overlay1);
|
||||
transition: transform 0.12s;
|
||||
transform: rotate(0deg);
|
||||
flex-shrink: 0;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.fb-chevron.open { transform: rotate(90deg); }
|
||||
|
||||
.fb-icon {
|
||||
flex-shrink: 0;
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
.file-type {
|
||||
font-size: 0.6875rem;
|
||||
color: var(--ctp-overlay1);
|
||||
font-family: var(--term-font-family);
|
||||
}
|
||||
|
||||
.fb-name {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.fb-dir .fb-name { color: var(--ctp-subtext1); font-weight: 500; }
|
||||
|
||||
.fb-preview {
|
||||
border-top: 1px solid var(--ctp-surface0);
|
||||
padding: 0.5rem 0.75rem;
|
||||
background: var(--ctp-mantle);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.fb-preview-label {
|
||||
font-size: 0.75rem;
|
||||
color: var(--ctp-subtext0);
|
||||
font-family: var(--term-font-family);
|
||||
margin-bottom: 0.2rem;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.fb-preview-content {
|
||||
font-size: 0.75rem;
|
||||
color: var(--ctp-overlay0);
|
||||
font-style: italic;
|
||||
}
|
||||
</style>
|
||||
208
ui-electrobun/src/mainview/MemoryTab.svelte
Normal file
208
ui-electrobun/src/mainview/MemoryTab.svelte
Normal file
|
|
@ -0,0 +1,208 @@
|
|||
<script lang="ts">
|
||||
type TrustLevel = 'human' | 'agent' | 'auto';
|
||||
|
||||
interface MemoryFragment {
|
||||
id: number;
|
||||
title: string;
|
||||
body: string;
|
||||
tags: string[];
|
||||
trust: TrustLevel;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
const MEMORIES: MemoryFragment[] = [
|
||||
{
|
||||
id: 1,
|
||||
title: 'Agent Orchestrator — Tech Stack',
|
||||
body: 'Tauri 2.x + Svelte 5 frontend. Rust backend with rusqlite (WAL mode). Agent sessions via @anthropic-ai/claude-agent-sdk query(). Sidecar uses stdio NDJSON.',
|
||||
tags: ['agor', 'tech-stack', 'architecture'],
|
||||
trust: 'human',
|
||||
updatedAt: '2026-03-20',
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
title: 'btmsg SQLite conventions',
|
||||
body: 'All queries use named column access (row.get("column_name")) — never positional indices. Rust structs use #[serde(rename_all = "camelCase")].',
|
||||
tags: ['agor', 'database', 'btmsg'],
|
||||
trust: 'agent',
|
||||
updatedAt: '2026-03-19',
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
title: 'Wake Scheduler — 3 strategies',
|
||||
body: 'persistent=resume prompt, on-demand=fresh session, smart=threshold-gated on-demand. 6 wake signals from S-3 hybrid tribunal. Pure scorer in wake-scorer.ts (24 tests).',
|
||||
tags: ['agor', 'wake-scheduler', 'agents'],
|
||||
trust: 'agent',
|
||||
updatedAt: '2026-03-18',
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
title: 'Svelte 5 runes file extension rule',
|
||||
body: 'Store files using Svelte 5 runes ($state, $derived) MUST have .svelte.ts extension. Plain .ts compiles but fails at runtime with "rune_outside_svelte".',
|
||||
tags: ['agor', 'svelte', 'conventions'],
|
||||
trust: 'auto',
|
||||
updatedAt: '2026-03-17',
|
||||
},
|
||||
];
|
||||
|
||||
const TRUST_LABELS: Record<TrustLevel, string> = {
|
||||
human: 'Human',
|
||||
agent: 'Agent',
|
||||
auto: 'Auto',
|
||||
};
|
||||
</script>
|
||||
|
||||
<div class="memory-tab">
|
||||
<div class="memory-header">
|
||||
<span class="memory-count">{MEMORIES.length} fragments</span>
|
||||
<span class="memory-hint">via Memora</span>
|
||||
</div>
|
||||
|
||||
<div class="memory-list">
|
||||
{#each MEMORIES as mem (mem.id)}
|
||||
<article class="memory-card">
|
||||
<div class="memory-card-top">
|
||||
<span class="memory-title">{mem.title}</span>
|
||||
<span class="trust-badge trust-{mem.trust}" title="Source: {TRUST_LABELS[mem.trust]}">
|
||||
{TRUST_LABELS[mem.trust]}
|
||||
</span>
|
||||
</div>
|
||||
<p class="memory-body">{mem.body}</p>
|
||||
<div class="memory-footer">
|
||||
<div class="memory-tags">
|
||||
{#each mem.tags as tag}
|
||||
<span class="tag">{tag}</span>
|
||||
{/each}
|
||||
</div>
|
||||
<span class="memory-date">{mem.updatedAt}</span>
|
||||
</div>
|
||||
</article>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.memory-tab {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.memory-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0.375rem 0.625rem;
|
||||
border-bottom: 1px solid var(--ctp-surface0);
|
||||
background: var(--ctp-mantle);
|
||||
flex-shrink: 0;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.memory-count { color: var(--ctp-text); font-weight: 500; }
|
||||
.memory-hint { color: var(--ctp-overlay0); font-style: italic; }
|
||||
|
||||
.memory-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 0.375rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.375rem;
|
||||
}
|
||||
|
||||
.memory-list::-webkit-scrollbar { width: 0.25rem; }
|
||||
.memory-list::-webkit-scrollbar-track { background: transparent; }
|
||||
.memory-list::-webkit-scrollbar-thumb { background: var(--ctp-surface1); border-radius: 0.25rem; }
|
||||
|
||||
.memory-card {
|
||||
background: var(--ctp-surface0);
|
||||
border: 1px solid var(--ctp-surface1);
|
||||
border-radius: 0.375rem;
|
||||
padding: 0.5rem 0.625rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.3rem;
|
||||
transition: border-color 0.12s;
|
||||
}
|
||||
|
||||
.memory-card:hover { border-color: var(--ctp-surface2); }
|
||||
|
||||
.memory-card-top {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.memory-title {
|
||||
flex: 1;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 600;
|
||||
color: var(--ctp-text);
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.trust-badge {
|
||||
flex-shrink: 0;
|
||||
padding: 0.1rem 0.35rem;
|
||||
border-radius: 0.25rem;
|
||||
font-size: 0.6rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.trust-human {
|
||||
background: color-mix(in srgb, var(--ctp-green) 15%, transparent);
|
||||
color: var(--ctp-green);
|
||||
}
|
||||
|
||||
.trust-agent {
|
||||
background: color-mix(in srgb, var(--ctp-blue) 15%, transparent);
|
||||
color: var(--ctp-blue);
|
||||
}
|
||||
|
||||
.trust-auto {
|
||||
background: color-mix(in srgb, var(--ctp-overlay1) 15%, transparent);
|
||||
color: var(--ctp-overlay1);
|
||||
}
|
||||
|
||||
.memory-body {
|
||||
margin: 0;
|
||||
font-size: 0.75rem;
|
||||
color: var(--ctp-subtext1);
|
||||
line-height: 1.45;
|
||||
font-family: var(--ui-font-family);
|
||||
}
|
||||
|
||||
.memory-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.1rem;
|
||||
}
|
||||
|
||||
.memory-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.25rem;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.tag {
|
||||
padding: 0.05rem 0.3rem;
|
||||
background: var(--ctp-surface1);
|
||||
border-radius: 0.2rem;
|
||||
font-size: 0.625rem;
|
||||
color: var(--ctp-overlay1);
|
||||
font-family: var(--term-font-family);
|
||||
}
|
||||
|
||||
.memory-date {
|
||||
font-size: 0.625rem;
|
||||
color: var(--ctp-overlay0);
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,10 +1,12 @@
|
|||
<script lang="ts">
|
||||
import AgentPane from './AgentPane.svelte';
|
||||
import TerminalTabs from './TerminalTabs.svelte';
|
||||
import FileBrowser from './FileBrowser.svelte';
|
||||
import MemoryTab from './MemoryTab.svelte';
|
||||
|
||||
type AgentStatus = 'running' | 'idle' | 'stalled';
|
||||
type MsgRole = 'user' | 'assistant' | 'tool-call' | 'tool-result';
|
||||
type ProjectTab = 'model' | 'docs' | 'files';
|
||||
type ProjectTab = 'model' | 'docs' | 'context' | 'files' | 'ssh' | 'memory';
|
||||
|
||||
interface AgentMessage {
|
||||
id: number;
|
||||
|
|
@ -47,26 +49,22 @@
|
|||
}: 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']));
|
||||
|
||||
const ALL_TABS: ProjectTab[] = ['model', 'docs', 'context', 'files', 'ssh', 'memory'];
|
||||
|
||||
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,
|
||||
};
|
||||
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,
|
||||
|
|
@ -96,15 +94,12 @@
|
|||
<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"
|
||||
|
|
@ -114,7 +109,6 @@
|
|||
>{contextPct}%</span>
|
||||
{/if}
|
||||
|
||||
<!-- Burn rate -->
|
||||
{#if burnRate > 0}
|
||||
<span class="burn-badge" title="Burn rate">${burnRate.toFixed(2)}/hr</span>
|
||||
{/if}
|
||||
|
|
@ -122,7 +116,7 @@
|
|||
|
||||
<!-- Project tab bar -->
|
||||
<div class="tab-bar" role="tablist" aria-label="{name} tabs">
|
||||
{#each (['model', 'docs', 'files'] as ProjectTab[]) as tab}
|
||||
{#each ALL_TABS as tab}
|
||||
<button
|
||||
class="tab-btn"
|
||||
class:active={activeTab === tab}
|
||||
|
|
@ -138,7 +132,7 @@
|
|||
|
||||
<!-- Tab content: display:flex/none to keep agent session mounted -->
|
||||
<div class="tab-content">
|
||||
<!-- Model tab: agent pane + terminal tabs -->
|
||||
<!-- Model tab: agent pane + terminal tabs — always mounted -->
|
||||
<div
|
||||
id="tabpanel-{id}-model"
|
||||
class="tab-pane"
|
||||
|
|
@ -174,6 +168,49 @@
|
|||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Context tab -->
|
||||
{#if activatedTabs.has('context')}
|
||||
<div
|
||||
id="tabpanel-{id}-context"
|
||||
class="tab-pane"
|
||||
style:display={activeTab === 'context' ? 'flex' : 'none'}
|
||||
role="tabpanel"
|
||||
aria-label="Context"
|
||||
>
|
||||
<div class="context-pane">
|
||||
<div class="ctx-stats-row">
|
||||
<div class="ctx-stat">
|
||||
<span class="ctx-stat-label">Tokens used</span>
|
||||
<span class="ctx-stat-value">{tokens.toLocaleString()}</span>
|
||||
</div>
|
||||
<div class="ctx-stat">
|
||||
<span class="ctx-stat-label">Context %</span>
|
||||
<span class="ctx-stat-value">{contextPct}%</span>
|
||||
</div>
|
||||
<div class="ctx-stat">
|
||||
<span class="ctx-stat-label">Model</span>
|
||||
<span class="ctx-stat-value">{model}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ctx-meter-wrap" title="{contextPct}% context used">
|
||||
<div class="ctx-meter-bar" style:width="{contextPct}%"
|
||||
class:meter-warn={contextPct >= 75}
|
||||
class:meter-danger={contextPct >= 90}
|
||||
></div>
|
||||
</div>
|
||||
<div class="ctx-turn-list">
|
||||
<div class="ctx-section-label">Turn breakdown</div>
|
||||
{#each messages.slice(0, 5) as msg}
|
||||
<div class="ctx-turn-row">
|
||||
<span class="ctx-turn-role ctx-role-{msg.role}">{msg.role}</span>
|
||||
<span class="ctx-turn-preview">{msg.content.slice(0, 60)}{msg.content.length > 60 ? '…' : ''}</span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Files tab -->
|
||||
{#if activatedTabs.has('files')}
|
||||
<div
|
||||
|
|
@ -183,7 +220,33 @@
|
|||
role="tabpanel"
|
||||
aria-label="Files"
|
||||
>
|
||||
<div class="placeholder-pane">File browser — coming soon</div>
|
||||
<FileBrowser />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- SSH tab -->
|
||||
{#if activatedTabs.has('ssh')}
|
||||
<div
|
||||
id="tabpanel-{id}-ssh"
|
||||
class="tab-pane"
|
||||
style:display={activeTab === 'ssh' ? 'flex' : 'none'}
|
||||
role="tabpanel"
|
||||
aria-label="SSH"
|
||||
>
|
||||
<div class="placeholder-pane">No SSH connections configured</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Memory tab -->
|
||||
{#if activatedTabs.has('memory')}
|
||||
<div
|
||||
id="tabpanel-{id}-memory"
|
||||
class="tab-pane"
|
||||
style:display={activeTab === 'memory' ? 'flex' : 'none'}
|
||||
role="tabpanel"
|
||||
aria-label="Memory"
|
||||
>
|
||||
<MemoryTab />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
|
@ -296,8 +359,8 @@
|
|||
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); }
|
||||
.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);
|
||||
|
|
@ -314,10 +377,14 @@
|
|||
flex-shrink: 0;
|
||||
padding: 0 0.25rem;
|
||||
gap: 0.125rem;
|
||||
overflow-x: auto;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.tab-bar::-webkit-scrollbar { display: none; }
|
||||
|
||||
.tab-btn {
|
||||
padding: 0 0.75rem;
|
||||
padding: 0 0.625rem;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-bottom: 2px solid transparent;
|
||||
|
|
@ -328,6 +395,7 @@
|
|||
white-space: nowrap;
|
||||
transition: color 0.12s, border-color 0.12s;
|
||||
margin-bottom: -1px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.tab-btn:hover { color: var(--ctp-text); }
|
||||
|
|
@ -361,4 +429,101 @@
|
|||
font-size: 0.8125rem;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* Context tab */
|
||||
.context-pane {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
padding: 0.625rem;
|
||||
overflow-y: auto;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.ctx-stats-row {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.ctx-stat {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.125rem;
|
||||
}
|
||||
|
||||
.ctx-stat-label {
|
||||
font-size: 0.625rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--ctp-overlay0);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.ctx-stat-value {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
color: var(--ctp-text);
|
||||
font-family: var(--term-font-family);
|
||||
}
|
||||
|
||||
.ctx-meter-wrap {
|
||||
height: 0.375rem;
|
||||
background: var(--ctp-surface0);
|
||||
border-radius: 0.25rem;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.ctx-meter-bar {
|
||||
height: 100%;
|
||||
background: var(--ctp-teal);
|
||||
border-radius: 0.25rem;
|
||||
transition: width 0.3s;
|
||||
}
|
||||
|
||||
.ctx-meter-bar.meter-warn { background: var(--ctp-peach); }
|
||||
.ctx-meter-bar.meter-danger { background: var(--ctp-red); }
|
||||
|
||||
.ctx-section-label {
|
||||
font-size: 0.625rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--ctp-overlay0);
|
||||
font-weight: 600;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.ctx-turn-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.ctx-turn-row {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.ctx-turn-role {
|
||||
flex-shrink: 0;
|
||||
font-size: 0.625rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
min-width: 4.5rem;
|
||||
}
|
||||
|
||||
.ctx-role-user { color: var(--ctp-blue); }
|
||||
.ctx-role-assistant { color: var(--ctp-mauve); }
|
||||
.ctx-role-tool-call { color: var(--ctp-peach); }
|
||||
.ctx-role-tool-result { color: var(--ctp-teal); }
|
||||
|
||||
.ctx-turn-preview {
|
||||
color: var(--ctp-subtext0);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
flex: 1;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -7,7 +7,21 @@
|
|||
let { open, onClose }: Props = $props();
|
||||
|
||||
// Local settings state (demo — no persistence in prototype)
|
||||
let theme = $state('Catppuccin Mocha');
|
||||
const THEMES = [
|
||||
{ id: 'mocha', label: 'Catppuccin Mocha' },
|
||||
{ id: 'macchiato', label: 'Catppuccin Macchiato' },
|
||||
{ id: 'frappe', label: 'Catppuccin Frappé' },
|
||||
{ id: 'latte', label: 'Catppuccin Latte' },
|
||||
];
|
||||
let themeId = $state('mocha');
|
||||
let themeDropdownOpen = $state(false);
|
||||
let selectedThemeLabel = $derived(THEMES.find(t => t.id === themeId)?.label ?? 'Catppuccin Mocha');
|
||||
|
||||
function selectTheme(id: string) {
|
||||
themeId = id;
|
||||
themeDropdownOpen = false;
|
||||
}
|
||||
|
||||
let uiFontSize = $state(14);
|
||||
let termFontSize = $state(13);
|
||||
|
||||
|
|
@ -60,8 +74,36 @@
|
|||
<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>
|
||||
<label class="setting-label" for="theme-dropdown-btn">Theme</label>
|
||||
<div class="theme-dropdown">
|
||||
<button
|
||||
id="theme-dropdown-btn"
|
||||
class="theme-dropdown-btn"
|
||||
onclick={() => themeDropdownOpen = !themeDropdownOpen}
|
||||
aria-haspopup="listbox"
|
||||
aria-expanded={themeDropdownOpen}
|
||||
>
|
||||
<span class="theme-dropdown-label">{selectedThemeLabel}</span>
|
||||
<svg class="theme-chevron" class:open={themeDropdownOpen} viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
|
||||
<polyline points="6 9 12 15 18 9"/>
|
||||
</svg>
|
||||
</button>
|
||||
{#if themeDropdownOpen}
|
||||
<ul class="theme-dropdown-list" role="listbox" aria-label="Theme options">
|
||||
{#each THEMES as t}
|
||||
<li
|
||||
class="theme-option"
|
||||
class:selected={themeId === t.id}
|
||||
role="option"
|
||||
aria-selected={themeId === t.id}
|
||||
onclick={() => selectTheme(t.id)}
|
||||
onkeydown={(e) => (e.key === 'Enter' || e.key === ' ') && selectTheme(t.id)}
|
||||
tabindex="0"
|
||||
>{t.label}</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="setting-row">
|
||||
|
|
@ -232,17 +274,76 @@
|
|||
color: var(--ctp-subtext1);
|
||||
}
|
||||
|
||||
.setting-value {
|
||||
font-size: 0.8125rem;
|
||||
color: var(--ctp-text);
|
||||
/* Theme dropdown */
|
||||
.theme-dropdown {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.theme-pill {
|
||||
.theme-dropdown-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
background: var(--ctp-surface0);
|
||||
padding: 0.2rem 0.5rem;
|
||||
border: 1px solid var(--ctp-surface1);
|
||||
border-radius: 0.3rem;
|
||||
font-size: 0.75rem;
|
||||
padding: 0.2rem 0.5rem;
|
||||
color: var(--ctp-mauve);
|
||||
font-family: var(--ui-font-family);
|
||||
font-size: 0.75rem;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.12s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.theme-dropdown-btn:hover { border-color: var(--ctp-surface2); }
|
||||
|
||||
.theme-dropdown-label { flex: 1; }
|
||||
|
||||
.theme-chevron {
|
||||
width: 0.75rem;
|
||||
height: 0.75rem;
|
||||
color: var(--ctp-overlay1);
|
||||
transition: transform 0.15s;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.theme-chevron.open { transform: rotate(180deg); }
|
||||
|
||||
.theme-dropdown-list {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: calc(100% + 0.25rem);
|
||||
z-index: 10;
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0.25rem;
|
||||
background: var(--ctp-mantle);
|
||||
border: 1px solid var(--ctp-surface1);
|
||||
border-radius: 0.375rem;
|
||||
min-width: 11rem;
|
||||
box-shadow: 0 0.5rem 1.25rem color-mix(in srgb, var(--ctp-crust) 60%, transparent);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.0625rem;
|
||||
}
|
||||
|
||||
.theme-option {
|
||||
padding: 0.35rem 0.5rem;
|
||||
border-radius: 0.25rem;
|
||||
font-size: 0.8125rem;
|
||||
color: var(--ctp-subtext1);
|
||||
cursor: pointer;
|
||||
transition: background 0.08s, color 0.08s;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.theme-option:hover,
|
||||
.theme-option:focus { background: var(--ctp-surface0); color: var(--ctp-text); }
|
||||
|
||||
.theme-option.selected {
|
||||
background: color-mix(in srgb, var(--ctp-mauve) 15%, transparent);
|
||||
color: var(--ctp-mauve);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Font stepper */
|
||||
|
|
|
|||
|
|
@ -34,6 +34,13 @@
|
|||
let term: Terminal;
|
||||
let fitAddon: FitAddon;
|
||||
|
||||
// Current line buffer for demo shell simulation
|
||||
let lineBuffer = '';
|
||||
|
||||
function writePrompt() {
|
||||
term.write('\r\n\x1b[1;34m~/code/ai/agent-orchestrator\x1b[0m \x1b[32m$\x1b[0m ');
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
term = new Terminal({
|
||||
theme: THEME,
|
||||
|
|
@ -67,9 +74,43 @@
|
|||
term.writeln(' \x1b[1;32mRunning\x1b[0m tests/unit.rs');
|
||||
term.writeln('test result: ok. \x1b[32m47 passed\x1b[0m; 0 failed');
|
||||
term.writeln('');
|
||||
term.writeln('\x1b[1;34m~/code/ai/agent-orchestrator\x1b[0m \x1b[32m$\x1b[0m \x1b[5m▊\x1b[0m');
|
||||
term.write('\x1b[1;34m~/code/ai/agent-orchestrator\x1b[0m \x1b[32m$\x1b[0m ');
|
||||
|
||||
// Resize on window resize
|
||||
// Input handler — simulate local shell until real PTY is connected
|
||||
term.onData((data: string) => {
|
||||
for (const ch of data) {
|
||||
const code = ch.charCodeAt(0);
|
||||
|
||||
if (ch === '\r') {
|
||||
// Enter key — echo newline, execute (demo), write new prompt
|
||||
const cmd = lineBuffer.trim();
|
||||
lineBuffer = '';
|
||||
if (cmd === '') {
|
||||
writePrompt();
|
||||
} else if (cmd === 'clear') {
|
||||
term.clear();
|
||||
term.write('\x1b[1;34m~/code/ai/agent-orchestrator\x1b[0m \x1b[32m$\x1b[0m ');
|
||||
} else {
|
||||
term.write('\r\n');
|
||||
term.writeln(`\x1b[31mbash: ${cmd}: command not found\x1b[0m`);
|
||||
writePrompt();
|
||||
}
|
||||
} else if (code === 127 || ch === '\x08') {
|
||||
// Backspace
|
||||
if (lineBuffer.length > 0) {
|
||||
lineBuffer = lineBuffer.slice(0, -1);
|
||||
term.write('\b \b');
|
||||
}
|
||||
} else if (code >= 32 && code !== 127) {
|
||||
// Printable character — echo and buffer
|
||||
lineBuffer += ch;
|
||||
term.write(ch);
|
||||
}
|
||||
// Ignore control characters (Ctrl+C etc. — no real PTY)
|
||||
}
|
||||
});
|
||||
|
||||
// Resize on container resize
|
||||
const ro = new ResizeObserver(() => fitAddon.fit());
|
||||
ro.observe(termEl);
|
||||
|
||||
|
|
|
|||
|
|
@ -38,11 +38,9 @@
|
|||
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;
|
||||
|
|
@ -50,7 +48,6 @@
|
|||
|
||||
function activateTab(id: string) {
|
||||
activeTabId = id;
|
||||
// Mount on first activation
|
||||
if (!mounted.has(id)) {
|
||||
mounted = new Set([...mounted, id]);
|
||||
}
|
||||
|
|
@ -120,23 +117,24 @@
|
|||
</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}
|
||||
<!--
|
||||
Terminal panes: always rendered (display:none when collapsed) so xterm state
|
||||
is preserved across collapse/expand. Using display:none instead of {#if}.
|
||||
-->
|
||||
<div class="term-panes" style:display={collapsed ? 'none' : 'block'}>
|
||||
{#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>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
|
|
|
|||
132
ui-electrobun/src/mainview/ToastContainer.svelte
Normal file
132
ui-electrobun/src/mainview/ToastContainer.svelte
Normal file
|
|
@ -0,0 +1,132 @@
|
|||
<script lang="ts">
|
||||
type ToastVariant = 'success' | 'warning' | 'error' | 'info';
|
||||
|
||||
interface Toast {
|
||||
id: number;
|
||||
message: string;
|
||||
variant: ToastVariant;
|
||||
dismissAt: number; // epoch ms
|
||||
}
|
||||
|
||||
let toasts = $state<Toast[]>([]);
|
||||
let counter = $state(0);
|
||||
|
||||
// Public API — call via component binding or module-level store
|
||||
export function addToast(message: string, variant: ToastVariant = 'info', durationMs = 4000) {
|
||||
const id = ++counter;
|
||||
toasts = [...toasts, { id, message, variant, dismissAt: Date.now() + durationMs }];
|
||||
setTimeout(() => dismiss(id), durationMs);
|
||||
}
|
||||
|
||||
function dismiss(id: number) {
|
||||
toasts = toasts.filter(t => t.id !== id);
|
||||
}
|
||||
|
||||
// Show a demo toast on mount
|
||||
import { onMount } from 'svelte';
|
||||
onMount(() => {
|
||||
setTimeout(() => {
|
||||
addToast('Agent Orchestrator connected', 'success', 4000);
|
||||
}, 800);
|
||||
});
|
||||
|
||||
const ICONS: Record<ToastVariant, string> = {
|
||||
success: '✓',
|
||||
warning: '⚠',
|
||||
error: '✕',
|
||||
info: 'ℹ',
|
||||
};
|
||||
</script>
|
||||
|
||||
<div class="toast-container" aria-live="polite" aria-label="Notifications">
|
||||
{#each toasts as toast (toast.id)}
|
||||
<div class="toast toast-{toast.variant}" role="alert">
|
||||
<span class="toast-icon" aria-hidden="true">{ICONS[toast.variant]}</span>
|
||||
<span class="toast-msg">{toast.message}</span>
|
||||
<button
|
||||
class="toast-close"
|
||||
onclick={() => dismiss(toast.id)}
|
||||
aria-label="Dismiss notification"
|
||||
>×</button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.toast-container {
|
||||
position: fixed;
|
||||
bottom: 2.5rem; /* above status bar */
|
||||
right: 0.875rem;
|
||||
z-index: 400;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.375rem;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.toast {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 0.625rem;
|
||||
border-radius: 0.4375rem;
|
||||
border: 1px solid var(--ctp-surface1);
|
||||
background: var(--ctp-mantle);
|
||||
font-size: 0.8125rem;
|
||||
color: var(--ctp-text);
|
||||
pointer-events: auto;
|
||||
animation: toast-in 0.18s ease-out;
|
||||
min-width: 14rem;
|
||||
max-width: 22rem;
|
||||
box-shadow: 0 0.5rem 1.5rem color-mix(in srgb, var(--ctp-crust) 70%, transparent);
|
||||
}
|
||||
|
||||
@keyframes toast-in {
|
||||
from { transform: translateX(1.5rem); opacity: 0; }
|
||||
to { transform: translateX(0); opacity: 1; }
|
||||
}
|
||||
|
||||
.toast-success { border-left: 3px solid var(--ctp-green); }
|
||||
.toast-warning { border-left: 3px solid var(--ctp-yellow); }
|
||||
.toast-error { border-left: 3px solid var(--ctp-red); }
|
||||
.toast-info { border-left: 3px solid var(--ctp-blue); }
|
||||
|
||||
.toast-icon {
|
||||
flex-shrink: 0;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.toast-success .toast-icon { color: var(--ctp-green); }
|
||||
.toast-warning .toast-icon { color: var(--ctp-yellow); }
|
||||
.toast-error .toast-icon { color: var(--ctp-red); }
|
||||
.toast-info .toast-icon { color: var(--ctp-blue); }
|
||||
|
||||
.toast-msg {
|
||||
flex: 1;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.toast-close {
|
||||
flex-shrink: 0;
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--ctp-overlay1);
|
||||
font-size: 1rem;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 0.2rem;
|
||||
padding: 0;
|
||||
transition: color 0.1s, background 0.1s;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.toast-close:hover {
|
||||
color: var(--ctp-text);
|
||||
background: var(--ctp-surface0);
|
||||
}
|
||||
</style>
|
||||
Loading…
Add table
Add a link
Reference in a new issue