diff --git a/v2/src-tauri/src/lib.rs b/v2/src-tauri/src/lib.rs index 8504d36..6c21533 100644 --- a/v2/src-tauri/src/lib.rs +++ b/v2/src-tauri/src/lib.rs @@ -132,6 +132,23 @@ fn layout_load(state: State<'_, AppState>) -> Result { state.session_db.load_layout() } +// --- Settings commands --- + +#[tauri::command] +fn settings_get(state: State<'_, AppState>, key: String) -> Result, String> { + state.session_db.get_setting(&key) +} + +#[tauri::command] +fn settings_set(state: State<'_, AppState>, key: String, value: String) -> Result<(), String> { + state.session_db.set_setting(&key, &value) +} + +#[tauri::command] +fn settings_list(state: State<'_, AppState>) -> Result, String> { + state.session_db.get_all_settings() +} + #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { let pty_manager = Arc::new(PtyManager::new()); @@ -175,6 +192,9 @@ pub fn run() { session_touch, layout_save, layout_load, + settings_get, + settings_set, + settings_list, ]) .setup(move |app| { if cfg!(debug_assertions) { diff --git a/v2/src-tauri/src/session.rs b/v2/src-tauri/src/session.rs index aeb0e65..df9ff87 100644 --- a/v2/src-tauri/src/session.rs +++ b/v2/src-tauri/src/session.rs @@ -68,11 +68,51 @@ impl SessionDb { ); INSERT OR IGNORE INTO layout_state (id, preset, pane_ids) VALUES (1, '1-col', '[]'); + + CREATE TABLE IF NOT EXISTS settings ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL + ); " ).map_err(|e| format!("Migration failed: {e}"))?; Ok(()) } + pub fn get_setting(&self, key: &str) -> Result, String> { + let conn = self.conn.lock().unwrap(); + let mut stmt = conn + .prepare("SELECT value FROM settings WHERE key = ?1") + .map_err(|e| format!("Settings query failed: {e}"))?; + let result = stmt.query_row(params![key], |row| row.get(0)); + match result { + Ok(val) => Ok(Some(val)), + Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None), + Err(e) => Err(format!("Settings read failed: {e}")), + } + } + + pub fn set_setting(&self, key: &str, value: &str) -> Result<(), String> { + let conn = self.conn.lock().unwrap(); + conn.execute( + "INSERT OR REPLACE INTO settings (key, value) VALUES (?1, ?2)", + params![key, value], + ).map_err(|e| format!("Settings write failed: {e}"))?; + Ok(()) + } + + pub fn get_all_settings(&self) -> Result, String> { + let conn = self.conn.lock().unwrap(); + let mut stmt = conn + .prepare("SELECT key, value FROM settings ORDER BY key") + .map_err(|e| format!("Settings query failed: {e}"))?; + let settings = stmt + .query_map([], |row| Ok((row.get(0)?, row.get(1)?))) + .map_err(|e| format!("Settings query failed: {e}"))? + .collect::, _>>() + .map_err(|e| format!("Settings read failed: {e}"))?; + Ok(settings) + } + pub fn list_sessions(&self) -> Result, String> { let conn = self.conn.lock().unwrap(); let mut stmt = conn diff --git a/v2/src/App.svelte b/v2/src/App.svelte index b8086f0..04d4605 100644 --- a/v2/src/App.svelte +++ b/v2/src/App.svelte @@ -2,7 +2,12 @@ import { onMount, onDestroy } from 'svelte'; import SessionList from './lib/components/Sidebar/SessionList.svelte'; import TilingGrid from './lib/components/Layout/TilingGrid.svelte'; - import { addPane, focusPaneByIndex, getPanes, restoreFromDb } from './lib/stores/layout.svelte'; + import StatusBar from './lib/components/StatusBar/StatusBar.svelte'; + 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'; + + let settingsOpen = $state(false); import { startAgentDispatcher, stopAgentDispatcher } from './lib/agent-dispatcher'; function newTerminal() { @@ -50,6 +55,21 @@ focusPaneByIndex(parseInt(e.key) - 1); return; } + + // Ctrl+, — settings + if (e.ctrlKey && e.key === ',') { + e.preventDefault(); + settingsOpen = !settingsOpen; + return; + } + + // Ctrl+W — close focused pane + if (e.ctrlKey && !e.shiftKey && e.key === 'w') { + e.preventDefault(); + const focused = getPanes().find(p => p.focused); + if (focused) removePane(focused.id); + return; + } } window.addEventListener('keydown', handleKeydown); @@ -66,6 +86,9 @@
+ + + settingsOpen = false} /> diff --git a/v2/src/lib/components/Notifications/ToastContainer.svelte b/v2/src/lib/components/Notifications/ToastContainer.svelte new file mode 100644 index 0000000..26da094 --- /dev/null +++ b/v2/src/lib/components/Notifications/ToastContainer.svelte @@ -0,0 +1,94 @@ + + +{#if toasts.length > 0} +
+ {#each toasts as toast (toast.id)} + + {/each} +
+{/if} + + diff --git a/v2/src/lib/components/Settings/SettingsDialog.svelte b/v2/src/lib/components/Settings/SettingsDialog.svelte new file mode 100644 index 0000000..3fd5ba5 --- /dev/null +++ b/v2/src/lib/components/Settings/SettingsDialog.svelte @@ -0,0 +1,199 @@ + + +{#if open} + +
+ +
+ +
+{/if} + + diff --git a/v2/src/lib/components/StatusBar/StatusBar.svelte b/v2/src/lib/components/StatusBar/StatusBar.svelte new file mode 100644 index 0000000..484308d --- /dev/null +++ b/v2/src/lib/components/StatusBar/StatusBar.svelte @@ -0,0 +1,95 @@ + + +
+
+ {terminalCount} terminals + + {agentCount} agents + {#if activeAgents > 0} + + + + {activeAgents} running + + {/if} +
+
+ {#if totalTokens > 0} + {totalTokens.toLocaleString()} tokens + + {/if} + {#if totalCost > 0} + ${totalCost.toFixed(4)} + + {/if} + BTerminal v2 +
+
+ + diff --git a/v2/src/lib/stores/notifications.svelte.ts b/v2/src/lib/stores/notifications.svelte.ts new file mode 100644 index 0000000..6ed0b13 --- /dev/null +++ b/v2/src/lib/stores/notifications.svelte.ts @@ -0,0 +1,36 @@ +// Notification store — ephemeral toast messages + +export type NotificationType = 'info' | 'success' | 'warning' | 'error'; + +export interface Notification { + id: string; + type: NotificationType; + message: string; + timestamp: number; +} + +let notifications = $state([]); + +const MAX_TOASTS = 5; +const TOAST_DURATION_MS = 4000; + +export function getNotifications(): Notification[] { + return notifications; +} + +export function notify(type: NotificationType, message: string): void { + const id = crypto.randomUUID(); + notifications.push({ id, type, message, timestamp: Date.now() }); + + // Cap visible toasts + if (notifications.length > MAX_TOASTS) { + notifications = notifications.slice(-MAX_TOASTS); + } + + // Auto-dismiss + setTimeout(() => dismissNotification(id), TOAST_DURATION_MS); +} + +export function dismissNotification(id: string): void { + notifications = notifications.filter(n => n.id !== id); +} diff --git a/v2/src/lib/utils/agent-tree.ts b/v2/src/lib/utils/agent-tree.ts new file mode 100644 index 0000000..1aa9e30 --- /dev/null +++ b/v2/src/lib/utils/agent-tree.ts @@ -0,0 +1,88 @@ +// Agent tree builder — constructs hierarchical tree from agent messages +// Subagents are identified by parent_tool_use_id on their messages + +import type { AgentMessage, ToolCallContent, CostContent } from '../adapters/sdk-messages'; + +export interface AgentTreeNode { + id: string; + label: string; + toolName?: string; + status: 'running' | 'done' | 'error'; + costUsd: number; + tokens: number; + children: AgentTreeNode[]; +} + +/** + * Build a tree from a flat list of agent messages. + * Root node represents the main agent session. + * Child nodes represent tool_use calls (potential subagents). + */ +export function buildAgentTree( + sessionId: string, + messages: AgentMessage[], + sessionStatus: string, + sessionCost: number, + sessionTokens: number, +): AgentTreeNode { + const root: AgentTreeNode = { + id: sessionId, + label: sessionId.slice(0, 8), + status: sessionStatus === 'running' || sessionStatus === 'starting' ? 'running' : + sessionStatus === 'error' ? 'error' : 'done', + costUsd: sessionCost, + tokens: sessionTokens, + children: [], + }; + + // Map tool_use_id -> node for nesting + const toolNodes = new Map(); + + for (const msg of messages) { + if (msg.type === 'tool_call') { + const tc = msg.content as ToolCallContent; + const node: AgentTreeNode = { + id: tc.toolUseId, + label: tc.name, + toolName: tc.name, + status: 'running', // will be updated by result + costUsd: 0, + tokens: 0, + children: [], + }; + toolNodes.set(tc.toolUseId, node); + + if (msg.parentId) { + // This is a subagent tool call — attach to parent tool node + const parent = toolNodes.get(msg.parentId); + if (parent) { + parent.children.push(node); + } else { + root.children.push(node); + } + } else { + root.children.push(node); + } + } + + if (msg.type === 'tool_result') { + const tr = msg.content as { toolUseId: string }; + const node = toolNodes.get(tr.toolUseId); + if (node) { + node.status = 'done'; + } + } + } + + return root; +} + +/** Flatten tree to get total count of nodes */ +export function countTreeNodes(node: AgentTreeNode): number { + return 1 + node.children.reduce((sum, c) => sum + countTreeNodes(c), 0); +} + +/** Aggregate cost across a subtree */ +export function subtreeCost(node: AgentTreeNode): number { + return node.costUsd + node.children.reduce((sum, c) => sum + subtreeCost(c), 0); +}