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 charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Svelte App</title>
|
<title>Svelte App</title>
|
||||||
<script type="module" crossorigin src="/assets/index-6SIDSUhF.js"></script>
|
<script type="module" crossorigin src="/assets/index-DaS4mpNA.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/assets/index-Dzsk_7I4.css">
|
<link rel="stylesheet" crossorigin href="/assets/index-CQYzhden.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app"></div>
|
<div id="app"></div>
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,12 @@
|
||||||
content: string;
|
content: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface SubAgent {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
status: 'running' | 'done' | 'error';
|
||||||
|
}
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
messages: AgentMessage[];
|
messages: AgentMessage[];
|
||||||
status: 'running' | 'idle' | 'stalled';
|
status: 'running' | 'idle' | 'stalled';
|
||||||
|
|
@ -28,20 +34,25 @@
|
||||||
costUsd,
|
costUsd,
|
||||||
tokens,
|
tokens,
|
||||||
model = 'claude-opus-4-5',
|
model = 'claude-opus-4-5',
|
||||||
provider = 'claude',
|
provider: _provider = 'claude',
|
||||||
profile,
|
profile: _profile,
|
||||||
contextPct = 0,
|
contextPct = 0,
|
||||||
burnRate = 0,
|
burnRate = 0,
|
||||||
onSend,
|
onSend,
|
||||||
}: Props = $props();
|
}: 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 scrollEl: HTMLDivElement;
|
||||||
let promptText = $state('');
|
let promptText = $state('');
|
||||||
|
|
||||||
// Auto-scroll to bottom on new messages
|
// Auto-scroll to bottom on new messages
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
// track messages length as dependency
|
void messages.length;
|
||||||
const _ = messages.length;
|
|
||||||
tick().then(() => {
|
tick().then(() => {
|
||||||
if (scrollEl) scrollEl.scrollTop = scrollEl.scrollHeight;
|
if (scrollEl) scrollEl.scrollTop = scrollEl.scrollHeight;
|
||||||
});
|
});
|
||||||
|
|
@ -69,13 +80,41 @@
|
||||||
return `$${n.toFixed(3)}`;
|
return `$${n.toFixed(3)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pair tool-calls with their results for collapsible display
|
// ── Drag-resize between messages and terminal ──────────────
|
||||||
function isToolMsg(msg: AgentMessage) {
|
// The "terminal" section is a sibling rendered by ProjectCard.
|
||||||
return msg.role === 'tool-call' || msg.role === 'tool-result';
|
// 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>
|
</script>
|
||||||
|
|
||||||
<div class="agent-pane">
|
<div class="agent-pane" bind:this={agentPaneEl}>
|
||||||
<!-- Messages -->
|
<!-- Messages -->
|
||||||
<div class="agent-messages" bind:this={scrollEl}>
|
<div class="agent-messages" bind:this={scrollEl}>
|
||||||
{#each messages as msg (msg.id)}
|
{#each messages as msg (msg.id)}
|
||||||
|
|
@ -125,6 +164,26 @@
|
||||||
<span class="strip-cost">{fmtCost(costUsd)}</span>
|
<span class="strip-cost">{fmtCost(costUsd)}</span>
|
||||||
</div>
|
</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 -->
|
<!-- Prompt input -->
|
||||||
<div class="agent-prompt">
|
<div class="agent-prompt">
|
||||||
<textarea
|
<textarea
|
||||||
|
|
@ -144,6 +203,16 @@
|
||||||
</div>
|
</div>
|
||||||
</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>
|
<style>
|
||||||
.agent-pane {
|
.agent-pane {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|
@ -213,10 +282,7 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Tool call collapsible */
|
/* Tool call collapsible */
|
||||||
.tool-group {
|
.tool-group { border-radius: 0.3125rem; overflow: hidden; }
|
||||||
border-radius: 0.3125rem;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tool-summary {
|
.tool-summary {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|
@ -235,30 +301,12 @@
|
||||||
|
|
||||||
.tool-summary::-webkit-details-marker { display: none; }
|
.tool-summary::-webkit-details-marker { display: none; }
|
||||||
.tool-summary:hover { color: var(--ctp-text); }
|
.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 {
|
.tool-group[open] .tool-summary { border-radius: 0.3125rem 0.3125rem 0 0; }
|
||||||
font-size: 0.6875rem;
|
.tool-group .msg-body { border-radius: 0 0 0.3125rem 0.3125rem; border-left: 2px solid var(--ctp-peach); border-top: none; }
|
||||||
color: var(--ctp-peach);
|
.tool-group .msg-body.tool-result { border-left-color: var(--ctp-teal); }
|
||||||
}
|
|
||||||
|
|
||||||
.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 */
|
/* Status strip */
|
||||||
.agent-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-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); }
|
.badge-stalled { background: color-mix(in srgb, var(--ctp-peach) 20%, transparent); color: var(--ctp-peach); }
|
||||||
|
|
||||||
.strip-model {
|
.strip-model { color: var(--ctp-overlay1); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 8rem; }
|
||||||
color: var(--ctp-overlay1);
|
|
||||||
white-space: nowrap;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
max-width: 8rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.strip-spacer { flex: 1; }
|
.strip-spacer { flex: 1; }
|
||||||
|
|
||||||
.strip-ctx {
|
.strip-ctx {
|
||||||
|
|
@ -304,17 +345,85 @@
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
.strip-ctx.ctx-danger {
|
.strip-ctx.ctx-warn { background: color-mix(in srgb, var(--ctp-peach) 15%, transparent); color: var(--ctp-peach); }
|
||||||
background: color-mix(in srgb, var(--ctp-red) 15%, transparent);
|
.strip-ctx.ctx-danger { background: color-mix(in srgb, var(--ctp-red) 15%, transparent); color: var(--ctp-red); }
|
||||||
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); }
|
.subagents-label {
|
||||||
.strip-tokens { color: var(--ctp-subtext1); }
|
font-size: 0.625rem;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
color: var(--ctp-overlay0);
|
||||||
|
margin-bottom: 0.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
.strip-cost {
|
.subagents-list {
|
||||||
color: var(--ctp-text);
|
list-style: none;
|
||||||
font-weight: 500;
|
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 */
|
/* Prompt area */
|
||||||
|
|
@ -343,10 +452,7 @@
|
||||||
transition: border-color 0.12s;
|
transition: border-color 0.12s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.prompt-input:focus {
|
.prompt-input:focus { border-color: var(--accent, var(--ctp-mauve)); }
|
||||||
border-color: var(--accent, var(--ctp-mauve));
|
|
||||||
}
|
|
||||||
|
|
||||||
.prompt-input::placeholder { color: var(--ctp-overlay0); }
|
.prompt-input::placeholder { color: var(--ctp-overlay0); }
|
||||||
|
|
||||||
.prompt-send {
|
.prompt-send {
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
import ProjectCard from './ProjectCard.svelte';
|
import ProjectCard from './ProjectCard.svelte';
|
||||||
import SettingsDrawer from './SettingsDrawer.svelte';
|
import SettingsDrawer from './SettingsDrawer.svelte';
|
||||||
import CommandPalette from './CommandPalette.svelte';
|
import CommandPalette from './CommandPalette.svelte';
|
||||||
|
import ToastContainer from './ToastContainer.svelte';
|
||||||
|
|
||||||
// ── Types ─────────────────────────────────────────────────────
|
// ── Types ─────────────────────────────────────────────────────
|
||||||
type AgentStatus = 'running' | 'idle' | 'stalled';
|
type AgentStatus = 'running' | 'idle' | 'stalled';
|
||||||
|
|
@ -140,6 +141,7 @@
|
||||||
|
|
||||||
<SettingsDrawer open={settingsOpen} onClose={() => settingsOpen = false} />
|
<SettingsDrawer open={settingsOpen} onClose={() => settingsOpen = false} />
|
||||||
<CommandPalette open={paletteOpen} onClose={() => paletteOpen = false} />
|
<CommandPalette open={paletteOpen} onClose={() => paletteOpen = false} />
|
||||||
|
<ToastContainer />
|
||||||
|
|
||||||
<div class="app-shell">
|
<div class="app-shell">
|
||||||
<!-- Sidebar icon rail -->
|
<!-- 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">
|
<script lang="ts">
|
||||||
import AgentPane from './AgentPane.svelte';
|
import AgentPane from './AgentPane.svelte';
|
||||||
import TerminalTabs from './TerminalTabs.svelte';
|
import TerminalTabs from './TerminalTabs.svelte';
|
||||||
|
import FileBrowser from './FileBrowser.svelte';
|
||||||
|
import MemoryTab from './MemoryTab.svelte';
|
||||||
|
|
||||||
type AgentStatus = 'running' | 'idle' | 'stalled';
|
type AgentStatus = 'running' | 'idle' | 'stalled';
|
||||||
type MsgRole = 'user' | 'assistant' | 'tool-call' | 'tool-result';
|
type MsgRole = 'user' | 'assistant' | 'tool-call' | 'tool-result';
|
||||||
type ProjectTab = 'model' | 'docs' | 'files';
|
type ProjectTab = 'model' | 'docs' | 'context' | 'files' | 'ssh' | 'memory';
|
||||||
|
|
||||||
interface AgentMessage {
|
interface AgentMessage {
|
||||||
id: number;
|
id: number;
|
||||||
|
|
@ -47,26 +49,22 @@
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
let activeTab = $state<ProjectTab>('model');
|
let activeTab = $state<ProjectTab>('model');
|
||||||
// Capture snapshot of initial prop value — messages is stable mount-time demo data
|
|
||||||
// svelte-ignore state_referenced_locally
|
// svelte-ignore state_referenced_locally
|
||||||
const seedMessages = initialMessages.slice();
|
const seedMessages = initialMessages.slice();
|
||||||
let messages = $state(seedMessages);
|
let messages = $state(seedMessages);
|
||||||
// Track which project tabs have been activated (PERSISTED-LAZY pattern)
|
// Track which project tabs have been activated (PERSISTED-LAZY pattern)
|
||||||
let activatedTabs = $state<Set<ProjectTab>>(new Set(['model']));
|
let activatedTabs = $state<Set<ProjectTab>>(new Set(['model']));
|
||||||
|
|
||||||
|
const ALL_TABS: ProjectTab[] = ['model', 'docs', 'context', 'files', 'ssh', 'memory'];
|
||||||
|
|
||||||
function setTab(tab: ProjectTab) {
|
function setTab(tab: ProjectTab) {
|
||||||
activeTab = tab;
|
activeTab = tab;
|
||||||
activatedTabs = new Set([...activatedTabs, tab]);
|
activatedTabs = new Set([...activatedTabs, tab]);
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleSend(text: string) {
|
function handleSend(text: string) {
|
||||||
const newMsg: AgentMessage = {
|
const newMsg: AgentMessage = { id: messages.length + 1, role: 'user', content: text };
|
||||||
id: messages.length + 1,
|
|
||||||
role: 'user',
|
|
||||||
content: text,
|
|
||||||
};
|
|
||||||
messages = [...messages, newMsg];
|
messages = [...messages, newMsg];
|
||||||
// Simulate assistant echo (demo only)
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
messages = [...messages, {
|
messages = [...messages, {
|
||||||
id: messages.length + 1,
|
id: messages.length + 1,
|
||||||
|
|
@ -96,15 +94,12 @@
|
||||||
<span class="project-name" title={name}>{name}</span>
|
<span class="project-name" title={name}>{name}</span>
|
||||||
<span class="project-cwd" title={cwd}>{cwd}</span>
|
<span class="project-cwd" title={cwd}>{cwd}</span>
|
||||||
|
|
||||||
<!-- Provider badge -->
|
|
||||||
<span class="provider-badge" title="Provider: {provider}">{provider}</span>
|
<span class="provider-badge" title="Provider: {provider}">{provider}</span>
|
||||||
|
|
||||||
<!-- Profile (if set) -->
|
|
||||||
{#if profile}
|
{#if profile}
|
||||||
<span class="profile-badge" title="Profile: {profile}">{profile}</span>
|
<span class="profile-badge" title="Profile: {profile}">{profile}</span>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<!-- Context pressure badge -->
|
|
||||||
{#if contextPct > 50}
|
{#if contextPct > 50}
|
||||||
<span
|
<span
|
||||||
class="ctx-badge"
|
class="ctx-badge"
|
||||||
|
|
@ -114,7 +109,6 @@
|
||||||
>{contextPct}%</span>
|
>{contextPct}%</span>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<!-- Burn rate -->
|
|
||||||
{#if burnRate > 0}
|
{#if burnRate > 0}
|
||||||
<span class="burn-badge" title="Burn rate">${burnRate.toFixed(2)}/hr</span>
|
<span class="burn-badge" title="Burn rate">${burnRate.toFixed(2)}/hr</span>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
@ -122,7 +116,7 @@
|
||||||
|
|
||||||
<!-- Project tab bar -->
|
<!-- Project tab bar -->
|
||||||
<div class="tab-bar" role="tablist" aria-label="{name} tabs">
|
<div class="tab-bar" role="tablist" aria-label="{name} tabs">
|
||||||
{#each (['model', 'docs', 'files'] as ProjectTab[]) as tab}
|
{#each ALL_TABS as tab}
|
||||||
<button
|
<button
|
||||||
class="tab-btn"
|
class="tab-btn"
|
||||||
class:active={activeTab === tab}
|
class:active={activeTab === tab}
|
||||||
|
|
@ -138,7 +132,7 @@
|
||||||
|
|
||||||
<!-- Tab content: display:flex/none to keep agent session mounted -->
|
<!-- Tab content: display:flex/none to keep agent session mounted -->
|
||||||
<div class="tab-content">
|
<div class="tab-content">
|
||||||
<!-- Model tab: agent pane + terminal tabs -->
|
<!-- Model tab: agent pane + terminal tabs — always mounted -->
|
||||||
<div
|
<div
|
||||||
id="tabpanel-{id}-model"
|
id="tabpanel-{id}-model"
|
||||||
class="tab-pane"
|
class="tab-pane"
|
||||||
|
|
@ -174,6 +168,49 @@
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/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 -->
|
<!-- Files tab -->
|
||||||
{#if activatedTabs.has('files')}
|
{#if activatedTabs.has('files')}
|
||||||
<div
|
<div
|
||||||
|
|
@ -183,7 +220,33 @@
|
||||||
role="tabpanel"
|
role="tabpanel"
|
||||||
aria-label="Files"
|
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>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -296,8 +359,8 @@
|
||||||
color: var(--ctp-yellow);
|
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-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-danger { color: var(--ctp-red); background: color-mix(in srgb, var(--ctp-red) 15%, transparent); }
|
||||||
|
|
||||||
.burn-badge {
|
.burn-badge {
|
||||||
background: color-mix(in srgb, var(--ctp-peach) 10%, transparent);
|
background: color-mix(in srgb, var(--ctp-peach) 10%, transparent);
|
||||||
|
|
@ -314,10 +377,14 @@
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
padding: 0 0.25rem;
|
padding: 0 0.25rem;
|
||||||
gap: 0.125rem;
|
gap: 0.125rem;
|
||||||
|
overflow-x: auto;
|
||||||
|
scrollbar-width: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.tab-bar::-webkit-scrollbar { display: none; }
|
||||||
|
|
||||||
.tab-btn {
|
.tab-btn {
|
||||||
padding: 0 0.75rem;
|
padding: 0 0.625rem;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
border: none;
|
border: none;
|
||||||
border-bottom: 2px solid transparent;
|
border-bottom: 2px solid transparent;
|
||||||
|
|
@ -328,6 +395,7 @@
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
transition: color 0.12s, border-color 0.12s;
|
transition: color 0.12s, border-color 0.12s;
|
||||||
margin-bottom: -1px;
|
margin-bottom: -1px;
|
||||||
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tab-btn:hover { color: var(--ctp-text); }
|
.tab-btn:hover { color: var(--ctp-text); }
|
||||||
|
|
@ -361,4 +429,101 @@
|
||||||
font-size: 0.8125rem;
|
font-size: 0.8125rem;
|
||||||
font-style: italic;
|
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>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,21 @@
|
||||||
let { open, onClose }: Props = $props();
|
let { open, onClose }: Props = $props();
|
||||||
|
|
||||||
// Local settings state (demo — no persistence in prototype)
|
// 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 uiFontSize = $state(14);
|
||||||
let termFontSize = $state(13);
|
let termFontSize = $state(13);
|
||||||
|
|
||||||
|
|
@ -60,8 +74,36 @@
|
||||||
<h3 class="section-heading">Appearance</h3>
|
<h3 class="section-heading">Appearance</h3>
|
||||||
|
|
||||||
<div class="setting-row">
|
<div class="setting-row">
|
||||||
<label class="setting-label" for="theme-select">Theme</label>
|
<label class="setting-label" for="theme-dropdown-btn">Theme</label>
|
||||||
<div class="setting-value theme-pill">{theme}</div>
|
<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>
|
||||||
|
|
||||||
<div class="setting-row">
|
<div class="setting-row">
|
||||||
|
|
@ -232,17 +274,76 @@
|
||||||
color: var(--ctp-subtext1);
|
color: var(--ctp-subtext1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.setting-value {
|
/* Theme dropdown */
|
||||||
font-size: 0.8125rem;
|
.theme-dropdown {
|
||||||
color: var(--ctp-text);
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
.theme-pill {
|
.theme-dropdown-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.375rem;
|
||||||
background: var(--ctp-surface0);
|
background: var(--ctp-surface0);
|
||||||
padding: 0.2rem 0.5rem;
|
border: 1px solid var(--ctp-surface1);
|
||||||
border-radius: 0.3rem;
|
border-radius: 0.3rem;
|
||||||
font-size: 0.75rem;
|
padding: 0.2rem 0.5rem;
|
||||||
color: var(--ctp-mauve);
|
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 */
|
/* Font stepper */
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,13 @@
|
||||||
let term: Terminal;
|
let term: Terminal;
|
||||||
let fitAddon: FitAddon;
|
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(() => {
|
onMount(() => {
|
||||||
term = new Terminal({
|
term = new Terminal({
|
||||||
theme: THEME,
|
theme: THEME,
|
||||||
|
|
@ -67,9 +74,43 @@
|
||||||
term.writeln(' \x1b[1;32mRunning\x1b[0m tests/unit.rs');
|
term.writeln(' \x1b[1;32mRunning\x1b[0m tests/unit.rs');
|
||||||
term.writeln('test result: ok. \x1b[32m47 passed\x1b[0m; 0 failed');
|
term.writeln('test result: ok. \x1b[32m47 passed\x1b[0m; 0 failed');
|
||||||
term.writeln('');
|
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());
|
const ro = new ResizeObserver(() => fitAddon.fit());
|
||||||
ro.observe(termEl);
|
ro.observe(termEl);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -38,11 +38,9 @@
|
||||||
const idx = tabs.findIndex(t => t.id === id);
|
const idx = tabs.findIndex(t => t.id === id);
|
||||||
tabs = tabs.filter(t => t.id !== id);
|
tabs = tabs.filter(t => t.id !== id);
|
||||||
if (activeTabId === id) {
|
if (activeTabId === id) {
|
||||||
// activate neighbor
|
|
||||||
const next = tabs[Math.min(idx, tabs.length - 1)];
|
const next = tabs[Math.min(idx, tabs.length - 1)];
|
||||||
activeTabId = next?.id ?? '';
|
activeTabId = next?.id ?? '';
|
||||||
}
|
}
|
||||||
// Remove from mounted to free resources
|
|
||||||
const m = new Set(mounted);
|
const m = new Set(mounted);
|
||||||
m.delete(id);
|
m.delete(id);
|
||||||
mounted = m;
|
mounted = m;
|
||||||
|
|
@ -50,7 +48,6 @@
|
||||||
|
|
||||||
function activateTab(id: string) {
|
function activateTab(id: string) {
|
||||||
activeTabId = id;
|
activeTabId = id;
|
||||||
// Mount on first activation
|
|
||||||
if (!mounted.has(id)) {
|
if (!mounted.has(id)) {
|
||||||
mounted = new Set([...mounted, id]);
|
mounted = new Set([...mounted, id]);
|
||||||
}
|
}
|
||||||
|
|
@ -120,23 +117,24 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Terminal panes: display:flex/none to preserve xterm state -->
|
<!--
|
||||||
{#if !collapsed}
|
Terminal panes: always rendered (display:none when collapsed) so xterm state
|
||||||
<div class="term-panes">
|
is preserved across collapse/expand. Using display:none instead of {#if}.
|
||||||
{#each tabs as tab (tab.id)}
|
-->
|
||||||
{#if mounted.has(tab.id)}
|
<div class="term-panes" style:display={collapsed ? 'none' : 'block'}>
|
||||||
<div
|
{#each tabs as tab (tab.id)}
|
||||||
class="term-pane"
|
{#if mounted.has(tab.id)}
|
||||||
style:display={activeTabId === tab.id ? 'flex' : 'none'}
|
<div
|
||||||
role="tabpanel"
|
class="term-pane"
|
||||||
aria-label={tab.title}
|
style:display={activeTabId === tab.id ? 'flex' : 'none'}
|
||||||
>
|
role="tabpanel"
|
||||||
<Terminal />
|
aria-label={tab.title}
|
||||||
</div>
|
>
|
||||||
{/if}
|
<Terminal />
|
||||||
{/each}
|
</div>
|
||||||
</div>
|
{/if}
|
||||||
{/if}
|
{/each}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>
|
<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