feat(v2): add SSH management, ctx integration, themes, detached mode, auto-updater

SSH session management:
- SshSession struct + ssh_sessions SQLite table in session.rs
- CRUD Tauri commands (ssh_session_list/save/delete) in lib.rs
- SshDialog.svelte (create/edit modal), SshSessionList.svelte (sidebar)
- SSH pane routes to TerminalPane with shell=/usr/bin/ssh + args

ctx context database integration:
- ctx.rs: read-only CtxDb (SQLITE_OPEN_READ_ONLY for ~/.claude-context/context.db)
- 5 Tauri commands (ctx_list_projects/get_context/get_shared/get_summaries/search)
- ContextPane.svelte with project selector, tabs, search
- ctx-bridge.ts adapter

Catppuccin theme flavors (Latte/Frappe/Macchiato/Mocha):
- themes.ts: all 4 palette definitions + buildXtermTheme/applyCssVariables
- theme.svelte.ts: reactive store with SQLite persistence
- SettingsDialog flavor dropdown, TerminalPane theme-aware

Detached pane mode (pop-out windows):
- detach.ts: isDetachedMode/getDetachedConfig from URL params
- App.svelte: conditional rendering of single pane without chrome

Other additions:
- Shiki syntax highlighting (highlight.ts, lazy singleton, 13 languages)
- Tauri auto-updater plugin (tauri-plugin-updater + updater.ts)
- AgentPane markdown rendering with Shiki code highlighting
- New deps: shiki, @tauri-apps/plugin-updater, tauri-plugin-updater
This commit is contained in:
Hibryda 2026-03-06 14:50:00 +01:00
parent 4f2614186d
commit 4db7ccff60
28 changed files with 2992 additions and 51 deletions

View file

