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
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-6SIDSUhF.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-Dzsk_7I4.css">
|
||||
<script type="module" crossorigin src="/assets/index-DaS4mpNA.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-CQYzhden.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
|
|
|||
|
|
@ -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