@ -6,8 +6,14 @@
import ToastContainer from './lib/components/Notifications/ToastContainer.svelte';
import SettingsDialog from './lib/components/Settings/SettingsDialog.svelte';
import { addPane, focusPaneByIndex, removePane, getPanes, restoreFromDb } from './lib/stores/layout.svelte';
import { initTheme } from './lib/stores/theme.svelte';
import { isDetachedMode, getDetachedConfig } from './lib/utils/detach';
import TerminalPane from './lib/components/Terminal/TerminalPane.svelte';
import AgentPane from './lib/components/Agent/AgentPane.svelte';
let settingsOpen = $state(false);
let detached = isDetachedMode();
let detachedConfig = getDetachedConfig();
import { startAgentDispatcher, stopAgentDispatcher } from './lib/agent-dispatcher';
function newTerminal() {
@ -31,8 +37,9 @@
}
onMount(() => {
initTheme();
startAgentDispatcher();
restoreFromDb();
if (!detached) restoreFromDb();
function handleKeydown(e: KeyboardEvent) {
// Ctrl+N — new terminal
@ -80,17 +87,42 @@
});
</script>
<aside class="sidebar">
<SessionList />
</aside>
<main class="workspace">
<TilingGrid />
</main>
<StatusBar />
{#if detached && detachedConfig}
<div class="detached-pane">
{#if detachedConfig.type === 'terminal' || detachedConfig.type === 'ssh'}
<TerminalPane
shell={detachedConfig.shell}
cwd={detachedConfig.cwd}
args={detachedConfig.args}
/>
{:else if detachedConfig.type === 'agent'}
<AgentPane
sessionId={detachedConfig.sessionId ?? crypto.randomUUID()}
cwd={detachedConfig.cwd}
/>
{:else}
<TerminalPane />
{/if}
</div>
{:else}
<aside class="sidebar">
<SessionList />
</aside>
<main class="workspace">
<TilingGrid />
</main>
<StatusBar />
<SettingsDialog open={settingsOpen} onClose={() => settingsOpen = false} />
{/if}
<ToastContainer />
<SettingsDialog open={settingsOpen} onClose={() => settingsOpen = false} />
<style>
.detached-pane {
height: 100vh;
width: 100vw;
background: var(--bg-primary);
}
.sidebar {
background: var(--bg-secondary);
border-right: 1px solid var(--border);

View file

@ -0,0 +1,41 @@
import { invoke } from '@tauri-apps/api/core';
export interface CtxProject {
name: string;
description: string;
work_dir: string | null;
created_at: string;
}
export interface CtxEntry {
project: string;
key: string;
value: string;
updated_at: string;
}
export interface CtxSummary {
project: string;
summary: string;
created_at: string;
}
export async function ctxListProjects(): Promise<CtxProject[]> {
return invoke('ctx_list_projects');
}
export async function ctxGetContext(project: string): Promise<CtxEntry[]> {
return invoke('ctx_get_context', { project });
}
export async function ctxGetShared(): Promise<CtxEntry[]> {
return invoke('ctx_get_shared');
}
export async function ctxGetSummaries(project: string, limit: number = 5): Promise<CtxSummary[]> {
return invoke('ctx_get_summaries', { project, limit });
}
export async function ctxSearch(query: string): Promise<CtxEntry[]> {
return invoke('ctx_search', { query });
}

View file

@ -0,0 +1,26 @@
import { invoke } from '@tauri-apps/api/core';
export interface SshSession {
id: string;
name: string;
host: string;
port: number;
username: string;
key_file: string;
folder: string;
color: string;
created_at: number;
last_used_at: number;
}
export async function listSshSessions(): Promise<SshSession[]> {
return invoke('ssh_session_list');
}
export async function saveSshSession(session: SshSession): Promise<void> {
return invoke('ssh_session_save', { session });
}
export async function deleteSshSession(id: string): Promise<void> {
return invoke('ssh_session_delete', { id });
}

View file

@ -1,7 +1,7 @@
// Agent Dispatcher — connects sidecar bridge events to agent store
// Single listener that routes sidecar messages to the correct agent session
import { onSidecarMessage, onSidecarExited, type SidecarMessage } from './adapters/agent-bridge';
import { onSidecarMessage, onSidecarExited, restartAgent, type SidecarMessage } from './adapters/agent-bridge';
import { adaptSDKMessage } from './adapters/sdk-messages';
import type { InitContent, CostContent } from './adapters/sdk-messages';
import {
@ -19,6 +19,10 @@ let unlistenExit: (() => void) | null = null;
// Sidecar liveness — checked by UI components
let sidecarAlive = true;
// Sidecar crash recovery state
const MAX_RESTART_ATTEMPTS = 3;
let restartAttempts = 0;
export function isSidecarAlive(): boolean {
return sidecarAlive;
}
@ -33,6 +37,11 @@ export async function startAgentDispatcher(): Promise<void> {
unlistenMsg = await onSidecarMessage((msg: SidecarMessage) => {
sidecarAlive = true;
// Reset restart counter on any successful message — sidecar recovered
if (restartAttempts > 0) {
notify('success', 'Sidecar recovered');
restartAttempts = 0;
}
const sessionId = msg.sessionId;
if (!sessionId) return;
@ -61,15 +70,33 @@ export async function startAgentDispatcher(): Promise<void> {
}
});
unlistenExit = await onSidecarExited(() => {
unlistenExit = await onSidecarExited(async () => {
sidecarAlive = false;
notify('error', 'Sidecar process crashed — agent features unavailable');
// Mark all running sessions as errored
for (const session of getAgentSessions()) {
if (session.status === 'running' || session.status === 'starting') {
updateAgentStatus(session.id, 'error', 'Sidecar crashed');
}
}
// Attempt auto-restart with exponential backoff
if (restartAttempts < MAX_RESTART_ATTEMPTS) {
restartAttempts++;
const delayMs = 1000 * Math.pow(2, restartAttempts - 1); // 1s, 2s, 4s
notify('warning', `Sidecar crashed, restarting (attempt ${restartAttempts}/${MAX_RESTART_ATTEMPTS})...`);
await new Promise((resolve) => setTimeout(resolve, delayMs));
try {
await restartAgent();
sidecarAlive = true;
// Note: restartAttempts is reset when next sidecar message arrives
} catch {
if (restartAttempts >= MAX_RESTART_ATTEMPTS) {
notify('error', `Sidecar restart failed after ${MAX_RESTART_ATTEMPTS} attempts`);
}
}
} else {
notify('error', `Sidecar restart failed after ${MAX_RESTART_ATTEMPTS} attempts`);
}
});
}

View file

@ -1,5 +1,6 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { marked, Renderer } from 'marked';
import { queryAgent, stopAgent, isAgentReady, restartAgent } from '../../adapters/agent-bridge';
import {
getAgentSession,
@ -9,6 +10,7 @@
} from '../../stores/agents.svelte';
import { isSidecarAlive, setSidecarAlive } from '../../agent-dispatcher';
import AgentTree from './AgentTree.svelte';
import { getHighlighter, highlightCode, escapeHtml } from '../../utils/highlight';
import type {
AgentMessage,
TextContent,
@ -36,7 +38,25 @@
let showTree = $state(false);
let hasToolCalls = $derived(session?.messages.some(m => m.type === 'tool_call') ?? false);
const mdRenderer = new Renderer();
mdRenderer.code = function({ text, lang }: { text: string; lang?: string }) {
if (lang) {
const highlighted = highlightCode(text, lang);
if (highlighted !== escapeHtml(text)) return highlighted;
}
return `<pre><code>${escapeHtml(text)}</code></pre>`;
};
function renderMarkdown(source: string): string {
try {
return marked.parse(source, { renderer: mdRenderer, async: false }) as string;
} catch {
return escapeHtml(source);
}
}
onMount(async () => {
await getHighlighter();
if (initialPrompt) {
await startQuery(initialPrompt);
}
@ -164,7 +184,7 @@
<span class="model">{(msg.content as import('../../adapters/sdk-messages').InitContent).model}</span>
</div>
{:else if msg.type === 'text'}
<div class="msg-text">{(msg.content as TextContent).text}</div>
<div class="msg-text markdown-body">{@html renderMarkdown((msg.content as TextContent).text)}</div>
{:else if msg.type === 'thinking'}
<details class="msg-thinking">
<summary>Thinking...</summary>
@ -217,6 +237,9 @@
<span class="cost">${session.costUsd.toFixed(4)}</span>
<span class="tokens">{session.inputTokens + session.outputTokens} tokens</span>
<span class="duration">{(session.durationMs / 1000).toFixed(1)}s</span>
{#if !autoScroll}
<button class="scroll-btn" onclick={() => { autoScroll = true; scrollToBottom(); }}>Scroll to bottom</button>
{/if}
</div>
{:else if session.status === 'error'}
<div class="error-bar">
@ -334,11 +357,118 @@
}
.msg-text {
white-space: pre-wrap;
word-break: break-word;
line-height: 1.5;
}
.msg-text.markdown-body :global(h1) {
font-size: 1.4em;
font-weight: 700;
margin: 0.6em 0 0.3em;
color: var(--ctp-lavender);
}
.msg-text.markdown-body :global(h2) {
font-size: 1.2em;
font-weight: 600;
margin: 0.5em 0 0.3em;
color: var(--ctp-blue);
}
.msg-text.markdown-body :global(h3) {
font-size: 1.05em;
font-weight: 600;
margin: 0.4em 0 0.2em;
color: var(--ctp-sapphire);
}
.msg-text.markdown-body :global(p) {
margin: 0.4em 0;
}
.msg-text.markdown-body :global(code) {
background: var(--bg-surface);
padding: 1px 5px;
border-radius: 3px;
font-family: var(--font-mono);
font-size: 0.9em;
color: var(--ctp-green);
}
.msg-text.markdown-body :global(pre) {
background: var(--bg-surface);
padding: 10px 12px;
border-radius: var(--border-radius);
overflow-x: auto;
font-size: 12px;
line-height: 1.5;
margin: 0.5em 0;
}
.msg-text.markdown-body :global(pre code) {
background: none;
padding: 0;
color: var(--text-primary);
}
.msg-text.markdown-body :global(.shiki) {
background: var(--bg-surface) !important;
padding: 10px 12px;
border-radius: var(--border-radius);
overflow-x: auto;
font-size: 12px;
line-height: 1.5;
margin: 0.5em 0;
}
.msg-text.markdown-body :global(.shiki code) {
background: none !important;
padding: 0;
}
.msg-text.markdown-body :global(blockquote) {
border-left: 3px solid var(--ctp-mauve);
margin: 0.4em 0;
padding: 2px 10px;
color: var(--text-secondary);
}
.msg-text.markdown-body :global(ul), .msg-text.markdown-body :global(ol) {
padding-left: 20px;
margin: 0.3em 0;
}
.msg-text.markdown-body :global(li) {
margin: 0.15em 0;
}
.msg-text.markdown-body :global(a) {
color: var(--ctp-blue);
text-decoration: none;
}
.msg-text.markdown-body :global(a:hover) {
text-decoration: underline;
}
.msg-text.markdown-body :global(table) {
border-collapse: collapse;
width: 100%;
margin: 0.4em 0;
font-size: 12px;
}
.msg-text.markdown-body :global(th), .msg-text.markdown-body :global(td) {
border: 1px solid var(--border);
padding: 4px 8px;
text-align: left;
}
.msg-text.markdown-body :global(th) {
background: var(--bg-surface);
font-weight: 600;
}
.msg-thinking {
color: var(--ctp-overlay1);
font-size: 12px;

View file

@ -0,0 +1,340 @@
<script lang="ts">
import { onMount } from 'svelte';
import {
ctxListProjects,
ctxGetContext,
ctxGetShared,
ctxGetSummaries,
ctxSearch,
type CtxProject,
type CtxEntry,
type CtxSummary,
} from '../../adapters/ctx-bridge';
interface Props {
onExit?: () => void;
}
let { onExit }: Props = $props();
let projects = $state<CtxProject[]>([]);
let selectedProject = $state<string | null>(null);
let entries = $state<CtxEntry[]>([]);
let sharedEntries = $state<CtxEntry[]>([]);
let summaries = $state<CtxSummary[]>([]);
let searchQuery = $state('');
let searchResults = $state<CtxEntry[]>([]);
let error = $state('');
let loading = $state(false);
onMount(async () => {
try {
projects = await ctxListProjects();
sharedEntries = await ctxGetShared();
} catch (e) {
error = `ctx database not available: ${e}`;
}
});
async function selectProject(name: string) {
selectedProject = name;
loading = true;
try {
[entries, summaries] = await Promise.all([
ctxGetContext(name),
ctxGetSummaries(name, 5),
]);
error = '';
} catch (e) {
error = `Failed to load context: ${e}`;
} finally {
loading = false;
}
}
async function handleSearch() {
if (!searchQuery.trim()) {
searchResults = [];
return;
}
try {
searchResults = await ctxSearch(searchQuery);
} catch (e) {
error = `Search failed: ${e}`;
}
}
</script>
<div class="context-pane">
<div class="ctx-header">
<h3>Context Manager</h3>
<input
type="text"
class="search-input"
placeholder="Search contexts..."
bind:value={searchQuery}
onkeydown={(e) => { if (e.key === 'Enter') handleSearch(); }}
/>
</div>
{#if error}
<div class="ctx-error">{error}</div>
{/if}
<div class="ctx-body">
{#if searchResults.length > 0}
<div class="section">
<h4>Search Results</h4>
{#each searchResults as result}
<div class="entry">
<div class="entry-header">
<span class="entry-project">{result.project}</span>
<span class="entry-key">{result.key}</span>
</div>
<pre class="entry-value">{result.value}</pre>
</div>
{/each}
<button class="clear-btn" onclick={() => { searchResults = []; searchQuery = ''; }}>Clear search</button>
</div>
{:else}
<div class="project-list">
<h4>Projects</h4>
{#if projects.length === 0}
<p class="empty">No projects registered. Use <code>ctx init</code> to add one.</p>
{/if}
{#each projects as project}
<button
class="project-btn"
class:active={selectedProject === project.name}
onclick={() => selectProject(project.name)}
>
<span class="project-name">{project.name}</span>
<span class="project-desc">{project.description}</span>
</button>
{/each}
</div>
{#if sharedEntries.length > 0}
<div class="section">
<h4>Shared Context</h4>
{#each sharedEntries as entry}
<div class="entry">
<div class="entry-header">
<span class="entry-key">{entry.key}</span>
</div>
<pre class="entry-value">{entry.value}</pre>
</div>
{/each}
</div>
{/if}
{#if selectedProject && !loading}
<div class="section">
<h4>{selectedProject} Context</h4>
{#if entries.length === 0}
<p class="empty">No context entries for this project.</p>
{/if}
{#each entries as entry}
<div class="entry">
<div class="entry-header">
<span class="entry-key">{entry.key}</span>
<span class="entry-date">{entry.updated_at}</span>
</div>
<pre class="entry-value">{entry.value}</pre>
</div>
{/each}
</div>
{#if summaries.length > 0}
<div class="section">
<h4>Recent Sessions</h4>
{#each summaries as summary}
<div class="entry">
<div class="entry-header">
<span class="entry-date">{summary.created_at}</span>
</div>
<pre class="entry-value">{summary.summary}</pre>
</div>
{/each}
</div>
{/if}
{/if}
{#if loading}
<div class="loading">Loading...</div>
{/if}
{/if}
</div>
</div>
<style>
.context-pane {
display: flex;
flex-direction: column;
height: 100%;
background: var(--bg-primary);
color: var(--text-primary);
font-size: 13px;
}
.ctx-header {
padding: 8px 12px;
border-bottom: 1px solid var(--border);
display: flex;
align-items: center;
gap: 12px;
flex-shrink: 0;
}
.ctx-header h3 {
font-size: 13px;
font-weight: 600;
white-space: nowrap;
}
.search-input {
flex: 1;
background: var(--bg-surface);
border: 1px solid var(--border);
border-radius: var(--border-radius);
color: var(--text-primary);
font-family: var(--font-mono);
font-size: 11px;
padding: 4px 8px;
}
.search-input:focus {
outline: none;
border-color: var(--accent);
}
.ctx-error {
color: var(--ctp-red);
padding: 8px 12px;
font-size: 12px;
}
.ctx-body {
flex: 1;
overflow-y: auto;
padding: 8px 12px;
}
.project-list {
margin-bottom: 12px;
}
h4 {
font-size: 11px;
font-weight: 600;
color: var(--ctp-mauve);
text-transform: uppercase;
letter-spacing: 0.5px;
margin-bottom: 6px;
}
.project-btn {
display: flex;
flex-direction: column;
width: 100%;
background: var(--bg-surface);
border: 1px solid var(--border);
border-radius: var(--border-radius);
padding: 6px 8px;
margin-bottom: 4px;
cursor: pointer;
text-align: left;
color: var(--text-primary);
}
.project-btn:hover { border-color: var(--accent); }
.project-btn.active {
border-color: var(--accent);
background: color-mix(in srgb, var(--accent) 10%, var(--bg-surface));
}
.project-name {
font-weight: 600;
font-size: 12px;
}
.project-desc {
font-size: 10px;
color: var(--text-muted);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.section {
margin-bottom: 16px;
}
.entry {
background: var(--bg-surface);
border-radius: var(--border-radius);
padding: 6px 8px;
margin-bottom: 4px;
}
.entry-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 4px;
}
.entry-project {
font-size: 10px;
color: var(--ctp-blue);
font-weight: 600;
}
.entry-key {
font-size: 11px;
font-weight: 600;
color: var(--ctp-green);
}
.entry-date {
font-size: 9px;
color: var(--text-muted);
margin-left: auto;
}
.entry-value {
font-size: 11px;
white-space: pre-wrap;
word-break: break-word;
color: var(--text-secondary);
max-height: 200px;
overflow-y: auto;
margin: 0;
}
.empty {
color: var(--text-muted);
font-size: 11px;
font-style: italic;
}
.clear-btn {
background: var(--bg-surface);
border: 1px solid var(--border);
color: var(--text-secondary);
border-radius: var(--border-radius);
padding: 4px 10px;
font-size: 11px;
cursor: pointer;
margin-top: 4px;
}
.clear-btn:hover { color: var(--text-primary); }
.loading {
color: var(--text-muted);
font-size: 12px;
text-align: center;
padding: 16px;
}
</style>

View file

@ -5,10 +5,11 @@
title: string;
status?: 'idle' | 'running' | 'error' | 'done';
onClose?: () => void;
onDetach?: () => void;
children: Snippet;
}
let { title, status = 'idle', onClose, children }: Props = $props();
let { title, status = 'idle', onClose, onDetach, children }: Props = $props();
</script>
<div class="pane-container">
@ -18,6 +19,9 @@
{#if status !== 'idle'}
<span class="status {status}">{status}</span>
{/if}
{#if onDetach}
<button class="detach-btn" onclick={onDetach} title="Pop out to new window">&#8599;</button>
{/if}
{#if onClose}
<button class="close-btn" onclick={onClose} title="Close pane">&times;</button>
{/if}
@ -75,6 +79,18 @@
.status.error { color: var(--ctp-red); }
.status.done { color: var(--ctp-green); }
.detach-btn {
background: none;
border: none;
color: var(--text-muted);
font-size: 12px;
cursor: pointer;
padding: 0 2px;
line-height: 1;
}
.detach-btn:hover { color: var(--ctp-blue); }
.close-btn {
background: none;
border: none;

View file

@ -3,6 +3,7 @@
import TerminalPane from '../Terminal/TerminalPane.svelte';
import AgentPane from '../Agent/AgentPane.svelte';
import MarkdownPane from '../Markdown/MarkdownPane.svelte';
import ContextPane from '../Context/ContextPane.svelte';
import {
getPanes,
getGridTemplate,
@ -10,9 +11,17 @@
focusPane,
removePane,
} from '../../stores/layout.svelte';
import { detachPane } from '../../utils/detach';
import { isDetachedMode } from '../../utils/detach';
let gridTemplate = $derived(getGridTemplate());
let panes = $derived(getPanes());
let detached = isDetachedMode();
function handleDetach(pane: typeof panes[0]) {
detachPane(pane);
removePane(pane.id);
}
</script>
<div
@ -41,6 +50,7 @@
title={pane.title}
status={pane.focused ? 'running' : 'idle'}
onClose={() => removePane(pane.id)}
onDetach={detached ? undefined : () => handleDetach(pane)}
>
{#if pane.type === 'terminal'}
<TerminalPane
@ -55,6 +65,15 @@
cwd={pane.cwd}
onExit={() => removePane(pane.id)}
/>
{:else if pane.type === 'ssh'}
<TerminalPane
shell={pane.shell}
cwd={pane.cwd}
args={pane.args}
onExit={() => removePane(pane.id)}
/>
{:else if pane.type === 'context'}
<ContextPane onExit={() => removePane(pane.id)} />
{:else if pane.type === 'markdown'}
<MarkdownPane
paneId={pane.id}

View file

@ -1,7 +1,8 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { marked } from 'marked';
import { marked, Renderer } from 'marked';
import { watchFile, unwatchFile, onFileChanged, type FileChangedPayload } from '../../adapters/file-bridge';
import { getHighlighter, highlightCode, escapeHtml } from '../../utils/highlight';
interface Props {
filePath: string;
@ -15,9 +16,18 @@
let error = $state('');
let unlisten: (() => void) | undefined;
const renderer = new Renderer();
renderer.code = function({ text, lang }: { text: string; lang?: string }) {
if (lang) {
const highlighted = highlightCode(text, lang);
if (highlighted !== escapeHtml(text)) return highlighted;
}
return `<pre><code>${escapeHtml(text)}</code></pre>`;
};
function renderMarkdown(source: string): void {
try {
renderedHtml = marked.parse(source, { async: false }) as string;
renderedHtml = marked.parse(source, { renderer, async: false }) as string;
error = '';
} catch (e) {
error = `Render error: ${e}`;
@ -26,6 +36,7 @@
onMount(async () => {
try {
await getHighlighter();
const content = await watchFile(paneId, filePath);
renderMarkdown(content);
@ -125,6 +136,21 @@
color: var(--text-primary);
}
.markdown-body :global(.shiki) {
background: var(--bg-surface) !important;
padding: 12px 14px;
border-radius: var(--border-radius);
overflow-x: auto;
font-size: 12px;
line-height: 1.5;
margin: 0.6em 0;
}
.markdown-body :global(.shiki code) {
background: none !important;
padding: 0;
}
.markdown-body :global(blockquote) {
border-left: 3px solid var(--ctp-mauve);
margin: 0.5em 0;

View file

@ -0,0 +1,281 @@
<script lang="ts">
import { notify } from '../../stores/notifications.svelte';
import { saveSshSession, type SshSession } from '../../adapters/ssh-bridge';
interface Props {
open: boolean;
editSession?: SshSession;
onClose: () => void;
onSaved: () => void;
}
let { open, editSession, onClose, onSaved }: Props = $props();
let name = $state('');
let host = $state('');
let port = $state(22);
let username = $state('');
let keyFile = $state('');
let folder = $state('');
let validationError = $state('');
$effect(() => {
if (open && editSession) {
name = editSession.name;
host = editSession.host;
port = editSession.port;
username = editSession.username;
keyFile = editSession.key_file;
folder = editSession.folder;
} else if (open) {
name = '';
host = '';
port = 22;
username = '';
keyFile = '';
folder = '';
}
validationError = '';
});
function validate(): boolean {
if (!name.trim()) {
validationError = 'Name is required';
return false;
}
if (!host.trim()) {
validationError = 'Host is required';
return false;
}
if (!username.trim()) {
validationError = 'Username is required';
return false;
}
if (port < 1 || port > 65535) {
validationError = 'Port must be between 1 and 65535';
return false;
}
validationError = '';
return true;
}
async function handleSave() {
if (!validate()) return;
const now = Math.floor(Date.now() / 1000);
const session: SshSession = {
id: editSession?.id ?? crypto.randomUUID(),
name: name.trim(),
host: host.trim(),
port,
username: username.trim(),
key_file: keyFile.trim(),
folder: folder.trim(),
color: editSession?.color ?? '#89b4fa',
created_at: editSession?.created_at ?? now,
last_used_at: now,
};
try {
await saveSshSession(session);
notify('success', `SSH session "${session.name}" saved`);
onSaved();
onClose();
} catch (e) {
notify('error', `Failed to save SSH session: ${e}`);
}
}
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') onClose();
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) handleSave();
}
</script>
{#if open}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="overlay" onkeydown={handleKeydown}>
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div class="backdrop" onclick={onClose}></div>
<div class="dialog" role="dialog" aria-label="SSH Session">
<div class="dialog-header">
<h2>{editSession ? 'Edit' : 'New'} SSH Session</h2>
<button class="close-btn" onclick={onClose}>&times;</button>
</div>
<div class="dialog-body">
{#if validationError}
<div class="validation-error">{validationError}</div>
{/if}
<label class="field">
<span class="field-label">Name</span>
<input type="text" bind:value={name} placeholder="My Server" />
</label>
<label class="field">
<span class="field-label">Host</span>
<input type="text" bind:value={host} placeholder="192.168.1.100 or server.example.com" />
</label>
<div class="field-row">
<label class="field" style="flex: 1;">
<span class="field-label">Username</span>
<input type="text" bind:value={username} placeholder="root" />
</label>
<label class="field" style="width: 100px;">
<span class="field-label">Port</span>
<input type="number" bind:value={port} min="1" max="65535" />
</label>
</div>
<label class="field">
<span class="field-label">SSH Key (optional)</span>
<input type="text" bind:value={keyFile} placeholder="~/.ssh/id_ed25519" />
<span class="field-hint">Leave empty to use default key or password auth</span>
</label>
<label class="field">
<span class="field-label">Folder (optional)</span>
<input type="text" bind:value={folder} placeholder="Group name for organizing" />
<span class="field-hint">Sessions with the same folder are grouped together</span>
</label>
</div>
<div class="dialog-footer">
<button class="btn-cancel" onclick={onClose}>Cancel</button>
<button class="btn-save" onclick={handleSave}>Save</button>
</div>
</div>
</div>
{/if}
<style>
.overlay {
position: fixed;
inset: 0;
z-index: 9000;
display: flex;
align-items: center;
justify-content: center;
}
.backdrop {
position: absolute;
inset: 0;
background: rgba(0, 0, 0, 0.5);
}
.dialog {
position: relative;
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: 8px;
width: 420px;
max-height: 80vh;
overflow-y: auto;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
}
.dialog-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
border-bottom: 1px solid var(--border);
}
.dialog-header h2 {
font-size: 14px;
font-weight: 600;
color: var(--text-primary);
}
.close-btn {
background: none;
border: none;
color: var(--text-muted);
font-size: 18px;
cursor: pointer;
}
.close-btn:hover { color: var(--text-primary); }
.dialog-body {
padding: 16px;
display: flex;
flex-direction: column;
gap: 14px;
}
.validation-error {
background: rgba(243, 139, 168, 0.1);
border: 1px solid var(--ctp-red);
border-radius: var(--border-radius);
color: var(--ctp-red);
font-size: 11px;
padding: 6px 10px;
}
.field {
display: flex;
flex-direction: column;
gap: 4px;
}
.field-row {
display: flex;
gap: 10px;
}
.field-label {
font-size: 12px;
font-weight: 600;
color: var(--text-secondary);
}
.field input {
background: var(--bg-surface);
border: 1px solid var(--border);
border-radius: var(--border-radius);
color: var(--text-primary);
font-family: var(--font-mono);
font-size: 12px;
padding: 6px 8px;
}
.field input:focus {
outline: none;
border-color: var(--accent);
}
.field-hint {
font-size: 10px;
color: var(--text-muted);
}
.dialog-footer {
display: flex;
justify-content: flex-end;
gap: 8px;
padding: 12px 16px;
border-top: 1px solid var(--border);
}
.btn-cancel, .btn-save {
border: none;
border-radius: var(--border-radius);
padding: 6px 14px;
font-size: 12px;
font-weight: 600;
cursor: pointer;
}
.btn-cancel {
background: var(--bg-surface);
color: var(--text-secondary);
}
.btn-cancel:hover { color: var(--text-primary); }
.btn-save {
background: var(--accent);
color: var(--ctp-crust);
}
.btn-save:hover { opacity: 0.9; }
</style>

View file

@ -0,0 +1,263 @@
<script lang="ts">
import { onMount } from 'svelte';
import { listSshSessions, deleteSshSession, type SshSession } from '../../adapters/ssh-bridge';
import { addPane } from '../../stores/layout.svelte';
import { notify } from '../../stores/notifications.svelte';
import SshDialog from './SshDialog.svelte';
let sessions = $state<SshSession[]>([]);
let dialogOpen = $state(false);
let editingSession = $state<SshSession | undefined>(undefined);
onMount(() => {
loadSessions();
});
async function loadSessions() {
try {
sessions = await listSshSessions();
} catch (e) {
console.warn('Failed to load SSH sessions:', e);
}
}
function openNewDialog() {
editingSession = undefined;
dialogOpen = true;
}
function openEditDialog(session: SshSession) {
editingSession = session;
dialogOpen = true;
}
function connectSsh(session: SshSession) {
const id = crypto.randomUUID();
const args: string[] = [];
// Build ssh command arguments
args.push('-p', String(session.port));
if (session.key_file) {
args.push('-i', session.key_file);
}
args.push(`${session.username}@${session.host}`);
addPane({
id,
type: 'ssh',
title: `SSH: ${session.name}`,
shell: '/usr/bin/ssh',
args,
});
}
async function handleDelete(session: SshSession, event: MouseEvent) {
event.stopPropagation();
try {
await deleteSshSession(session.id);
sessions = sessions.filter(s => s.id !== session.id);
notify('success', `Deleted SSH session "${session.name}"`);
} catch (e) {
notify('error', `Failed to delete SSH session: ${e}`);
}
}
// Group sessions by folder
let grouped = $derived(() => {
const groups = new Map<string, SshSession[]>();
for (const s of sessions) {
const folder = s.folder || '';
if (!groups.has(folder)) groups.set(folder, []);
groups.get(folder)!.push(s);
}
return groups;
});
</script>
<div class="ssh-sessions">
<div class="ssh-header">
<h3>SSH</h3>
<button class="new-btn" onclick={openNewDialog} title="Add SSH session">+</button>
</div>
{#if sessions.length === 0}
<div class="empty-state">
<p>No SSH sessions.</p>
</div>
{:else}
{@const groups = grouped()}
{#each [...groups.entries()] as [folder, folderSessions] (folder)}
{#if folder}
<div class="folder-label">{folder}</div>
{/if}
<ul class="ssh-list">
{#each folderSessions as session (session.id)}
<li class="ssh-item">
<button class="ssh-btn" onclick={() => connectSsh(session)} title="Connect to {session.host}">
<span class="ssh-color" style:background={session.color}></span>
<span class="ssh-info">
<span class="ssh-name">{session.name}</span>
<span class="ssh-host">{session.username}@{session.host}:{session.port}</span>
</span>
</button>
<button class="edit-btn" onclick={() => openEditDialog(session)} title="Edit">E</button>
<button class="remove-btn" onclick={(e) => handleDelete(session, e)} title="Delete">&times;</button>
</li>
{/each}
</ul>
{/each}
{/if}
</div>
<SshDialog
open={dialogOpen}
editSession={editingSession}
onClose={() => { dialogOpen = false; }}
onSaved={loadSessions}
/>
<style>
.ssh-sessions {
display: flex;
flex-direction: column;
gap: 4px;
}
.ssh-header {
display: flex;
align-items: center;
justify-content: space-between;
}
.ssh-header h3 {
font-size: 12px;
font-weight: 600;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.new-btn {
background: var(--bg-surface);
border: 1px solid var(--border);
color: var(--text-primary);
width: 20px;
height: 20px;
border-radius: var(--border-radius);
cursor: pointer;
font-size: 12px;
font-weight: 600;
display: flex;
align-items: center;
justify-content: center;
}
.new-btn:hover {
background: var(--bg-surface-hover);
color: var(--accent);
}
.empty-state {
color: var(--text-muted);
font-size: 11px;
text-align: center;
padding: 8px 0;
}
.folder-label {
font-size: 10px;
font-weight: 600;
color: var(--ctp-overlay0);
text-transform: uppercase;
letter-spacing: 0.5px;
padding: 4px 0 2px;
}
.ssh-list {
list-style: none;
display: flex;
flex-direction: column;
gap: 1px;
}
.ssh-item {
display: flex;
align-items: center;
border-radius: var(--border-radius);
}
.ssh-item:hover {
background: var(--bg-surface);
}
.ssh-btn {
flex: 1;
background: none;
border: none;
color: var(--text-secondary);
font-size: 12px;
padding: 4px 6px;
cursor: pointer;
display: flex;
align-items: center;
gap: 6px;
text-align: left;
}
.ssh-btn:hover { color: var(--text-primary); }
.ssh-color {
width: 6px;
height: 6px;
border-radius: 50%;
flex-shrink: 0;
}
.ssh-info {
display: flex;
flex-direction: column;
gap: 1px;
overflow: hidden;
}
.ssh-name {
font-size: 11px;
font-weight: 600;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.ssh-host {
font-size: 9px;
color: var(--text-muted);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.edit-btn {
background: none;
border: none;
color: var(--text-muted);
cursor: pointer;
font-size: 10px;
padding: 2px 3px;
opacity: 0;
}
.remove-btn {
background: none;
border: none;
color: var(--text-muted);
cursor: pointer;
font-size: 14px;
padding: 2px 4px;
opacity: 0;
}
.ssh-item:hover .edit-btn { opacity: 1; }
.ssh-item:hover .remove-btn { opacity: 1; }
.edit-btn:hover { color: var(--ctp-yellow); }
.remove-btn:hover { color: var(--ctp-red); }
</style>

View file

@ -2,6 +2,8 @@
import { onMount } from 'svelte';
import { getSetting, setSetting } from '../../adapters/settings-bridge';
import { notify } from '../../stores/notifications.svelte';
import { getCurrentFlavor, setFlavor } from '../../stores/theme.svelte';
import { ALL_FLAVORS, FLAVOR_LABELS, type CatppuccinFlavor } from '../../styles/themes';
interface Props {
open: boolean;
@ -13,12 +15,14 @@
let defaultShell = $state('');
let defaultCwd = $state('');
let maxPanes = $state('4');
let themeFlavor = $state<CatppuccinFlavor>('mocha');
onMount(async () => {
try {
defaultShell = (await getSetting('default_shell')) ?? '';
defaultCwd = (await getSetting('default_cwd')) ?? '';
maxPanes = (await getSetting('max_panes')) ?? '4';
themeFlavor = getCurrentFlavor();
} catch {
// Use defaults
}
@ -29,6 +33,7 @@
if (defaultShell) await setSetting('default_shell', defaultShell);
if (defaultCwd) await setSetting('default_cwd', defaultCwd);
await setSetting('max_panes', maxPanes);
await setFlavor(themeFlavor);
notify('success', 'Settings saved');
onClose();
} catch (e) {
@ -67,6 +72,15 @@
<input type="number" bind:value={maxPanes} min="1" max="8" />
<span class="field-hint">Maximum simultaneous panes (1-8)</span>
</label>
<label class="field">
<span class="field-label">Theme</span>
<select bind:value={themeFlavor}>
{#each ALL_FLAVORS as flavor}
<option value={flavor}>{FLAVOR_LABELS[flavor]}</option>
{/each}
</select>
<span class="field-hint">Catppuccin color scheme. New terminals use the updated theme.</span>
</label>
</div>
<div class="dialog-footer">
<button class="btn-cancel" onclick={onClose}>Cancel</button>
@ -146,7 +160,7 @@
color: var(--text-secondary);
}
.field input {
.field input, .field select {
background: var(--bg-surface);
border: 1px solid var(--border);
border-radius: var(--border-radius);
@ -156,7 +170,7 @@
padding: 6px 8px;
}
.field input:focus {
.field input:focus, .field select:focus {
outline: none;
border-color: var(--accent);
}

View file

@ -8,6 +8,7 @@
setPreset,
type LayoutPreset,
} from '../../stores/layout.svelte';
import SshSessionList from '../SSH/SshSessionList.svelte';
let panes = $derived(getPanes());
let preset = $derived(getActivePreset());
@ -34,6 +35,20 @@
});
}
function openContext() {
const existing = panes.find(p => p.type === 'context');
if (existing) {
focusPane(existing.id);
return;
}
const id = crypto.randomUUID();
addPane({
id,
type: 'context',
title: 'Context',
});
}
let fileInputEl: HTMLInputElement | undefined = $state();
function openMarkdown() {
@ -62,6 +77,7 @@
<div class="header">
<h2>Sessions</h2>
<div class="header-buttons">
<button class="new-btn" onclick={openContext} title="Context manager">C</button>
<button class="new-btn" onclick={openMarkdown} title="Open markdown file">M</button>
<button class="new-btn" onclick={newAgent} title="New agent (Ctrl+Shift+N)">A</button>
<button class="new-btn" onclick={newTerminal} title="New terminal (Ctrl+N)">+</button>
@ -96,7 +112,7 @@
{#each panes as pane (pane.id)}
<li class="pane-item" class:focused={pane.focused}>
<button class="pane-btn" onclick={() => focusPane(pane.id)}>
<span class="pane-icon">{pane.type === 'terminal' ? '>' : pane.type === 'agent' ? '*' : pane.type === 'markdown' ? 'M' : '#'}</span>
<span class="pane-icon">{pane.type === 'terminal' ? '>' : pane.type === 'agent' ? '*' : pane.type === 'markdown' ? 'M' : pane.type === 'ssh' ? '@' : pane.type === 'context' ? 'C' : '#'}</span>
<span class="pane-name">{pane.title}</span>
</button>
<button class="remove-btn" onclick={() => removePane(pane.id)}>&times;</button>
@ -104,9 +120,18 @@
{/each}
</ul>
{/if}
<div class="divider"></div>
<SshSessionList />
</div>
<style>
.divider {
height: 1px;
background: var(--border);
margin: 4px 0;
}
.session-list {
padding: 12px;
display: flex;

View file

@ -4,6 +4,7 @@
import { CanvasAddon } from '@xterm/addon-canvas';
import { FitAddon } from '@xterm/addon-fit';
import { spawnPty, writePty, resizePty, killPty, onPtyData, onPtyExit } from '../../adapters/pty-bridge';
import { getXtermTheme } from '../../stores/theme.svelte';
import type { UnlistenFn } from '@tauri-apps/api/event';
import '@xterm/xterm/css/xterm.css';
@ -24,35 +25,9 @@
let unlistenExit: UnlistenFn | null = null;
let resizeObserver: ResizeObserver | null = null;
// Catppuccin Mocha xterm theme
const catppuccinTheme = {
background: '#1e1e2e',
foreground: '#cdd6f4',
cursor: '#f5e0dc',
cursorAccent: '#1e1e2e',
selectionBackground: '#45475a',
selectionForeground: '#cdd6f4',
black: '#45475a',
red: '#f38ba8',
green: '#a6e3a1',
yellow: '#f9e2af',
blue: '#89b4fa',
magenta: '#f5c2e7',
cyan: '#94e2d5',
white: '#bac2de',
brightBlack: '#585b70',
brightRed: '#f38ba8',
brightGreen: '#a6e3a1',
brightYellow: '#f9e2af',
brightBlue: '#89b4fa',
brightMagenta: '#f5c2e7',
brightCyan: '#94e2d5',
brightWhite: '#a6adc8',
};
onMount(async () => {
term = new Terminal({
theme: catppuccinTheme,
theme: getXtermTheme(),
fontFamily: "'JetBrains Mono', 'Fira Code', 'Cascadia Code', monospace",
fontSize: 13,
lineHeight: 1.2,

View file

@ -11,7 +11,7 @@ import {
export type LayoutPreset = '1-col' | '2-col' | '3-col' | '2x2' | 'master-stack';
export type PaneType = 'terminal' | 'agent' | 'markdown' | 'empty';
export type PaneType = 'terminal' | 'agent' | 'markdown' | 'ssh' | 'context' | 'empty';
export interface Pane {
id: string;

View file

@ -0,0 +1,47 @@
// Theme store — persists Catppuccin flavor selection via settings bridge
import { getSetting, setSetting } from '../adapters/settings-bridge';
import {
type CatppuccinFlavor,
buildXtermTheme,
applyCssVariables,
type XtermTheme,
} from '../styles/themes';
let currentFlavor = $state<CatppuccinFlavor>('mocha');
export function getCurrentFlavor(): CatppuccinFlavor {
return currentFlavor;
}
export function getXtermTheme(): XtermTheme {
return buildXtermTheme(currentFlavor);
}
/** Change flavor, apply CSS variables, and persist to settings DB */
export async function setFlavor(flavor: CatppuccinFlavor): Promise<void> {
currentFlavor = flavor;
applyCssVariables(flavor);
try {
await setSetting('theme', flavor);
} catch (e) {
console.error('Failed to persist theme setting:', e);
}
}
/** Load saved flavor from settings DB and apply. Call once on app startup. */
export async function initTheme(): Promise<void> {
try {
const saved = await getSetting('theme');
if (saved && ['latte', 'frappe', 'macchiato', 'mocha'].includes(saved)) {
currentFlavor = saved as CatppuccinFlavor;
}
} catch {
// Fall back to default (mocha) — catppuccin.css provides Mocha defaults
}
// Always apply to sync CSS vars with current flavor
// (skip if mocha — catppuccin.css already has Mocha values)
if (currentFlavor !== 'mocha') {
applyCssVariables(currentFlavor);
}
}

254
v2/src/lib/styles/themes.ts Normal file
View file

@ -0,0 +1,254 @@
// Catppuccin theme flavors — https://catppuccin.com/palette
// Each flavor provides CSS custom properties and an xterm.js theme object.
export type CatppuccinFlavor = 'latte' | 'frappe' | 'macchiato' | 'mocha';
export interface CatppuccinPalette {
rosewater: string;
flamingo: string;
pink: string;
mauve: string;
red: string;
maroon: string;
peach: string;
yellow: string;
green: string;
teal: string;
sky: string;
sapphire: string;
blue: string;
lavender: string;
text: string;
subtext1: string;
subtext0: string;
overlay2: string;
overlay1: string;
overlay0: string;
surface2: string;
surface1: string;
surface0: string;
base: string;
mantle: string;
crust: string;
}
export interface XtermTheme {
background: string;
foreground: string;
cursor: string;
cursorAccent: string;
selectionBackground: string;
selectionForeground: string;
black: string;
red: string;
green: string;
yellow: string;
blue: string;
magenta: string;
cyan: string;
white: string;
brightBlack: string;
brightRed: string;
brightGreen: string;
brightYellow: string;
brightBlue: string;
brightMagenta: string;
brightCyan: string;
brightWhite: string;
}
const palettes: Record<CatppuccinFlavor, CatppuccinPalette> = {
latte: {
rosewater: '#dc8a78',
flamingo: '#dd7878',
pink: '#ea76cb',
mauve: '#8839ef',
red: '#d20f39',
maroon: '#e64553',
peach: '#fe640b',
yellow: '#df8e1d',
green: '#40a02b',
teal: '#179299',
sky: '#04a5e5',
sapphire: '#209fb5',
blue: '#1e66f5',
lavender: '#7287fd',
text: '#4c4f69',
subtext1: '#5c5f77',
subtext0: '#6c6f85',
overlay2: '#7c7f93',
overlay1: '#8c8fa1',
overlay0: '#9ca0b0',
surface2: '#acb0be',
surface1: '#bcc0cc',
surface0: '#ccd0da',
base: '#eff1f5',
mantle: '#e6e9ef',
crust: '#dce0e8',
},
frappe: {
rosewater: '#f2d5cf',
flamingo: '#eebebe',
pink: '#f4b8e4',
mauve: '#ca9ee6',
red: '#e78284',
maroon: '#ea999c',
peach: '#ef9f76',
yellow: '#e5c890',
green: '#a6d189',
teal: '#81c8be',
sky: '#99d1db',
sapphire: '#85c1dc',
blue: '#8caaee',
lavender: '#babbf1',
text: '#c6d0f5',
subtext1: '#b5bfe2',
subtext0: '#a5adce',
overlay2: '#949cbb',
overlay1: '#838ba7',
overlay0: '#737994',
surface2: '#626880',
surface1: '#51576d',
surface0: '#414559',
base: '#303446',
mantle: '#292c3c',
crust: '#232634',
},
macchiato: {
rosewater: '#f4dbd6',
flamingo: '#f0c6c6',
pink: '#f5bde6',
mauve: '#c6a0f6',
red: '#ed8796',
maroon: '#ee99a0',
peach: '#f5a97f',
yellow: '#eed49f',
green: '#a6da95',
teal: '#8bd5ca',
sky: '#91d7e3',
sapphire: '#7dc4e4',
blue: '#8aadf4',
lavender: '#b7bdf8',
text: '#cad3f5',
subtext1: '#b8c0e0',
subtext0: '#a5adcb',
overlay2: '#939ab7',
overlay1: '#8087a2',
overlay0: '#6e738d',
surface2: '#5b6078',
surface1: '#494d64',
surface0: '#363a4f',
base: '#24273a',
mantle: '#1e2030',
crust: '#181926',
},
mocha: {
rosewater: '#f5e0dc',
flamingo: '#f2cdcd',
pink: '#f5c2e7',
mauve: '#cba6f7',
red: '#f38ba8',
maroon: '#eba0ac',
peach: '#fab387',
yellow: '#f9e2af',
green: '#a6e3a1',
teal: '#94e2d5',
sky: '#89dceb',
sapphire: '#74c7ec',
blue: '#89b4fa',
lavender: '#b4befe',
text: '#cdd6f4',
subtext1: '#bac2de',
subtext0: '#a6adc8',
overlay2: '#9399b2',
overlay1: '#7f849c',
overlay0: '#6c7086',
surface2: '#585b70',
surface1: '#45475a',
surface0: '#313244',
base: '#1e1e2e',
mantle: '#181825',
crust: '#11111b',
},
};
export function getPalette(flavor: CatppuccinFlavor): CatppuccinPalette {
return palettes[flavor];
}
/** Build xterm.js ITheme from a Catppuccin palette */
export function buildXtermTheme(flavor: CatppuccinFlavor): XtermTheme {
const p = palettes[flavor];
return {
background: p.base,
foreground: p.text,
cursor: p.rosewater,
cursorAccent: p.base,
selectionBackground: p.surface1,
selectionForeground: p.text,
black: p.surface1,
red: p.red,
green: p.green,
yellow: p.yellow,
blue: p.blue,
magenta: p.pink,
cyan: p.teal,
white: p.subtext1,
brightBlack: p.surface2,
brightRed: p.red,
brightGreen: p.green,
brightYellow: p.yellow,
brightBlue: p.blue,
brightMagenta: p.pink,
brightCyan: p.teal,
brightWhite: p.subtext0,
};
}
/** CSS custom property names mapped to palette keys */
const CSS_VAR_MAP: [string, keyof CatppuccinPalette][] = [
['--ctp-rosewater', 'rosewater'],
['--ctp-flamingo', 'flamingo'],
['--ctp-pink', 'pink'],
['--ctp-mauve', 'mauve'],
['--ctp-red', 'red'],
['--ctp-maroon', 'maroon'],
['--ctp-peach', 'peach'],
['--ctp-yellow', 'yellow'],
['--ctp-green', 'green'],
['--ctp-teal', 'teal'],
['--ctp-sky', 'sky'],
['--ctp-sapphire', 'sapphire'],
['--ctp-blue', 'blue'],
['--ctp-lavender', 'lavender'],
['--ctp-text', 'text'],
['--ctp-subtext1', 'subtext1'],
['--ctp-subtext0', 'subtext0'],
['--ctp-overlay2', 'overlay2'],
['--ctp-overlay1', 'overlay1'],
['--ctp-overlay0', 'overlay0'],
['--ctp-surface2', 'surface2'],
['--ctp-surface1', 'surface1'],
['--ctp-surface0', 'surface0'],
['--ctp-base', 'base'],
['--ctp-mantle', 'mantle'],
['--ctp-crust', 'crust'],
];
/** Apply a Catppuccin flavor's CSS custom properties to document root */
export function applyCssVariables(flavor: CatppuccinFlavor): void {
const p = palettes[flavor];
const style = document.documentElement.style;
for (const [varName, key] of CSS_VAR_MAP) {
style.setProperty(varName, p[key]);
}
}
export const FLAVOR_LABELS: Record<CatppuccinFlavor, string> = {
latte: 'Latte (Light)',
frappe: 'Frappe',
macchiato: 'Macchiato',
mocha: 'Mocha (Default)',
};
export const ALL_FLAVORS: CatppuccinFlavor[] = ['latte', 'frappe', 'macchiato', 'mocha'];

View file

@ -0,0 +1,68 @@
// Detachable pane support — opens panes in separate OS windows
// Uses Tauri's WebviewWindow API
import { WebviewWindow } from '@tauri-apps/api/webviewWindow';
import type { Pane } from '../stores/layout.svelte';
let detachCounter = 0;
export async function detachPane(pane: Pane): Promise<void> {
detachCounter++;
const label = `detached-${detachCounter}`;
const params = new URLSearchParams({
detached: 'true',
type: pane.type,
title: pane.title,
});
if (pane.shell) params.set('shell', pane.shell);
if (pane.cwd) params.set('cwd', pane.cwd);
if (pane.args) params.set('args', JSON.stringify(pane.args));
if (pane.type === 'agent') params.set('sessionId', pane.id);
const webview = new WebviewWindow(label, {
url: `index.html?${params.toString()}`,
title: `BTerminal — ${pane.title}`,
width: 800,
height: 600,
decorations: true,
resizable: true,
});
// Wait for the window to be created
await webview.once('tauri://created', () => {
// Window created successfully
});
await webview.once('tauri://error', (e) => {
console.error('Failed to create detached window:', e);
});
}
export function isDetachedMode(): boolean {
const params = new URLSearchParams(window.location.search);
return params.get('detached') === 'true';
}
export function getDetachedConfig(): {
type: string;
title: string;
shell?: string;
cwd?: string;
args?: string[];
sessionId?: string;
} | null {
const params = new URLSearchParams(window.location.search);
if (params.get('detached') !== 'true') return null;
const argsStr = params.get('args');
return {
type: params.get('type') ?? 'terminal',
title: params.get('title') ?? 'Detached',
shell: params.get('shell') ?? undefined,
cwd: params.get('cwd') ?? undefined,
args: argsStr ? JSON.parse(argsStr) : undefined,
sessionId: params.get('sessionId') ?? undefined,
};
}

View file

@ -0,0 +1,51 @@
import { createHighlighter, type Highlighter } from 'shiki';
let highlighter: Highlighter | null = null;
let initPromise: Promise<Highlighter> | null = null;
// Use catppuccin-mocha theme (bundled with shiki)
const THEME = 'catppuccin-mocha';
// Common languages to preload
const LANGS = [
'typescript', 'javascript', 'rust', 'python', 'bash',
'json', 'html', 'css', 'svelte', 'sql', 'yaml', 'toml', 'markdown',
];
export async function getHighlighter(): Promise<Highlighter> {
if (highlighter) return highlighter;
if (initPromise) return initPromise;
initPromise = createHighlighter({
themes: [THEME],
langs: LANGS,
});
highlighter = await initPromise;
return highlighter;
}
export function highlightCode(code: string, lang: string): string {
if (!highlighter) return escapeHtml(code);
try {
const loadedLangs = highlighter.getLoadedLanguages();
if (!loadedLangs.includes(lang as any)) {
return escapeHtml(code);
}
return highlighter.codeToHtml(code, {
lang,
theme: THEME,
});
} catch {
return escapeHtml(code);
}
}
export function escapeHtml(text: string): string {
return text
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
}

View file

@ -0,0 +1,32 @@
// Auto-update checker — uses Tauri updater plugin
// Requires signing key to be configured in tauri.conf.json before use
import { check } from '@tauri-apps/plugin-updater';
export async function checkForUpdates(): Promise<{
available: boolean;
version?: string;
notes?: string;
}> {
try {
const update = await check();
if (update) {
return {
available: true,
version: update.version,
notes: update.body ?? undefined,
};
}
return { available: false };
} catch {
// Updater not configured or network error — silently skip
return { available: false };
}
}
export async function installUpdate(): Promise<void> {
const update = await check();
if (update) {
await update.downloadAndInstall();
}
}