From 457722dc3a6251b1f8f6e76750a41530795c47bb Mon Sep 17 00:00:00 2001 From: Hikari Date: Sun, 25 Jan 2026 13:38:30 -0800 Subject: [PATCH 01/21] feat: add minimize to system tray option Add ability to minimize Hikari to the system tray when closing the window instead of fully exiting. When enabled, clicking the close button hides the window and shows a tray icon with "Show Hikari" and "Quit" options. - Add tray module with system tray setup and menu handling - Add minimize_to_tray config option in settings - Handle window close event to hide instead of close when enabled - Add tray icon click handler to restore window --- src-tauri/Cargo.lock | 1 + src-tauri/Cargo.toml | 2 +- src-tauri/capabilities/default.json | 3 +- src-tauri/src/config.rs | 6 +++ src-tauri/src/lib.rs | 24 +++++++++ src-tauri/src/tray.rs | 68 +++++++++++++++++++++++++ src-tauri/tauri.conf.json | 6 +++ src/lib/components/ConfigSidebar.svelte | 16 ++++++ src/lib/components/StatusBar.svelte | 1 + src/lib/stores/config.ts | 2 + 10 files changed, 127 insertions(+), 2 deletions(-) create mode 100644 src-tauri/src/tray.rs diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 3ed5bd2..8f4fc69 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -4180,6 +4180,7 @@ dependencies = [ "gtk", "heck 0.5.0", "http", + "image", "jni", "libc", "log", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 5ac4de4..ede8d32 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -13,7 +13,7 @@ crate-type = ["staticlib", "cdylib", "rlib"] tauri-build = { version = "2", features = [] } [dependencies] -tauri = { version = "2", features = [] } +tauri = { version = "2", features = ["tray-icon", "image-png"] } tauri-plugin-dialog = "2" tauri-plugin-opener = "2" tauri-plugin-shell = "2" diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index 5e067ad..2bf139b 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -15,6 +15,7 @@ "notification:allow-request-permission", "notification:allow-notify", "clipboard-manager:default", - "clipboard-manager:allow-read-image" + "clipboard-manager:allow-read-image", + "core:tray:default" ] } diff --git a/src-tauri/src/config.rs b/src-tauri/src/config.rs index d6f6fae..6800346 100644 --- a/src-tauri/src/config.rs +++ b/src-tauri/src/config.rs @@ -70,6 +70,9 @@ pub struct HikariConfig { #[serde(default = "default_font_size")] pub font_size: u32, + + #[serde(default)] + pub minimize_to_tray: bool, } impl Default for HikariConfig { @@ -89,6 +92,7 @@ impl Default for HikariConfig { update_checks_enabled: true, character_panel_width: None, font_size: 14, + minimize_to_tray: false, } } } @@ -140,6 +144,7 @@ mod tests { assert!(config.update_checks_enabled); assert!(config.character_panel_width.is_none()); assert_eq!(config.font_size, 14); + assert!(!config.minimize_to_tray); } #[test] @@ -159,6 +164,7 @@ mod tests { update_checks_enabled: true, character_panel_width: Some(400), font_size: 16, + minimize_to_tray: true, }; let json = serde_json::to_string(&config).unwrap(); diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index f635d4d..8957ffa 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -5,6 +5,7 @@ mod config; mod notifications; mod stats; mod temp_manager; +mod tray; mod types; mod vbs_notification; mod windows_toast; @@ -15,7 +16,9 @@ use bridge_manager::create_shared_bridge_manager; use commands::load_saved_achievements; use commands::*; use notifications::*; +use tauri::Manager; use temp_manager::create_shared_temp_manager; +use tray::{setup_tray, should_minimize_to_tray}; use vbs_notification::*; use windows_toast::*; use wsl_notifications::*; @@ -47,6 +50,27 @@ pub fn run() { } } + // Set up system tray + if let Err(e) = setup_tray(app.handle()) { + eprintln!("Failed to set up system tray: {}", e); + } + + // Handle window close event for minimize to tray + let main_window = app.get_webview_window("main").unwrap(); + main_window.on_window_event({ + let app_handle = app.handle().clone(); + move |event| { + if let tauri::WindowEvent::CloseRequested { api, .. } = event { + if should_minimize_to_tray(&app_handle) { + api.prevent_close(); + if let Some(window) = app_handle.get_webview_window("main") { + let _ = window.hide(); + } + } + } + } + }); + Ok(()) }) .invoke_handler(tauri::generate_handler![ diff --git a/src-tauri/src/tray.rs b/src-tauri/src/tray.rs new file mode 100644 index 0000000..9f87b0b --- /dev/null +++ b/src-tauri/src/tray.rs @@ -0,0 +1,68 @@ +use tauri::{ + menu::{Menu, MenuItem}, + tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent}, + AppHandle, Manager, +}; + +use crate::config::HikariConfig; + +pub fn setup_tray(app: &AppHandle) -> tauri::Result<()> { + let show_item = MenuItem::with_id(app, "show", "Show Hikari", true, None::<&str>)?; + let quit_item = MenuItem::with_id(app, "quit", "Quit", true, None::<&str>)?; + + let menu = Menu::with_items(app, &[&show_item, &quit_item])?; + + let _tray = TrayIconBuilder::with_id("main") + .icon(app.default_window_icon().unwrap().clone()) + .menu(&menu) + .tooltip("Hikari - Claude Code Assistant") + .on_menu_event(|app, event| match event.id.as_ref() { + "show" => { + if let Some(window) = app.get_webview_window("main") { + let _ = window.show(); + let _ = window.unminimize(); + let _ = window.set_focus(); + } + } + "quit" => { + app.exit(0); + } + _ => {} + }) + .on_tray_icon_event(|tray, event| { + if let TrayIconEvent::Click { + button: MouseButton::Left, + button_state: MouseButtonState::Up, + .. + } = event + { + let app = tray.app_handle(); + if let Some(window) = app.get_webview_window("main") { + let _ = window.show(); + let _ = window.unminimize(); + let _ = window.set_focus(); + } + } + }) + .build(app)?; + + Ok(()) +} + +pub fn should_minimize_to_tray(app: &AppHandle) -> bool { + let config_path = app + .path() + .app_config_dir() + .ok() + .map(|p| p.join("hikari-config.json")); + + if let Some(path) = config_path { + if let Ok(content) = std::fs::read_to_string(&path) { + if let Ok(config) = serde_json::from_str::(&content) { + return config.minimize_to_tray; + } + } + } + + false +} diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index b529bcc..368ea33 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -22,6 +22,12 @@ ], "security": { "csp": null + }, + "trayIcon": { + "id": "main", + "iconPath": "icons/32x32.png", + "iconAsTemplate": false, + "tooltip": "Hikari - Claude Code Assistant" } }, "bundle": { diff --git a/src/lib/components/ConfigSidebar.svelte b/src/lib/components/ConfigSidebar.svelte index 8981690..22ce8c1 100644 --- a/src/lib/components/ConfigSidebar.svelte +++ b/src/lib/components/ConfigSidebar.svelte @@ -23,6 +23,7 @@ notifications_enabled: true, notification_volume: 0.7, always_on_top: false, + minimize_to_tray: false, update_checks_enabled: true, character_panel_width: null, font_size: 14, @@ -477,6 +478,21 @@

+ +
+ +

+ Hide to tray instead of closing when you click the X button +

+
+
diff --git a/src/lib/stores/config.ts b/src/lib/stores/config.ts index 1e5569a..86b069c 100644 --- a/src/lib/stores/config.ts +++ b/src/lib/stores/config.ts @@ -1,7 +1,7 @@ import { writable, derived } from "svelte/store"; import { invoke } from "@tauri-apps/api/core"; -export type Theme = "dark" | "light"; +export type Theme = "dark" | "light" | "high-contrast"; export interface HikariConfig { model: string | null; -- 2.52.0 From ce97c51cd86256f93ed49e33f96f83a444a6d86d Mon Sep 17 00:00:00 2001 From: Hikari Date: Sun, 25 Jan 2026 14:10:05 -0800 Subject: [PATCH 03/21] feat: add session history with auto-save - Add Rust backend for session persistence using tauri-plugin-store - Create SessionHistoryPanel component for browsing saved sessions - Implement debounced auto-save (2s delay) when messages are added - Add session search, resume, and delete functionality - Add clock icon button in StatusBar to access session history Closes #14 --- src-tauri/src/lib.rs | 8 + src-tauri/src/sessions.rs | 167 ++++++++ src/lib/components/ConversationTabs.svelte | 46 ++- src/lib/components/SessionHistoryPanel.svelte | 369 ++++++++++++++++++ src/lib/components/StatusBar.svelte | 20 + src/lib/stores/conversations.ts | 8 + src/lib/stores/sessions.ts | 194 +++++++++ 7 files changed, 791 insertions(+), 21 deletions(-) create mode 100644 src-tauri/src/sessions.rs create mode 100644 src/lib/components/SessionHistoryPanel.svelte create mode 100644 src/lib/stores/sessions.ts diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 8957ffa..ef86871 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -3,6 +3,7 @@ mod bridge_manager; mod commands; mod config; mod notifications; +mod sessions; mod stats; mod temp_manager; mod tray; @@ -16,6 +17,7 @@ use bridge_manager::create_shared_bridge_manager; use commands::load_saved_achievements; use commands::*; use notifications::*; +use sessions::*; use tauri::Manager; use temp_manager::create_shared_temp_manager; use tray::{setup_tray, should_minimize_to_tray}; @@ -102,6 +104,12 @@ pub fn run() { cleanup_all_temp_files, cleanup_orphaned_temp_files, get_file_size, + list_sessions, + save_session, + load_session, + delete_session, + search_sessions, + clear_all_sessions, ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); diff --git a/src-tauri/src/sessions.rs b/src-tauri/src/sessions.rs new file mode 100644 index 0000000..d8c0c54 --- /dev/null +++ b/src-tauri/src/sessions.rs @@ -0,0 +1,167 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use tauri::AppHandle; +use tauri_plugin_store::StoreExt; + +const SESSIONS_STORE_KEY: &str = "sessions"; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SavedSession { + pub id: String, + pub name: String, + pub created_at: DateTime, + pub last_activity_at: DateTime, + pub working_directory: String, + pub message_count: usize, + pub preview: String, // First ~100 chars of conversation for preview + pub messages: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SavedMessage { + pub id: String, + #[serde(rename = "type")] + pub message_type: String, + pub content: String, + pub timestamp: DateTime, + pub tool_name: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SessionListItem { + pub id: String, + pub name: String, + pub created_at: DateTime, + pub last_activity_at: DateTime, + pub working_directory: String, + pub message_count: usize, + pub preview: String, +} + +impl From<&SavedSession> for SessionListItem { + fn from(session: &SavedSession) -> Self { + SessionListItem { + id: session.id.clone(), + name: session.name.clone(), + created_at: session.created_at, + last_activity_at: session.last_activity_at, + working_directory: session.working_directory.clone(), + message_count: session.message_count, + preview: session.preview.clone(), + } + } +} + +fn load_all_sessions(app: &AppHandle) -> Result, String> { + let store = app + .store("hikari-sessions.json") + .map_err(|e| e.to_string())?; + + match store.get(SESSIONS_STORE_KEY) { + Some(value) => serde_json::from_value(value.clone()).map_err(|e| e.to_string()), + None => Ok(Vec::new()), + } +} + +fn save_all_sessions(app: &AppHandle, sessions: &[SavedSession]) -> Result<(), String> { + let store = app + .store("hikari-sessions.json") + .map_err(|e| e.to_string())?; + + let value = serde_json::to_value(sessions).map_err(|e| e.to_string())?; + store.set(SESSIONS_STORE_KEY, value); + store.save().map_err(|e| e.to_string())?; + + Ok(()) +} + +#[tauri::command] +pub async fn list_sessions(app: AppHandle) -> Result, String> { + let sessions = load_all_sessions(&app)?; + let mut items: Vec = sessions.iter().map(SessionListItem::from).collect(); + + // Sort by last activity, most recent first + items.sort_by(|a, b| b.last_activity_at.cmp(&a.last_activity_at)); + + Ok(items) +} + +#[tauri::command] +pub async fn save_session(app: AppHandle, session: SavedSession) -> Result<(), String> { + let mut sessions = load_all_sessions(&app)?; + + // Update existing or add new + if let Some(existing) = sessions.iter_mut().find(|s| s.id == session.id) { + *existing = session; + } else { + sessions.push(session); + } + + save_all_sessions(&app, &sessions) +} + +#[tauri::command] +pub async fn load_session(app: AppHandle, session_id: String) -> Result, String> { + let sessions = load_all_sessions(&app)?; + Ok(sessions.into_iter().find(|s| s.id == session_id)) +} + +#[tauri::command] +pub async fn delete_session(app: AppHandle, session_id: String) -> Result<(), String> { + let mut sessions = load_all_sessions(&app)?; + sessions.retain(|s| s.id != session_id); + save_all_sessions(&app, &sessions) +} + +#[tauri::command] +pub async fn search_sessions(app: AppHandle, query: String) -> Result, String> { + let sessions = load_all_sessions(&app)?; + let query_lower = query.to_lowercase(); + + let mut matching: Vec = sessions + .iter() + .filter(|s| { + s.name.to_lowercase().contains(&query_lower) + || s.preview.to_lowercase().contains(&query_lower) + || s.working_directory.to_lowercase().contains(&query_lower) + || s.messages + .iter() + .any(|m| m.content.to_lowercase().contains(&query_lower)) + }) + .map(SessionListItem::from) + .collect(); + + // Sort by last activity, most recent first + matching.sort_by(|a, b| b.last_activity_at.cmp(&a.last_activity_at)); + + Ok(matching) +} + +#[tauri::command] +pub async fn clear_all_sessions(app: AppHandle) -> Result<(), String> { + save_all_sessions(&app, &[]) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_session_list_item_from_saved_session() { + let session = SavedSession { + id: "test-id".to_string(), + name: "Test Session".to_string(), + created_at: Utc::now(), + last_activity_at: Utc::now(), + working_directory: "/home/test".to_string(), + message_count: 5, + preview: "Hello world".to_string(), + messages: vec![], + }; + + let item = SessionListItem::from(&session); + assert_eq!(item.id, "test-id"); + assert_eq!(item.name, "Test Session"); + assert_eq!(item.message_count, 5); + } +} diff --git a/src/lib/components/ConversationTabs.svelte b/src/lib/components/ConversationTabs.svelte index 6f2d8c0..34c9d87 100644 --- a/src/lib/components/ConversationTabs.svelte +++ b/src/lib/components/ConversationTabs.svelte @@ -236,28 +236,32 @@ {/if} - {#if $conversations.size > 1} - - {/if} + + + + + {/if} + {/each} diff --git a/src/lib/components/SessionHistoryPanel.svelte b/src/lib/components/SessionHistoryPanel.svelte new file mode 100644 index 0000000..130be84 --- /dev/null +++ b/src/lib/components/SessionHistoryPanel.svelte @@ -0,0 +1,369 @@ + + +
e.key === "Escape" && onClose()} +> +
e.stopPropagation()} + onkeydown={(e) => e.stopPropagation()} + role="dialog" + aria-labelledby="session-history-title" + tabindex="-1" + > +
+
+ {#if selectedSession} + + {/if} +

+ {selectedSession ? selectedSession.name : "Session History"} +

+
+ +
+ + {#if selectedSession} +
+
+ {formatDate(selectedSession.created_at)} + + {selectedSession.message_count} messages + {#if selectedSession.working_directory} + + {selectedSession.working_directory} + {/if} +
+
+ {#each selectedSession.messages as message (message.id)} +
+
+ + {message.type === "user" + ? "You" + : message.type === "assistant" + ? "Hikari" + : message.type === "tool" + ? message.tool_name || "Tool" + : message.type} + +
+

+ {message.content.length > 500 + ? message.content.slice(0, 500) + "..." + : message.content} +

+
+ {/each} +
+
+ {:else} +
+
+ + + + +
+
+ +
+ {#if $isLoading} +
+
Loading sessions...
+
+ {:else if $sessions.length === 0} +
+ + + +

No saved sessions yet

+

+ Your conversations will appear here once saved +

+
+ {:else} +
+ {#each $sessions as session (session.id)} +
+
+ +
+ + {#if showDeleteConfirm === session.id} +
+ + +
+ {:else} + + {/if} +
+
+
+ {/each} +
+ {/if} +
+ {/if} +
+
+ + diff --git a/src/lib/components/StatusBar.svelte b/src/lib/components/StatusBar.svelte index 6793090..543f674 100644 --- a/src/lib/components/StatusBar.svelte +++ b/src/lib/components/StatusBar.svelte @@ -19,6 +19,7 @@ import HelpPanel from "./HelpPanel.svelte"; import KeyboardShortcutsModal from "./KeyboardShortcutsModal.svelte"; import { achievementProgress } from "$lib/stores/achievements"; + import SessionHistoryPanel from "./SessionHistoryPanel.svelte"; const DISCORD_URL = "https://chat.nhcarrigan.com"; const DONATE_URL = "https://donate.nhcarrigan.com"; @@ -33,6 +34,7 @@ let showAbout = $state(false); let showHelp = $state(false); let showKeyboardShortcuts = $state(false); + let showSessionHistory = $state(false); const progress = $derived($achievementProgress); let currentConfig: HikariConfig = $state({ model: null, @@ -217,6 +219,20 @@ {/if} + + +
${highlighted}
+ `; }; renderer.codespan = ({ text }) => { @@ -123,6 +136,28 @@ } } + async function handleCopyClick(event: MouseEvent) { + const target = event.target as HTMLElement; + const copyBtn = target.closest(".copy-code-btn") as HTMLButtonElement; + if (copyBtn) { + event.preventDefault(); + const code = copyBtn.dataset.code + ?.replace(/"/g, '"') + .replace(/</g, "<") + .replace(/>/g, ">"); + if (code) { + await navigator.clipboard.writeText(code); + const textSpan = copyBtn.querySelector(".copy-text"); + if (textSpan) { + textSpan.textContent = "Copied!"; + setTimeout(() => { + textSpan.textContent = "Copy"; + }, 2000); + } + } + } + } + onMount(() => { if (containerElement) { containerElement.querySelectorAll("pre code:not(.hljs)").forEach((block) => { @@ -138,6 +173,7 @@ onclick={(e) => { handleSpoilerClick(e); handleLinkClick(e); + handleCopyClick(e); }} onkeydown={handleSpoilerKeydown} role="presentation" @@ -163,13 +199,59 @@ margin-bottom: 0; } + .markdown-content :global(.code-block-wrapper) { + margin: 0.75em 0; + border-radius: 6px; + border: 1px solid var(--border-color); + overflow: hidden; + } + + .markdown-content :global(.code-block-header) { + display: flex; + justify-content: space-between; + align-items: center; + background: var(--bg-secondary); + padding: 0.4em 0.75em; + border-bottom: 1px solid var(--border-color); + font-size: 0.8em; + } + + .markdown-content :global(.code-block-lang) { + color: var(--text-secondary); + font-family: "JetBrains Mono", "Fira Code", monospace; + text-transform: lowercase; + } + + .markdown-content :global(.copy-code-btn) { + display: flex; + align-items: center; + gap: 0.4em; + background: transparent; + border: none; + color: var(--text-secondary); + cursor: pointer; + padding: 0.25em 0.5em; + border-radius: 4px; + font-size: 0.9em; + transition: all 0.15s ease; + } + + .markdown-content :global(.copy-code-btn:hover) { + background: var(--bg-hover); + color: var(--text-primary); + } + + .markdown-content :global(.copy-code-btn svg) { + flex-shrink: 0; + } + .markdown-content :global(.hljs-code-block) { background: var(--bg-code, #1e1e2e); - border-radius: 6px; + border-radius: 0; padding: 1em; - margin: 0.75em 0; + margin: 0; overflow-x: auto; - border: 1px solid var(--border-color); + border: none; } .markdown-content :global(.hljs-code-block code) { -- 2.52.0 From 4a8a1d564a696db9ddc8ece00b3455b7abc73493 Mon Sep 17 00:00:00 2001 From: Naomi Carrigan Date: Sun, 25 Jan 2026 15:00:52 -0800 Subject: [PATCH 05/21] feat: add session import/export functionality - Add JSON export with full session metadata for backup/restore - Add Markdown export with formatted conversation history - Add import capability for previously exported sessions - Add tauri-plugin-fs for file system operations - Add export dropdown menu and import button to session history panel --- package.json | 1 + pnpm-lock.yaml | 10 ++ src-tauri/Cargo.lock | 1 + src-tauri/Cargo.toml | 1 + src-tauri/capabilities/default.json | 5 +- src-tauri/src/lib.rs | 1 + src/lib/components/SessionHistoryPanel.svelte | 112 ++++++++++++++-- src/lib/stores/sessions.ts | 121 ++++++++++++++++++ 8 files changed, 237 insertions(+), 15 deletions(-) diff --git a/package.json b/package.json index 42fca6d..5025a4b 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,7 @@ "@tauri-apps/api": "^2", "@tauri-apps/plugin-clipboard-manager": "^2.3.2", "@tauri-apps/plugin-dialog": "^2", + "@tauri-apps/plugin-fs": "^2.4.5", "@tauri-apps/plugin-notification": "^2", "@tauri-apps/plugin-opener": "^2", "@tauri-apps/plugin-os": "^2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2bc5ddd..78797db 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -17,6 +17,9 @@ importers: '@tauri-apps/plugin-dialog': specifier: ^2 version: 2.6.0 + '@tauri-apps/plugin-fs': + specifier: ^2.4.5 + version: 2.4.5 '@tauri-apps/plugin-notification': specifier: ^2 version: 2.3.3 @@ -744,6 +747,9 @@ packages: '@tauri-apps/plugin-dialog@2.6.0': resolution: {integrity: sha512-q4Uq3eY87TdcYzXACiYSPhmpBA76shgmQswGkSVio4C82Sz2W4iehe9TnKYwbq7weHiL88Yw19XZm7v28+Micg==} + '@tauri-apps/plugin-fs@2.4.5': + resolution: {integrity: sha512-dVxWWGE6VrOxC7/jlhyE+ON/Cc2REJlM35R3PJX3UvFw2XwYhLGQVAIyrehenDdKjotipjYEVc4YjOl3qq90fA==} + '@tauri-apps/plugin-notification@2.3.3': resolution: {integrity: sha512-Zw+ZH18RJb41G4NrfHgIuofJiymusqN+q8fGUIIV7vyCH+5sSn5coqRv/MWB9qETsUs97vmU045q7OyseCV3Qg==} @@ -2300,6 +2306,10 @@ snapshots: dependencies: '@tauri-apps/api': 2.9.1 + '@tauri-apps/plugin-fs@2.4.5': + dependencies: + '@tauri-apps/api': 2.9.1 + '@tauri-apps/plugin-notification@2.3.3': dependencies: '@tauri-apps/api': 2.9.1 diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 8f4fc69..ff78693 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -1613,6 +1613,7 @@ dependencies = [ "tauri-build", "tauri-plugin-clipboard-manager", "tauri-plugin-dialog", + "tauri-plugin-fs", "tauri-plugin-http", "tauri-plugin-notification", "tauri-plugin-opener", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index ede8d32..10243c0 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -27,6 +27,7 @@ tauri-plugin-notification = "2" tauri-plugin-os = "2" tauri-plugin-http = "2" tauri-plugin-clipboard-manager = "2" +tauri-plugin-fs = "2" tempfile = "3" semver = "1" chrono = { version = "0.4.43", features = ["serde"] } diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index 2bf139b..5caf529 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -16,6 +16,9 @@ "notification:allow-notify", "clipboard-manager:default", "clipboard-manager:allow-read-image", - "core:tray:default" + "core:tray:default", + "fs:default", + "fs:allow-read-text-file", + "fs:allow-write-text-file" ] } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index ef86871..cf97d8b 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -39,6 +39,7 @@ pub fn run() { .plugin(tauri_plugin_os::init()) .plugin(tauri_plugin_http::init()) .plugin(tauri_plugin_clipboard_manager::init()) + .plugin(tauri_plugin_fs::init()) .manage(bridge_manager.clone()) .manage(temp_manager.clone()) .setup(move |app| { diff --git a/src/lib/components/SessionHistoryPanel.svelte b/src/lib/components/SessionHistoryPanel.svelte index 130be84..f22995c 100644 --- a/src/lib/components/SessionHistoryPanel.svelte +++ b/src/lib/components/SessionHistoryPanel.svelte @@ -13,6 +13,8 @@ let searchInput = $state(""); let selectedSession = $state(null); let showDeleteConfirm = $state(null); + let showExportMenu = $state(null); + let isImporting = $state(false); const sessions = $derived(sessionsStore.sessions); const isLoading = $derived(sessionsStore.isLoading); @@ -89,6 +91,34 @@ function handleBackToList(): void { selectedSession = null; } + + async function handleExportJson(sessionId: string): Promise { + showExportMenu = null; + await sessionsStore.exportSessionAsJson(sessionId); + } + + async function handleExportMarkdown(sessionId: string): Promise { + showExportMenu = null; + await sessionsStore.exportSessionAsMarkdown(sessionId); + } + + async function handleImport(): Promise { + isImporting = true; + try { + await sessionsStore.importSession(); + } finally { + isImporting = false; + } + } + + function toggleExportMenu(sessionId: string): void { + if (showExportMenu === sessionId) { + showExportMenu = null; + } else { + showExportMenu = sessionId; + showDeleteConfirm = null; + } + }
- +
+ {#if !selectedSession} + + {/if} + +
{#if selectedSession} @@ -280,6 +330,40 @@ > Resume +
+ + {#if showExportMenu === session.id} +
+ + +
+ {/if} +
{#if showDeleteConfirm === session.id}
+ + {/if} +

+ {#if isCreating} + Create Snippet + {:else if editingSnippet} + Edit Snippet + {:else} + Snippet Library + {/if} +

+
+
+ {#if !editingSnippet && !isCreating} + + {/if} + +
+ + + {#if editingSnippet || isCreating} +
+
+
+ + +
+ +
+ +
+ {#if showNewCategoryInput} + + {:else} + + {/if} + +
+
+ +
+ + +
+ +
+ + +
+
+
+ {:else} +
+
+

+ Categories +

+
+ + {#each $categories as category (category)} + + {/each} +
+
+ +
+ {#if $isLoading} +
+
Loading snippets...
+
+ {:else if $snippets.length === 0} +
+ + + +

No snippets in this category

+ +
+ {:else} +
+ {#each $snippets as snippet (snippet.id)} +
+
+
+
+

{snippet.name}

+ {#if snippet.is_default} + + Default + + {/if} + {snippet.category} +
+

+ {snippet.content} +

+
+
+ + + {#if !snippet.is_default} + {#if showDeleteConfirm === snippet.id} +
+ + +
+ {:else} + + {/if} + {/if} +
+
+
+ {/each} +
+ {/if} +
+
+ {/if} + + + + diff --git a/src/lib/stores/snippets.ts b/src/lib/stores/snippets.ts new file mode 100644 index 0000000..b8e901b --- /dev/null +++ b/src/lib/stores/snippets.ts @@ -0,0 +1,138 @@ +import { writable, derived } from "svelte/store"; +import { invoke } from "@tauri-apps/api/core"; + +export interface Snippet { + id: string; + name: string; + content: string; + category: string; + is_default: boolean; + created_at: string; + updated_at: string; +} + +function createSnippetsStore() { + const snippets = writable([]); + const categories = writable([]); + const isLoading = writable(false); + const selectedCategory = writable(null); + + const filteredSnippets = derived( + [snippets, selectedCategory], + ([$snippets, $selectedCategory]) => { + if (!$selectedCategory) { + return $snippets; + } + return $snippets.filter((s) => s.category === $selectedCategory); + } + ); + + async function loadSnippets(): Promise { + isLoading.set(true); + try { + const [snippetList, categoryList] = await Promise.all([ + invoke("list_snippets"), + invoke("get_snippet_categories"), + ]); + snippets.set(snippetList); + categories.set(categoryList); + } catch (error) { + console.error("Failed to load snippets:", error); + } finally { + isLoading.set(false); + } + } + + async function saveSnippet(snippet: Snippet): Promise { + try { + await invoke("save_snippet", { snippet }); + await loadSnippets(); + return true; + } catch (error) { + console.error("Failed to save snippet:", error); + return false; + } + } + + async function createSnippet(name: string, content: string, category: string): Promise { + const now = new Date().toISOString(); + const snippet: Snippet = { + id: `custom-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`, + name, + content, + category, + is_default: false, + created_at: now, + updated_at: now, + }; + return saveSnippet(snippet); + } + + async function updateSnippet( + id: string, + name: string, + content: string, + category: string + ): Promise { + const currentSnippets = await invoke("list_snippets"); + const existing = currentSnippets.find((s) => s.id === id); + + if (!existing) { + console.error("Snippet not found for update"); + return false; + } + + const updated: Snippet = { + ...existing, + name, + content, + category, + updated_at: new Date().toISOString(), + }; + + return saveSnippet(updated); + } + + async function deleteSnippet(snippetId: string): Promise { + try { + await invoke("delete_snippet", { snippetId }); + await loadSnippets(); + return true; + } catch (error) { + console.error("Failed to delete snippet:", error); + return false; + } + } + + async function resetDefaults(): Promise { + try { + await invoke("reset_default_snippets"); + await loadSnippets(); + return true; + } catch (error) { + console.error("Failed to reset default snippets:", error); + return false; + } + } + + function setSelectedCategory(category: string | null): void { + selectedCategory.set(category); + } + + return { + snippets: { subscribe: snippets.subscribe }, + categories: { subscribe: categories.subscribe }, + filteredSnippets: { subscribe: filteredSnippets.subscribe }, + isLoading: { subscribe: isLoading.subscribe }, + selectedCategory: { subscribe: selectedCategory.subscribe }, + loadSnippets, + saveSnippet, + createSnippet, + updateSnippet, + deleteSnippet, + resetDefaults, + setSelectedCategory, + }; +} + +export const snippetsStore = createSnippetsStore(); -- 2.52.0 From a30b3a282c83e4bd426ce6618df7ec5c5f935657 Mon Sep 17 00:00:00 2001 From: Naomi Carrigan Date: Sun, 25 Jan 2026 15:28:06 -0800 Subject: [PATCH 07/21] feat: add session import/export functionality - Add HTML export with dark/light theme support and styled message types - Add PDF export via print-optimized HTML opened in browser - Include metadata toggle option for all export formats - Add export buttons to SessionHistoryPanel dropdown Closes #34 --- src/lib/components/SessionHistoryPanel.svelte | 22 + src/lib/stores/sessions.ts | 393 ++++++++++++++++++ 2 files changed, 415 insertions(+) diff --git a/src/lib/components/SessionHistoryPanel.svelte b/src/lib/components/SessionHistoryPanel.svelte index f22995c..4d0642e 100644 --- a/src/lib/components/SessionHistoryPanel.svelte +++ b/src/lib/components/SessionHistoryPanel.svelte @@ -102,6 +102,16 @@ await sessionsStore.exportSessionAsMarkdown(sessionId); } + async function handleExportHtml(sessionId: string): Promise { + showExportMenu = null; + await sessionsStore.exportSessionAsHtml(sessionId); + } + + async function handleExportPdf(sessionId: string): Promise { + showExportMenu = null; + await sessionsStore.exportSessionAsPdf(sessionId); + } + async function handleImport(): Promise { isImporting = true; try { @@ -361,6 +371,18 @@ > Export as Markdown + + {/if} diff --git a/src/lib/stores/sessions.ts b/src/lib/stores/sessions.ts index c28f3d9..02c5c19 100644 --- a/src/lib/stores/sessions.ts +++ b/src/lib/stores/sessions.ts @@ -3,6 +3,7 @@ import { invoke } from "@tauri-apps/api/core"; import { save, open } from "@tauri-apps/plugin-dialog"; import type { Conversation } from "./conversations"; import { writeTextFile, readTextFile } from "@tauri-apps/plugin-fs"; +import { openPath } from "@tauri-apps/plugin-opener"; // Debounce delay for auto-save (in milliseconds) const AUTO_SAVE_DEBOUNCE_MS = 2000; @@ -36,6 +37,335 @@ export interface SessionListItem { preview: string; } +function escapeHtml(text: string): string { + return text + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} + +function generateHtmlExport(session: SavedSession, includeMetadata: boolean): string { + const styles = ` + :root { + --bg-primary: #1a1a2e; + --bg-secondary: #16213e; + --bg-code: #1e1e2e; + --accent-primary: #e94560; + --text-primary: #ffffff; + --text-secondary: #a0a0a0; + --border-color: #2a2a4a; + --terminal-user: #22d3ee; + --terminal-tool: #c084fc; + --terminal-error: #f87171; + --hljs-keyword: #f472b6; + --hljs-string: #a3e635; + --hljs-number: #fbbf24; + --hljs-comment: #6b7280; + --hljs-function: #c084fc; + --hljs-type: #22d3ee; + } + * { box-sizing: border-box; margin: 0; padding: 0; } + body { + font-family: 'Segoe UI', system-ui, -apple-system, sans-serif; + background: var(--bg-primary); + color: var(--text-primary); + line-height: 1.6; + padding: 2rem; + max-width: 900px; + margin: 0 auto; + } + h1 { color: var(--accent-primary); margin-bottom: 1rem; } + .metadata { + background: var(--bg-secondary); + padding: 1rem; + border-radius: 8px; + margin-bottom: 2rem; + border: 1px solid var(--border-color); + } + .metadata p { color: var(--text-secondary); margin: 0.25rem 0; } + .metadata strong { color: var(--text-primary); } + hr { border: none; border-top: 1px solid var(--border-color); margin: 1.5rem 0; } + .message { + background: var(--bg-secondary); + border-radius: 8px; + padding: 1rem; + margin-bottom: 1rem; + border-left: 4px solid var(--border-color); + } + .message.user { border-left-color: var(--terminal-user); } + .message.assistant { border-left-color: var(--accent-primary); } + .message.tool_use { border-left-color: var(--terminal-tool); } + .message.tool_result { border-left-color: var(--hljs-string); } + .message.error { border-left-color: var(--terminal-error); } + .message.system { border-left-color: var(--text-secondary); } + .message-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 0.5rem; + padding-bottom: 0.5rem; + border-bottom: 1px solid var(--border-color); + } + .message-type { + font-weight: bold; + text-transform: capitalize; + } + .message-type.user { color: var(--terminal-user); } + .message-type.assistant { color: var(--accent-primary); } + .message-type.tool_use { color: var(--terminal-tool); } + .message-type.tool_result { color: var(--hljs-string); } + .message-type.error { color: var(--terminal-error); } + .message-type.system { color: var(--text-secondary); } + .timestamp { color: var(--text-secondary); font-size: 0.85rem; } + .tool-name { color: var(--terminal-tool); font-size: 0.9rem; } + .message-content { + white-space: pre-wrap; + word-wrap: break-word; + font-family: 'Consolas', 'Monaco', monospace; + font-size: 0.9rem; + } + pre { + background: var(--bg-code); + padding: 1rem; + border-radius: 4px; + overflow-x: auto; + margin: 0.5rem 0; + } + code { + font-family: 'Consolas', 'Monaco', monospace; + font-size: 0.85rem; + } + .footer { + text-align: center; + color: var(--text-secondary); + margin-top: 2rem; + padding-top: 1rem; + border-top: 1px solid var(--border-color); + } + @media (prefers-color-scheme: light) { + :root { + --bg-primary: #f8f9fa; + --bg-secondary: #ffffff; + --bg-code: #f5f5f5; + --text-primary: #1a1a2e; + --text-secondary: #5a5a7a; + --border-color: #d0d0e0; + --terminal-user: #0891b2; + --terminal-tool: #7c3aed; + --terminal-error: #dc2626; + --hljs-keyword: #d946ef; + --hljs-string: #16a34a; + } + } + `; + + let messagesHtml = ""; + for (const message of session.messages) { + const timestamp = new Date(message.timestamp).toLocaleString(); + const typeLabel = + message.type === "tool_use" + ? "Tool Use" + : message.type === "tool_result" + ? "Tool Result" + : message.type.charAt(0).toUpperCase() + message.type.slice(1); + + messagesHtml += ` +
+
+ ${typeLabel} + ${timestamp} +
+ ${message.tool_name ? `
🔧 ${escapeHtml(message.tool_name)}
` : ""} +
${escapeHtml(message.content)}
+
+ `; + } + + const metadataHtml = includeMetadata + ? ` + + ` + : ""; + + return ` + + + + + ${escapeHtml(session.name)} - Hikari Desktop Export + + + +

💬 ${escapeHtml(session.name)}

+ ${metadataHtml} +
+ ${messagesHtml} + + +`; +} + +function generatePrintableHtml(session: SavedSession, includeMetadata: boolean): string { + const printStyles = ` + * { box-sizing: border-box; margin: 0; padding: 0; } + body { + font-family: 'Georgia', 'Times New Roman', serif; + font-size: 11pt; + line-height: 1.5; + padding: 0.5in; + max-width: 8.5in; + margin: 0 auto; + color: #000; + background: #fff; + } + h1 { + font-size: 18pt; + margin-bottom: 0.25in; + border-bottom: 2px solid #333; + padding-bottom: 0.1in; + } + .metadata { + background: #f5f5f5; + padding: 0.15in; + margin-bottom: 0.25in; + border: 1px solid #ddd; + font-size: 9pt; + } + .metadata p { margin: 2pt 0; } + hr { border: none; border-top: 1px solid #ccc; margin: 0.2in 0; } + .message { + margin-bottom: 0.15in; + padding: 0.1in; + border-left: 3px solid #ccc; + page-break-inside: avoid; + } + .message.user { border-left-color: #0088cc; } + .message.assistant { border-left-color: #cc0044; } + .message.tool_use { border-left-color: #8844cc; } + .message.tool_result { border-left-color: #44cc44; } + .message.error { border-left-color: #cc4444; } + .message.system { border-left-color: #888; } + .message-header { + display: flex; + justify-content: space-between; + font-size: 9pt; + color: #666; + margin-bottom: 0.05in; + border-bottom: 1px dotted #ddd; + padding-bottom: 0.03in; + } + .message-type { font-weight: bold; text-transform: uppercase; } + .message-type.user { color: #0066aa; } + .message-type.assistant { color: #aa0033; } + .message-type.tool_use { color: #6633aa; } + .message-type.tool_result { color: #33aa33; } + .message-type.error { color: #aa3333; } + .tool-name { color: #666; font-size: 9pt; font-style: italic; } + .message-content { + white-space: pre-wrap; + word-wrap: break-word; + font-family: 'Consolas', 'Courier New', monospace; + font-size: 9pt; + background: #fafafa; + padding: 0.05in; + } + .footer { + text-align: center; + font-size: 8pt; + color: #999; + margin-top: 0.25in; + padding-top: 0.1in; + border-top: 1px solid #ddd; + } + .print-instructions { + background: #ffffcc; + border: 1px solid #cccc00; + padding: 0.15in; + margin-bottom: 0.25in; + font-size: 10pt; + } + .print-instructions h2 { font-size: 12pt; margin-bottom: 0.1in; } + .print-instructions ol { margin-left: 0.2in; } + .print-instructions li { margin: 0.05in 0; } + @media print { + .print-instructions { display: none; } + body { padding: 0; } + } + @page { margin: 0.5in; } + `; + + let messagesHtml = ""; + for (const message of session.messages) { + const timestamp = new Date(message.timestamp).toLocaleString(); + const typeLabel = + message.type === "tool_use" + ? "Tool" + : message.type === "tool_result" + ? "Result" + : message.type.charAt(0).toUpperCase() + message.type.slice(1); + + messagesHtml += ` +
+
+ ${typeLabel} + ${timestamp} +
+ ${message.tool_name ? `
${escapeHtml(message.tool_name)}
` : ""} +
${escapeHtml(message.content)}
+
+ `; + } + + const metadataHtml = includeMetadata + ? ` + + ` + : ""; + + return ` + + + + + ${escapeHtml(session.name)} - Print to PDF + + + + +

${escapeHtml(session.name)}

+ ${metadataHtml} +
+ ${messagesHtml} + + +`; +} + function createSessionsStore() { const sessions = writable([]); const isLoading = writable(false); @@ -260,6 +590,67 @@ function createSessionsStore() { } } + async function exportSessionAsHtml( + sessionId: string, + includeMetadata: boolean = true + ): Promise { + try { + const session = await loadSession(sessionId); + if (!session) { + console.error("Session not found for export"); + return false; + } + + const filePath = await save({ + defaultPath: `hikari-session-${session.name.replace(/[^a-zA-Z0-9]/g, "-")}.html`, + filters: [{ name: "HTML", extensions: ["html"] }], + }); + + if (!filePath) { + return false; + } + + const html = generateHtmlExport(session, includeMetadata); + await writeTextFile(filePath, html); + return true; + } catch (error) { + console.error("Failed to export session as HTML:", error); + return false; + } + } + + async function exportSessionAsPdf( + sessionId: string, + includeMetadata: boolean = true + ): Promise { + try { + const session = await loadSession(sessionId); + if (!session) { + console.error("Session not found for export"); + return false; + } + + const filePath = await save({ + defaultPath: `hikari-session-${session.name.replace(/[^a-zA-Z0-9]/g, "-")}-print.html`, + filters: [{ name: "HTML (for PDF printing)", extensions: ["html"] }], + }); + + if (!filePath) { + return false; + } + + const html = generatePrintableHtml(session, includeMetadata); + await writeTextFile(filePath, html); + + // Open the file in the default browser for print-to-PDF + await openPath(filePath); + return true; + } catch (error) { + console.error("Failed to export session for PDF:", error); + return false; + } + } + async function importSession(): Promise { try { const filePath = await open({ @@ -308,6 +699,8 @@ function createSessionsStore() { cancelAutoSave, exportSessionAsJson, exportSessionAsMarkdown, + exportSessionAsHtml, + exportSessionAsPdf, importSession, }; } -- 2.52.0 From 87cf6335649ca8cec1323f427034a90a3762b7a4 Mon Sep 17 00:00:00 2001 From: Hikari Date: Sun, 25 Jan 2026 16:04:09 -0800 Subject: [PATCH 08/21] feat: add git integration panel - Add Rust backend for git operations (status, diff, branches, commit, push, pull, fetch, stage, unstage, discard, create branch) - Add GitPanel.svelte component with three tabs: Changes, Branches, History - Integrate git button into StatusBar with link icon - Use consistent accent color theming for buttons --- src-tauri/src/git.rs | 288 +++++++ src-tauri/src/lib.rs | 16 + src/lib/components/GitPanel.svelte | 1123 +++++++++++++++++++++++++++ src/lib/components/StatusBar.svelte | 20 + 4 files changed, 1447 insertions(+) create mode 100644 src-tauri/src/git.rs create mode 100644 src/lib/components/GitPanel.svelte diff --git a/src-tauri/src/git.rs b/src-tauri/src/git.rs new file mode 100644 index 0000000..96ea1a4 --- /dev/null +++ b/src-tauri/src/git.rs @@ -0,0 +1,288 @@ +use serde::{Deserialize, Serialize}; +use std::process::Command; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GitStatus { + pub is_repo: bool, + pub branch: Option, + pub upstream: Option, + pub ahead: u32, + pub behind: u32, + pub staged: Vec, + pub unstaged: Vec, + pub untracked: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GitFileChange { + pub path: String, + pub status: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GitBranch { + pub name: String, + pub is_current: bool, + pub is_remote: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GitLogEntry { + pub hash: String, + pub short_hash: String, + pub author: String, + pub date: String, + pub message: String, +} + +fn run_git_command(working_dir: &str, args: &[&str]) -> Result { + let output = Command::new("git") + .args(args) + .current_dir(working_dir) + .output() + .map_err(|e| format!("Failed to execute git: {}", e))?; + + if output.status.success() { + Ok(String::from_utf8_lossy(&output.stdout).to_string()) + } else { + Err(String::from_utf8_lossy(&output.stderr).to_string()) + } +} + +#[tauri::command] +pub fn git_status(working_dir: String) -> Result { + // Check if it's a git repo + let is_repo = run_git_command(&working_dir, &["rev-parse", "--git-dir"]).is_ok(); + + if !is_repo { + return Ok(GitStatus { + is_repo: false, + branch: None, + upstream: None, + ahead: 0, + behind: 0, + staged: vec![], + unstaged: vec![], + untracked: vec![], + }); + } + + // Get current branch + let branch = run_git_command(&working_dir, &["rev-parse", "--abbrev-ref", "HEAD"]) + .ok() + .map(|s| s.trim().to_string()); + + // Get upstream branch + let upstream = run_git_command( + &working_dir, + &["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"], + ) + .ok() + .map(|s| s.trim().to_string()); + + // Get ahead/behind counts + let (ahead, behind) = if upstream.is_some() { + let rev_list = + run_git_command(&working_dir, &["rev-list", "--left-right", "--count", "@{u}...HEAD"]) + .unwrap_or_default(); + let parts: Vec<&str> = rev_list.trim().split('\t').collect(); + if parts.len() == 2 { + ( + parts[1].parse().unwrap_or(0), + parts[0].parse().unwrap_or(0), + ) + } else { + (0, 0) + } + } else { + (0, 0) + }; + + // Get status with porcelain format + let status_output = + run_git_command(&working_dir, &["status", "--porcelain=v1"]).unwrap_or_default(); + + let mut staged = vec![]; + let mut unstaged = vec![]; + let mut untracked = vec![]; + + for line in status_output.lines() { + if line.len() < 3 { + continue; + } + + let index_status = line.chars().next().unwrap_or(' '); + let worktree_status = line.chars().nth(1).unwrap_or(' '); + let path = line[3..].to_string(); + + // Untracked files + if index_status == '?' && worktree_status == '?' { + untracked.push(path); + continue; + } + + // Staged changes (index status) + if index_status != ' ' && index_status != '?' { + staged.push(GitFileChange { + path: path.clone(), + status: match index_status { + 'M' => "modified".to_string(), + 'A' => "added".to_string(), + 'D' => "deleted".to_string(), + 'R' => "renamed".to_string(), + 'C' => "copied".to_string(), + _ => "unknown".to_string(), + }, + }); + } + + // Unstaged changes (worktree status) + if worktree_status != ' ' && worktree_status != '?' { + unstaged.push(GitFileChange { + path, + status: match worktree_status { + 'M' => "modified".to_string(), + 'D' => "deleted".to_string(), + _ => "unknown".to_string(), + }, + }); + } + } + + Ok(GitStatus { + is_repo: true, + branch, + upstream, + ahead, + behind, + staged, + unstaged, + untracked, + }) +} + +#[tauri::command] +pub fn git_diff(working_dir: String, file_path: Option, staged: bool) -> Result { + let mut args = vec!["diff"]; + + if staged { + args.push("--cached"); + } + + if let Some(ref path) = file_path { + args.push("--"); + args.push(path); + } + + run_git_command(&working_dir, &args) +} + +#[tauri::command] +pub fn git_branches(working_dir: String) -> Result, String> { + let output = run_git_command(&working_dir, &["branch", "-a", "--format=%(refname:short)\t%(HEAD)"])?; + + let branches: Vec = output + .lines() + .filter_map(|line| { + let parts: Vec<&str> = line.split('\t').collect(); + if parts.is_empty() { + return None; + } + + let name = parts[0].to_string(); + let is_current = parts.get(1).map(|s| *s == "*").unwrap_or(false); + let is_remote = name.starts_with("remotes/") || name.starts_with("origin/"); + + Some(GitBranch { + name, + is_current, + is_remote, + }) + }) + .collect(); + + Ok(branches) +} + +#[tauri::command] +pub fn git_checkout(working_dir: String, branch: String) -> Result { + run_git_command(&working_dir, &["checkout", &branch]) +} + +#[tauri::command] +pub fn git_stage(working_dir: String, file_path: String) -> Result { + run_git_command(&working_dir, &["add", &file_path]) +} + +#[tauri::command] +pub fn git_unstage(working_dir: String, file_path: String) -> Result { + run_git_command(&working_dir, &["restore", "--staged", &file_path]) +} + +#[tauri::command] +pub fn git_stage_all(working_dir: String) -> Result { + run_git_command(&working_dir, &["add", "-A"]) +} + +#[tauri::command] +pub fn git_commit(working_dir: String, message: String) -> Result { + run_git_command(&working_dir, &["commit", "-m", &message]) +} + +#[tauri::command] +pub fn git_push(working_dir: String) -> Result { + run_git_command(&working_dir, &["push"]) +} + +#[tauri::command] +pub fn git_pull(working_dir: String) -> Result { + run_git_command(&working_dir, &["pull"]) +} + +#[tauri::command] +pub fn git_fetch(working_dir: String) -> Result { + run_git_command(&working_dir, &["fetch", "--all"]) +} + +#[tauri::command] +pub fn git_log(working_dir: String, limit: Option) -> Result, String> { + let limit_str = limit.unwrap_or(10).to_string(); + let output = run_git_command( + &working_dir, + &[ + "log", + &format!("-{}", limit_str), + "--pretty=format:%H\t%h\t%an\t%ar\t%s", + ], + )?; + + let entries: Vec = output + .lines() + .filter_map(|line| { + let parts: Vec<&str> = line.split('\t').collect(); + if parts.len() < 5 { + return None; + } + + Some(GitLogEntry { + hash: parts[0].to_string(), + short_hash: parts[1].to_string(), + author: parts[2].to_string(), + date: parts[3].to_string(), + message: parts[4..].join("\t"), + }) + }) + .collect(); + + Ok(entries) +} + +#[tauri::command] +pub fn git_discard(working_dir: String, file_path: String) -> Result { + run_git_command(&working_dir, &["checkout", "--", &file_path]) +} + +#[tauri::command] +pub fn git_create_branch(working_dir: String, branch_name: String) -> Result { + run_git_command(&working_dir, &["checkout", "-b", &branch_name]) +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 96966e1..16ab2df 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -2,6 +2,7 @@ mod achievements; mod bridge_manager; mod commands; mod config; +mod git; mod notifications; mod sessions; mod snippets; @@ -17,6 +18,7 @@ mod wsl_notifications; use bridge_manager::create_shared_bridge_manager; use commands::load_saved_achievements; use commands::*; +use git::*; use notifications::*; use sessions::*; use snippets::*; @@ -118,6 +120,20 @@ pub fn run() { delete_snippet, get_snippet_categories, reset_default_snippets, + git_status, + git_diff, + git_branches, + git_checkout, + git_stage, + git_unstage, + git_stage_all, + git_commit, + git_push, + git_pull, + git_fetch, + git_log, + git_discard, + git_create_branch, ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); diff --git a/src/lib/components/GitPanel.svelte b/src/lib/components/GitPanel.svelte new file mode 100644 index 0000000..adf4377 --- /dev/null +++ b/src/lib/components/GitPanel.svelte @@ -0,0 +1,1123 @@ + + + + +{#if isOpen} + + + {#if showDiff} +
(showDiff = false)} role="presentation"> + +
+ {/if} +{/if} + + diff --git a/src/lib/components/StatusBar.svelte b/src/lib/components/StatusBar.svelte index 543f674..533d6d8 100644 --- a/src/lib/components/StatusBar.svelte +++ b/src/lib/components/StatusBar.svelte @@ -20,6 +20,7 @@ import KeyboardShortcutsModal from "./KeyboardShortcutsModal.svelte"; import { achievementProgress } from "$lib/stores/achievements"; import SessionHistoryPanel from "./SessionHistoryPanel.svelte"; + import GitPanel from "./GitPanel.svelte"; const DISCORD_URL = "https://chat.nhcarrigan.com"; const DONATE_URL = "https://donate.nhcarrigan.com"; @@ -35,6 +36,7 @@ let showHelp = $state(false); let showKeyboardShortcuts = $state(false); let showSessionHistory = $state(false); + let showGitPanel = $state(false); const progress = $derived($achievementProgress); let currentConfig: HikariConfig = $state({ model: null, @@ -233,6 +235,20 @@ /> + +
@@ -735,29 +817,6 @@ User: ${formattedMessage}`;
- @@ -293,7 +293,7 @@

No quick actions available

diff --git a/src/lib/components/SessionHistoryPanel.svelte b/src/lib/components/SessionHistoryPanel.svelte index 4d0642e..9c782b1 100644 --- a/src/lib/components/SessionHistoryPanel.svelte +++ b/src/lib/components/SessionHistoryPanel.svelte @@ -335,7 +335,7 @@ > @@ -319,7 +319,7 @@

No snippets in this category

@@ -350,7 +350,7 @@ > diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index 00d2494..3c22695 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -13,6 +13,8 @@ import InputBar from "$lib/components/InputBar.svelte"; import StatusBar from "$lib/components/StatusBar.svelte"; import AnimeGirl from "$lib/components/AnimeGirl.svelte"; + import { characterState } from "$lib/stores/character"; + import type { CharacterState } from "$lib/types/states"; import PermissionModal from "$lib/components/PermissionModal.svelte"; import UserQuestionModal from "$lib/components/UserQuestionModal.svelte"; import ConfigSidebar from "$lib/components/ConfigSidebar.svelte"; @@ -23,6 +25,32 @@ let initialized = false; let updateNotification: UpdateNotification; let achievementPanelOpen = $state(false); + let currentCharacterState: CharacterState = $state("idle"); + + characterState.subscribe((state) => { + currentCharacterState = state; + }); + + function getPanelGlowClass(): string { + switch (currentCharacterState) { + case "thinking": + return "panel-glow-thinking"; + case "typing": + return "panel-glow-typing"; + case "searching": + return "panel-glow-searching"; + case "coding": + return "panel-glow-coding"; + case "mcp": + return "panel-glow-mcp"; + case "success": + return "panel-glow-success"; + case "error": + return "panel-glow-error"; + default: + return ""; + } + } // Resizable panel state let panelWidth = $state(320); // Default width in pixels @@ -190,7 +218,7 @@
@@ -235,6 +263,158 @@ .character-panel { background: linear-gradient(180deg, var(--bg-secondary) 0%, var(--bg-primary) 100%); + transition: all 0.5s ease; + position: relative; + } + + .character-panel::before { + content: ""; + position: absolute; + inset: 0; + padding: 3px; + background: transparent; + -webkit-mask: + linear-gradient(#fff 0 0) content-box, + linear-gradient(#fff 0 0); + -webkit-mask-composite: xor; + mask-composite: exclude; + opacity: 0; + transition: opacity 0.5s ease; + pointer-events: none; + } + + /* Trans pride gradient glow effects for the character panel */ + .panel-glow-thinking { + background: linear-gradient( + 180deg, + color-mix(in srgb, var(--bg-secondary) 85%, #9333ea) 0%, + color-mix(in srgb, var(--bg-primary) 90%, var(--trans-blue)) 50%, + color-mix(in srgb, var(--bg-primary) 90%, var(--trans-pink)) 100% + ); + box-shadow: + inset 0 0 60px rgba(147, 51, 234, 0.15), + inset 0 0 100px rgba(91, 206, 250, 0.1), + 0 0 40px rgba(91, 206, 250, 0.2), + 0 0 80px rgba(245, 169, 184, 0.15); + } + + .panel-glow-thinking::before { + background: linear-gradient(180deg, #9333ea, var(--trans-blue), var(--trans-pink)); + opacity: 1; + } + + .panel-glow-typing { + background: linear-gradient( + 180deg, + color-mix(in srgb, var(--bg-secondary) 85%, #3b82f6) 0%, + color-mix(in srgb, var(--bg-primary) 90%, var(--trans-blue)) 50%, + color-mix(in srgb, var(--bg-primary) 90%, var(--trans-pink)) 100% + ); + box-shadow: + inset 0 0 60px rgba(59, 130, 246, 0.15), + inset 0 0 100px rgba(91, 206, 250, 0.15), + 0 0 40px rgba(91, 206, 250, 0.25), + 0 0 80px rgba(245, 169, 184, 0.15); + } + + .panel-glow-typing::before { + background: linear-gradient(180deg, #3b82f6, var(--trans-blue), var(--trans-pink)); + opacity: 1; + } + + .panel-glow-searching { + background: linear-gradient( + 180deg, + color-mix(in srgb, var(--bg-secondary) 85%, #eab308) 0%, + color-mix(in srgb, var(--bg-primary) 90%, var(--trans-blue)) 50%, + color-mix(in srgb, var(--bg-primary) 90%, var(--trans-pink)) 100% + ); + box-shadow: + inset 0 0 60px rgba(234, 179, 8, 0.15), + inset 0 0 100px rgba(91, 206, 250, 0.1), + 0 0 40px rgba(91, 206, 250, 0.2), + 0 0 80px rgba(245, 169, 184, 0.15); + } + + .panel-glow-searching::before { + background: linear-gradient(180deg, #eab308, var(--trans-blue), var(--trans-pink)); + opacity: 1; + } + + .panel-glow-coding { + background: linear-gradient( + 180deg, + color-mix(in srgb, var(--bg-secondary) 85%, #22c55e) 0%, + color-mix(in srgb, var(--bg-primary) 90%, var(--trans-blue)) 50%, + color-mix(in srgb, var(--bg-primary) 90%, var(--trans-pink)) 100% + ); + box-shadow: + inset 0 0 60px rgba(34, 197, 94, 0.15), + inset 0 0 100px rgba(91, 206, 250, 0.1), + 0 0 40px rgba(91, 206, 250, 0.2), + 0 0 80px rgba(245, 169, 184, 0.15); + } + + .panel-glow-coding::before { + background: linear-gradient(180deg, #22c55e, var(--trans-blue), var(--trans-pink)); + opacity: 1; + } + + .panel-glow-mcp { + background: linear-gradient( + 180deg, + color-mix(in srgb, var(--bg-secondary) 80%, var(--trans-blue)) 0%, + color-mix(in srgb, var(--bg-primary) 85%, var(--trans-pink)) 50%, + color-mix(in srgb, var(--bg-primary) 90%, var(--trans-blue)) 100% + ); + box-shadow: + inset 0 0 80px rgba(91, 206, 250, 0.2), + inset 0 0 120px rgba(245, 169, 184, 0.15), + 0 0 60px rgba(91, 206, 250, 0.3), + 0 0 100px rgba(245, 169, 184, 0.2); + } + + .panel-glow-mcp::before { + background: var(--trans-gradient-vibrant); + opacity: 1; + } + + .panel-glow-success { + background: linear-gradient( + 180deg, + color-mix(in srgb, var(--bg-secondary) 85%, #10b981) 0%, + color-mix(in srgb, var(--bg-primary) 90%, var(--trans-blue)) 50%, + color-mix(in srgb, var(--bg-primary) 90%, var(--trans-pink)) 100% + ); + box-shadow: + inset 0 0 60px rgba(16, 185, 129, 0.15), + inset 0 0 100px rgba(91, 206, 250, 0.1), + 0 0 40px rgba(91, 206, 250, 0.2), + 0 0 80px rgba(245, 169, 184, 0.15); + } + + .panel-glow-success::before { + background: linear-gradient(180deg, #10b981, var(--trans-blue), var(--trans-pink)); + opacity: 1; + } + + .panel-glow-error { + background: linear-gradient( + 180deg, + color-mix(in srgb, var(--bg-secondary) 80%, #ef4444) 0%, + color-mix(in srgb, var(--bg-primary) 90%, var(--trans-pink)) 50%, + color-mix(in srgb, var(--bg-primary) 95%, var(--trans-blue)) 100% + ); + box-shadow: + inset 0 0 60px rgba(239, 68, 68, 0.2), + inset 0 0 100px rgba(245, 169, 184, 0.1), + 0 0 40px rgba(245, 169, 184, 0.2), + 0 0 80px rgba(239, 68, 68, 0.15); + } + + .panel-glow-error::before { + background: linear-gradient(180deg, #ef4444, var(--trans-pink), var(--trans-blue)); + opacity: 1; } .resize-handle:hover, -- 2.52.0 From 0a73d2238c4d66e0af2169e87d4d561f3f1686ff Mon Sep 17 00:00:00 2001 From: Naomi Carrigan Date: Sun, 25 Jan 2026 17:40:03 -0800 Subject: [PATCH 11/21] feat: add clipboard history for code snippets Implements issue #25 - Clipboard History feature that tracks copied code snippets with language detection, search, and filtering. Backend (Rust): - New clipboard.rs module with persistent storage via tauri-plugin-store - Commands: capture, list, delete, toggle pin, clear, search, update language - Auto-deduplication and max history size (100 entries) - Pinned entries stay at top and persist through clear Frontend (Svelte/TypeScript): - Clipboard store with filtering, search, and language detection - ClipboardHistoryPanel component with search, language filter, pin/delete - Clipboard button added to InputBar next to Snippets/Actions - Auto-capture from code block copy buttons - Auto-capture from manual text selection in terminal - Insert snippets directly into input field Co-Authored-By: Claude Opus 4.5 --- src-tauri/src/clipboard.rs | 259 +++++++++ src-tauri/src/lib.rs | 10 + .../components/ClipboardHistoryPanel.svelte | 497 ++++++++++++++++++ src/lib/components/InputBar.svelte | 58 +- src/lib/components/Markdown.svelte | 7 + src/lib/components/StatusBar.svelte | 4 +- src/lib/components/Terminal.svelte | 29 +- src/lib/stores/clipboard.ts | 230 ++++++++ 8 files changed, 1086 insertions(+), 8 deletions(-) create mode 100644 src-tauri/src/clipboard.rs create mode 100644 src/lib/components/ClipboardHistoryPanel.svelte create mode 100644 src/lib/stores/clipboard.ts diff --git a/src-tauri/src/clipboard.rs b/src-tauri/src/clipboard.rs new file mode 100644 index 0000000..058d9ac --- /dev/null +++ b/src-tauri/src/clipboard.rs @@ -0,0 +1,259 @@ +// Clipboard history module for tracking and managing copied code snippets +// Implements issue #25 - Clipboard History feature + +use serde::{Deserialize, Serialize}; +use std::sync::Mutex; +use tauri_plugin_store::StoreExt; +use uuid::Uuid; + +const STORE_FILE: &str = "hikari-clipboard.json"; +const HISTORY_KEY: &str = "clipboard_history"; +const MAX_HISTORY_SIZE: usize = 100; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ClipboardEntry { + pub id: String, + pub content: String, + pub language: Option, + pub source: Option, + pub timestamp: String, + pub is_pinned: bool, +} + +impl ClipboardEntry { + pub fn new(content: String, language: Option, source: Option) -> Self { + Self { + id: Uuid::new_v4().to_string(), + content, + language, + source, + timestamp: chrono::Utc::now().to_rfc3339(), + is_pinned: false, + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +struct ClipboardHistory { + entries: Vec, +} + +// Track last clipboard content to avoid duplicates +#[derive(Default)] +struct ClipboardState { + last_content: Option, +} + +static CLIPBOARD_STATE: Mutex = Mutex::new(ClipboardState { last_content: None }); + +fn load_history(app: &tauri::AppHandle) -> ClipboardHistory { + let store = app.store(STORE_FILE).ok(); + store + .and_then(|s| s.get(HISTORY_KEY)) + .and_then(|v| serde_json::from_value(v.clone()).ok()) + .unwrap_or_default() +} + +fn save_history(app: &tauri::AppHandle, history: &ClipboardHistory) -> Result<(), String> { + let store = app.store(STORE_FILE).map_err(|e| e.to_string())?; + store.set( + HISTORY_KEY, + serde_json::to_value(history).map_err(|e| e.to_string())?, + ); + store.save().map_err(|e| e.to_string())?; + Ok(()) +} + +/// List all clipboard entries, optionally filtered by language +#[tauri::command] +pub fn list_clipboard_entries( + app: tauri::AppHandle, + language: Option, +) -> Result, String> { + let history = load_history(&app); + let entries = if let Some(lang) = language { + history + .entries + .into_iter() + .filter(|e| e.language.as_ref() == Some(&lang)) + .collect() + } else { + history.entries + }; + Ok(entries) +} + +/// Capture current clipboard content and add to history +#[tauri::command] +pub fn capture_clipboard( + app: tauri::AppHandle, + content: String, + language: Option, + source: Option, +) -> Result { + // Check for duplicate (same content as last capture) + { + let mut state = CLIPBOARD_STATE.lock().map_err(|e| e.to_string())?; + if state.last_content.as_ref() == Some(&content) { + // Return existing entry if content is the same + let history = load_history(&app); + if let Some(entry) = history.entries.first() { + if entry.content == content { + return Ok(entry.clone()); + } + } + } + state.last_content = Some(content.clone()); + } + + let entry = ClipboardEntry::new(content, language, source); + let mut history = load_history(&app); + + // Add to front of history + history.entries.insert(0, entry.clone()); + + // Enforce max size (keep pinned entries) + let mut pinned: Vec = history + .entries + .iter() + .filter(|e| e.is_pinned) + .cloned() + .collect(); + let mut unpinned: Vec = history + .entries + .into_iter() + .filter(|e| !e.is_pinned) + .collect(); + + // Trim unpinned entries if over max size + if unpinned.len() + pinned.len() > MAX_HISTORY_SIZE { + let max_unpinned = MAX_HISTORY_SIZE.saturating_sub(pinned.len()); + unpinned.truncate(max_unpinned); + } + + // Merge back, pinned first then unpinned + pinned.extend(unpinned); + history.entries = pinned; + + // Sort by timestamp descending (newest first), pinned entries stay at top + history.entries.sort_by(|a, b| { + if a.is_pinned && !b.is_pinned { + std::cmp::Ordering::Less + } else if !a.is_pinned && b.is_pinned { + std::cmp::Ordering::Greater + } else { + b.timestamp.cmp(&a.timestamp) + } + }); + + save_history(&app, &history)?; + Ok(entry) +} + +/// Delete a clipboard entry by ID +#[tauri::command] +pub fn delete_clipboard_entry(app: tauri::AppHandle, id: String) -> Result<(), String> { + let mut history = load_history(&app); + history.entries.retain(|e| e.id != id); + save_history(&app, &history)?; + Ok(()) +} + +/// Toggle pin status of an entry +#[tauri::command] +pub fn toggle_pin_clipboard_entry( + app: tauri::AppHandle, + id: String, +) -> Result { + let mut history = load_history(&app); + let entry = history + .entries + .iter_mut() + .find(|e| e.id == id) + .ok_or("Entry not found")?; + + entry.is_pinned = !entry.is_pinned; + let updated_entry = entry.clone(); + + // Re-sort to move pinned entries to top + history.entries.sort_by(|a, b| { + if a.is_pinned && !b.is_pinned { + std::cmp::Ordering::Less + } else if !a.is_pinned && b.is_pinned { + std::cmp::Ordering::Greater + } else { + b.timestamp.cmp(&a.timestamp) + } + }); + + save_history(&app, &history)?; + Ok(updated_entry) +} + +/// Clear all non-pinned entries +#[tauri::command] +pub fn clear_clipboard_history(app: tauri::AppHandle) -> Result<(), String> { + let mut history = load_history(&app); + history.entries.retain(|e| e.is_pinned); + save_history(&app, &history)?; + Ok(()) +} + +/// Search clipboard entries by content +#[tauri::command] +pub fn search_clipboard_entries( + app: tauri::AppHandle, + query: String, +) -> Result, String> { + let history = load_history(&app); + let query_lower = query.to_lowercase(); + let entries = history + .entries + .into_iter() + .filter(|e| { + e.content.to_lowercase().contains(&query_lower) + || e.language + .as_ref() + .is_some_and(|l| l.to_lowercase().contains(&query_lower)) + || e.source + .as_ref() + .is_some_and(|s| s.to_lowercase().contains(&query_lower)) + }) + .collect(); + Ok(entries) +} + +/// Get all unique languages from history +#[tauri::command] +pub fn get_clipboard_languages(app: tauri::AppHandle) -> Result, String> { + let history = load_history(&app); + let mut languages: Vec = history + .entries + .iter() + .filter_map(|e| e.language.clone()) + .collect(); + languages.sort(); + languages.dedup(); + Ok(languages) +} + +/// Update the language of an entry +#[tauri::command] +pub fn update_clipboard_language( + app: tauri::AppHandle, + id: String, + language: Option, +) -> Result { + let mut history = load_history(&app); + let entry = history + .entries + .iter_mut() + .find(|e| e.id == id) + .ok_or("Entry not found")?; + + entry.language = language; + let updated_entry = entry.clone(); + + save_history(&app, &history)?; + Ok(updated_entry) +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index fc3e0b3..e7b4f98 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,5 +1,6 @@ mod achievements; mod bridge_manager; +mod clipboard; mod commands; mod config; mod git; @@ -17,6 +18,7 @@ mod wsl_bridge; mod wsl_notifications; use bridge_manager::create_shared_bridge_manager; +use clipboard::*; use commands::load_saved_achievements; use commands::*; use git::*; @@ -140,6 +142,14 @@ pub fn run() { git_log, git_discard, git_create_branch, + list_clipboard_entries, + capture_clipboard, + delete_clipboard_entry, + toggle_pin_clipboard_entry, + clear_clipboard_history, + search_clipboard_entries, + get_clipboard_languages, + update_clipboard_language, ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); diff --git a/src/lib/components/ClipboardHistoryPanel.svelte b/src/lib/components/ClipboardHistoryPanel.svelte new file mode 100644 index 0000000..ddbfa3c --- /dev/null +++ b/src/lib/components/ClipboardHistoryPanel.svelte @@ -0,0 +1,497 @@ + + +{#if isOpen} + + +
+
+
+

📋 Clipboard History

+
+ {#if entries.length > 0} + + {/if} + +
+
+ +
+ +
+ + {#each languages as lang (lang)} + + {/each} +
+
+ +
+ {#if isLoading} +
Loading...
+ {:else if entries.length === 0} +
+

📭 No clipboard entries yet

+

+ Copy code from Claude's responses or use the copy button on code blocks to save them + here. +

+
+ {:else} +
+ {#each entries as entry (entry.id)} +
+
+ +
+ + + + {#if confirmingDeleteId === entry.id} + + + {:else} + + {/if} +
+
+
{truncateContent(entry.content)}
+ {#if entry.source} +
From: {entry.source}
+ {/if} +
+ {/each} +
+ {/if} +
+
+
+{/if} + + diff --git a/src/lib/components/InputBar.svelte b/src/lib/components/InputBar.svelte index 346f826..b8f82cc 100644 --- a/src/lib/components/InputBar.svelte +++ b/src/lib/components/InputBar.svelte @@ -7,6 +7,7 @@ import { characterState } from "$lib/stores/character"; import { handleNewUserMessage } from "$lib/notifications/rules"; import { setSkipNextGreeting } from "$lib/tauri"; + import { clipboardStore } from "$lib/stores/clipboard"; import { setShouldRestoreHistory, setSavedHistory, @@ -27,6 +28,7 @@ import AttachmentPreview from "$lib/components/AttachmentPreview.svelte"; import SnippetLibraryPanel from "$lib/components/SnippetLibraryPanel.svelte"; import QuickActionsPanel from "$lib/components/QuickActionsPanel.svelte"; + import ClipboardHistoryPanel from "$lib/components/ClipboardHistoryPanel.svelte"; import type { Attachment } from "$lib/types/messages"; const INPUT_HISTORY_KEY = "hikari-input-history"; @@ -43,6 +45,7 @@ let isDragging = $state(false); let showSnippetLibrary = $state(false); let showQuickActions = $state(false); + let showClipboardHistory = $state(false); // Input history state let inputHistory = $state([]); @@ -504,6 +507,15 @@ User: ${formattedMessage}`; const items = event.clipboardData?.items; let handledFile = false; + // Also capture text content to clipboard history + const textContent = event.clipboardData?.getData("text/plain"); + if (textContent && textContent.trim().length > 0) { + // Only capture multi-line or longer text (likely code snippets) + if (textContent.includes("\n") || textContent.length > 50) { + clipboardStore.captureClipboard(textContent, null, "Pasted into chat"); + } + } + if (items && items.length > 0) { for (const item of items) { if (item.kind === "file") { @@ -631,6 +643,16 @@ User: ${formattedMessage}`; userHasTyped = true; } + function handleClipboardInsert(content: string): void { + // Insert clipboard content at cursor position or append to input + if (inputValue.trim()) { + inputValue = inputValue + "\n\n" + content; + } else { + inputValue = content; + } + userHasTyped = true; + } + async function handleQuickAction(prompt: string): Promise { // Quick actions send the prompt directly if (!isConnected || isSubmitting) return; @@ -787,6 +809,27 @@ User: ${formattedMessage}`; Snippets +
@@ -870,9 +913,14 @@ User: ${formattedMessage}`; {/if} {#if showQuickActions} - (showQuickActions = false)} - onAction={handleQuickAction} + (showQuickActions = false)} onAction={handleQuickAction} /> +{/if} + +{#if showClipboardHistory} + (showClipboardHistory = false)} + onInsert={handleClipboardInsert} /> {/if} @@ -1043,6 +1091,8 @@ User: ${formattedMessage}`; .trans-gradient-button:hover:not(:disabled) { filter: brightness(1.1); - box-shadow: 0 0 20px rgba(91, 206, 250, 0.4), 0 0 30px rgba(245, 169, 184, 0.3); + box-shadow: + 0 0 20px rgba(91, 206, 250, 0.4), + 0 0 30px rgba(245, 169, 184, 0.3); } diff --git a/src/lib/components/Markdown.svelte b/src/lib/components/Markdown.svelte index 81f94da..671ae48 100644 --- a/src/lib/components/Markdown.svelte +++ b/src/lib/components/Markdown.svelte @@ -3,6 +3,7 @@ import hljs from "highlight.js"; import { onMount } from "svelte"; import { openUrl } from "@tauri-apps/plugin-opener"; + import { clipboardStore } from "$lib/stores/clipboard"; interface Props { content: string; @@ -147,6 +148,12 @@ .replace(/>/g, ">"); if (code) { await navigator.clipboard.writeText(code); + + // Capture to clipboard history + const langElement = copyBtn.parentElement?.querySelector(".code-block-lang"); + const language = langElement?.textContent || null; + await clipboardStore.captureClipboard(code, language, "Claude response"); + const textSpan = copyBtn.querySelector(".copy-text"); if (textSpan) { textSpan.textContent = "Copied!"; diff --git a/src/lib/components/StatusBar.svelte b/src/lib/components/StatusBar.svelte index bf85829..43fd75a 100644 --- a/src/lib/components/StatusBar.svelte +++ b/src/lib/components/StatusBar.svelte @@ -251,9 +251,7 @@ +
{:else} - {/if} diff --git a/src/lib/components/QuickActionsPanel.svelte b/src/lib/components/QuickActionsPanel.svelte index e1d6526..0afdc01 100644 --- a/src/lib/components/QuickActionsPanel.svelte +++ b/src/lib/components/QuickActionsPanel.svelte @@ -228,7 +228,13 @@ : 'bg-[var(--bg-secondary)] border-[var(--border-color)] text-[var(--text-secondary)] hover:border-[var(--accent-primary)]/50'}" title={icon.label} > - + + {@html getIconSvg(icon.id)} @@ -304,15 +310,13 @@
- + {#if config.streamer_mode} + + {:else} + + + {/if}
@@ -519,6 +534,45 @@ + +
+

+ Privacy / Streamer Mode +

+ + +
+ +

+ Hide sensitive information like API keys when streaming (Ctrl+Shift+S to toggle) +

+
+ + + {#if config.streamer_mode} +
+ +

+ Mask directory paths (e.g., /home/user → /home/****) +

+
+ {/if} +
+

diff --git a/src/lib/components/InputBar.svelte b/src/lib/components/InputBar.svelte index b8f82cc..d798985 100644 --- a/src/lib/components/InputBar.svelte +++ b/src/lib/components/InputBar.svelte @@ -25,6 +25,7 @@ isSlashCommand, type SlashCommand, } from "$lib/commands/slashCommands"; + import { configStore, isStreamerMode } from "$lib/stores/config"; import AttachmentPreview from "$lib/components/AttachmentPreview.svelte"; import SnippetLibraryPanel from "$lib/components/SnippetLibraryPanel.svelte"; import QuickActionsPanel from "$lib/components/QuickActionsPanel.svelte"; @@ -46,6 +47,11 @@ let showSnippetLibrary = $state(false); let showQuickActions = $state(false); let showClipboardHistory = $state(false); + let streamerModeActive = $state(false); + + isStreamerMode.subscribe((value) => { + streamerModeActive = value; + }); // Input history state let inputHistory = $state([]); @@ -765,6 +771,34 @@ User: ${formattedMessage}`;
+
+ {#if streamerModeActive} +
+ {/if}
{/each} diff --git a/src/lib/stores/config.ts b/src/lib/stores/config.ts index 86b069c..afdecd0 100644 --- a/src/lib/stores/config.ts +++ b/src/lib/stores/config.ts @@ -19,6 +19,8 @@ export interface HikariConfig { update_checks_enabled: boolean; character_panel_width: number | null; font_size: number; + streamer_mode: boolean; + streamer_hide_paths: boolean; } const defaultConfig: HikariConfig = { @@ -37,6 +39,8 @@ const defaultConfig: HikariConfig = { update_checks_enabled: true, character_panel_width: null, font_size: 14, + streamer_mode: false, + streamer_hide_paths: false, }; function createConfigStore() { @@ -145,6 +149,12 @@ function createConfigStore() { config.subscribe((c) => (currentConfig = c))(); return currentConfig; }, + + toggleStreamerMode: async () => { + let currentConfig: HikariConfig = defaultConfig; + config.subscribe((c) => (currentConfig = c))(); + await updateConfig({ streamer_mode: !currentConfig.streamer_mode }); + }, }; } @@ -174,3 +184,25 @@ export { MIN_FONT_SIZE, MAX_FONT_SIZE, DEFAULT_FONT_SIZE }; export const configStore = createConfigStore(); export const isDarkTheme = derived(configStore.config, ($config) => $config.theme === "dark"); + +export const isStreamerMode = derived(configStore.config, ($config) => $config.streamer_mode); +export const shouldHidePaths = derived( + configStore.config, + ($config) => $config.streamer_mode && $config.streamer_hide_paths +); + +/** + * Masks file paths in text when streamer mode with hide paths is enabled. + * Replaces username portion of paths with asterisks. + */ +export function maskPaths(text: string, hidePaths: boolean): string { + if (!hidePaths) return text; + + // Match Unix paths like /home/username/... or /Users/username/... + // and Windows paths like C:\Users\username\... + return text + .replace(/\/home\/([^\/\s]+)/g, "/home/****") + .replace(/\/Users\/([^\/\s]+)/g, "/Users/****") + .replace(/C:\\Users\\([^\\\s]+)/gi, "C:\\Users\\****") + .replace(/~\//g, "****/"); +} diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index 3c22695..169c5a1 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -149,6 +149,13 @@ configStore.resetFontSize(); return; } + + // Ctrl+Shift+S - Toggle streamer mode + if (event.ctrlKey && event.shiftKey && event.key === "S") { + event.preventDefault(); + configStore.toggleStreamerMode(); + return; + } } async function handleInterrupt() { -- 2.52.0 From c5e0d5302c84d7974bdd27c51456a0f846705bdb Mon Sep 17 00:00:00 2001 From: Hikari Date: Sun, 25 Jan 2026 18:51:39 -0800 Subject: [PATCH 14/21] feat: add compact mode for minimal widget interface (#36) Add a compact mode that shrinks the window to a small widget showing just the character sprite, recent messages, and a quick input box. Perfect for quick questions while working without the full UI. - Add CompactMode.svelte component with minimal widget interface - Add compact mode toggle in StatusBar (Ctrl+Shift+M shortcut) - Save/restore window size when toggling compact mode - Handle display scaling by converting physical to logical pixels - Add compact_mode to config (Rust + TypeScript) - Add required Tauri window permissions for resize operations --- src-tauri/capabilities/default.json | 5 +- src-tauri/src/config.rs | 8 + src/lib/components/CompactMode.svelte | 560 ++++++++++++++++++ src/lib/components/ConfigSidebar.svelte | 1 + .../components/KeyboardShortcutsModal.svelte | 2 + src/lib/components/StatusBar.svelte | 18 +- src/lib/stores/config.ts | 13 + src/routes/+page.svelte | 160 +++-- 8 files changed, 730 insertions(+), 37 deletions(-) create mode 100644 src/lib/components/CompactMode.svelte diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index 5caf529..00d73b4 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -19,6 +19,9 @@ "core:tray:default", "fs:default", "fs:allow-read-text-file", - "fs:allow-write-text-file" + "fs:allow-write-text-file", + "core:window:allow-set-size", + "core:window:allow-set-always-on-top", + "core:window:allow-inner-size" ] } diff --git a/src-tauri/src/config.rs b/src-tauri/src/config.rs index 5bf59fd..cc626e0 100644 --- a/src-tauri/src/config.rs +++ b/src-tauri/src/config.rs @@ -79,6 +79,9 @@ pub struct HikariConfig { #[serde(default)] pub streamer_hide_paths: bool, + + #[serde(default)] + pub compact_mode: bool, } impl Default for HikariConfig { @@ -101,6 +104,7 @@ impl Default for HikariConfig { minimize_to_tray: false, streamer_mode: false, streamer_hide_paths: false, + compact_mode: false, } } } @@ -157,6 +161,7 @@ mod tests { assert!(!config.minimize_to_tray); assert!(!config.streamer_mode); assert!(!config.streamer_hide_paths); + assert!(!config.compact_mode); } #[test] @@ -177,6 +182,9 @@ mod tests { character_panel_width: Some(400), font_size: 16, minimize_to_tray: true, + streamer_mode: false, + streamer_hide_paths: false, + compact_mode: false, }; let json = serde_json::to_string(&config).unwrap(); diff --git a/src/lib/components/CompactMode.svelte b/src/lib/components/CompactMode.svelte new file mode 100644 index 0000000..765db06 --- /dev/null +++ b/src/lib/components/CompactMode.svelte @@ -0,0 +1,560 @@ + + +
+ +
+
+ Hikari - {info.label} { + const target = e.currentTarget as HTMLImageElement; + target.src = "/sprites/placeholder.svg"; + }} + /> +
+
{info.label}
+
+ + +
+ {#if recentMessages.length > 0} + {#each recentMessages.slice(-1) as msg} +
+ {msg.content} +
+ {/each} + {:else} +
Ask me anything~
+ {/if} +
+ + +
+ + +
+ {#if isProcessing} + + {:else} + + {/if} + + +
+
+ + + {#if streamerModeActive} +
+ {/if} +
+ + diff --git a/src/lib/components/ConfigSidebar.svelte b/src/lib/components/ConfigSidebar.svelte index 6015b62..de5d69d 100644 --- a/src/lib/components/ConfigSidebar.svelte +++ b/src/lib/components/ConfigSidebar.svelte @@ -29,6 +29,7 @@ font_size: 14, streamer_mode: false, streamer_hide_paths: false, + compact_mode: false, }); let isOpen = $state(false); diff --git a/src/lib/components/KeyboardShortcutsModal.svelte b/src/lib/components/KeyboardShortcutsModal.svelte index 47e09d5..535af64 100644 --- a/src/lib/components/KeyboardShortcutsModal.svelte +++ b/src/lib/components/KeyboardShortcutsModal.svelte @@ -12,6 +12,8 @@ { keys: ["Escape"], description: "Close modals and panels" }, { keys: ["Ctrl", "L"], description: "Clear the terminal" }, { keys: ["Ctrl", ","], description: "Open settings" }, + { keys: ["Ctrl", "Shift", "M"], description: "Toggle compact mode" }, + { keys: ["Ctrl", "Shift", "S"], description: "Toggle streamer mode" }, ], }, { diff --git a/src/lib/components/StatusBar.svelte b/src/lib/components/StatusBar.svelte index d89a1f5..e3b32c9 100644 --- a/src/lib/components/StatusBar.svelte +++ b/src/lib/components/StatusBar.svelte @@ -1,9 +1,10 @@ -
- (achievementPanelOpen = !achievementPanelOpen)} /> +{#if compactModeActive} + +
+ +
+{:else} + +
+ (achievementPanelOpen = !achievementPanelOpen)} + onToggleCompact={enterCompactMode} + /> -
- -
- -
+
+ +
+ +
- - -
+ + +
- -
- - -
-
+ +
+ + +
+
- - - - - (achievementPanelOpen = false)} - /> - -
+ + + + + (achievementPanelOpen = false)} + /> + +
+{/if} diff --git a/src/lib/components/StatsDisplay.svelte b/src/lib/components/StatsDisplay.svelte index 705433d..8590fc3 100644 --- a/src/lib/components/StatsDisplay.svelte +++ b/src/lib/components/StatsDisplay.svelte @@ -14,7 +14,6 @@
Messages: {$formattedStats.messagesSession} - / {$formattedStats.messagesTotal}
@@ -32,11 +31,6 @@ Output: {$formattedStats.sessionOutputTokens}
-
- Total: - {$formattedStats.totalTokens} - {$formattedStats.totalCost} -
@@ -44,17 +38,14 @@
Code blocks: {$formattedStats.codeBlocksSession} - / {$formattedStats.codeBlocksTotal}
Files edited: {$formattedStats.filesEditedSession} - / {$formattedStats.filesEditedTotal}
Files created: {$formattedStats.filesCreatedSession} - / {$formattedStats.filesCreatedTotal}
diff --git a/src/lib/components/StatusBar.svelte b/src/lib/components/StatusBar.svelte index 25d48d2..e109abe 100644 --- a/src/lib/components/StatusBar.svelte +++ b/src/lib/components/StatusBar.svelte @@ -22,6 +22,7 @@ import { achievementProgress } from "$lib/stores/achievements"; import SessionHistoryPanel from "./SessionHistoryPanel.svelte"; import GitPanel from "./GitPanel.svelte"; + import ProfilePanel from "./ProfilePanel.svelte"; const DISCORD_URL = "https://chat.nhcarrigan.com"; const DONATE_URL = "https://donate.nhcarrigan.com"; @@ -38,6 +39,7 @@ let showKeyboardShortcuts = $state(false); let showSessionHistory = $state(false); let showGitPanel = $state(false); + let showProfile = $state(false); const progress = $derived($achievementProgress); let currentConfig: HikariConfig = $state({ model: null, @@ -58,6 +60,9 @@ streamer_mode: false, streamer_hide_paths: false, compact_mode: false, + profile_name: null, + profile_avatar_path: null, + profile_bio: null, }); let streamerModeActive = $state(false); @@ -222,6 +227,20 @@ title="Streamer mode active (Ctrl+Shift+S to toggle)" > {/if} + + + @@ -514,4 +778,56 @@ color: var(--text-secondary); text-align: center; } + + /* Share */ + .share-section { + display: flex; + flex-direction: column; + align-items: center; + gap: 8px; + padding-top: 8px; + border-top: 1px solid var(--border-color); + } + + .share-btn { + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + padding: 12px 24px; + border: none; + border-radius: 8px; + cursor: pointer; + font-size: 15px; + font-weight: 600; + transition: all 0.2s; + } + + .share-btn:disabled { + opacity: 0.7; + cursor: not-allowed; + } + + .share-btn svg { + flex-shrink: 0; + } + + .share-hint { + margin: 0; + font-size: 12px; + color: var(--text-secondary); + } + + .spinner { + width: 16px; + height: 16px; + border: 2px solid transparent; + border-top-color: currentColor; + border-radius: 50%; + animation: spin 0.8s linear infinite; + } + + @keyframes spin { + to { transform: rotate(360deg); } + } -- 2.52.0 From c45414b0aa3b8445a5f00f58961faa9710814db9 Mon Sep 17 00:00:00 2001 From: Hikari Date: Sun, 25 Jan 2026 20:50:11 -0800 Subject: [PATCH 20/21] feat: add custom theme support with color picker UI - Add Custom variant to Theme enum and CustomThemeColors struct in Rust config - Add custom_theme_colors field to HikariConfig for storing user-defined colors - Create color picker UI in ConfigSidebar with 8 customizable color variables - Implement live preview when editing custom theme colors - Apply custom colors on app startup when custom theme is selected - Use dark theme as base, override with user's custom CSS variables --- src-tauri/src/config.rs | 31 ++++ src/lib/components/ConfigSidebar.svelte | 218 +++++++++++++++++++++++- src/lib/components/StatusBar.svelte | 10 ++ src/lib/stores/config.ts | 89 +++++++++- src/routes/+page.svelte | 2 +- 5 files changed, 337 insertions(+), 13 deletions(-) diff --git a/src-tauri/src/config.rs b/src-tauri/src/config.rs index fa73cbe..6ca733e 100644 --- a/src-tauri/src/config.rs +++ b/src-tauri/src/config.rs @@ -92,6 +92,10 @@ pub struct HikariConfig { #[serde(default)] pub profile_bio: Option, + + // Custom theme colors + #[serde(default)] + pub custom_theme_colors: CustomThemeColors, } impl Default for HikariConfig { @@ -118,6 +122,7 @@ impl Default for HikariConfig { profile_name: None, profile_avatar_path: None, profile_bio: None, + custom_theme_colors: CustomThemeColors::default(), } } } @@ -150,6 +155,27 @@ pub enum Theme { Light, #[serde(rename = "high-contrast")] HighContrast, + Custom, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)] +pub struct CustomThemeColors { + #[serde(default)] + pub bg_primary: Option, + #[serde(default)] + pub bg_secondary: Option, + #[serde(default)] + pub bg_terminal: Option, + #[serde(default)] + pub accent_primary: Option, + #[serde(default)] + pub accent_secondary: Option, + #[serde(default)] + pub text_primary: Option, + #[serde(default)] + pub text_secondary: Option, + #[serde(default)] + pub border_color: Option, } #[cfg(test)] @@ -178,6 +204,7 @@ mod tests { assert!(config.profile_name.is_none()); assert!(config.profile_avatar_path.is_none()); assert!(config.profile_bio.is_none()); + assert_eq!(config.custom_theme_colors, CustomThemeColors::default()); } #[test] @@ -204,6 +231,7 @@ mod tests { profile_name: Some("Test User".to_string()), profile_avatar_path: None, profile_bio: Some("A test bio".to_string()), + custom_theme_colors: CustomThemeColors::default(), }; let json = serde_json::to_string(&config).unwrap(); @@ -232,5 +260,8 @@ mod tests { serde_json::to_string(&high_contrast).unwrap(), "\"high-contrast\"" ); + + let custom = Theme::Custom; + assert_eq!(serde_json::to_string(&custom).unwrap(), "\"custom\""); } } diff --git a/src/lib/components/ConfigSidebar.svelte b/src/lib/components/ConfigSidebar.svelte index 3dd3673..4aea4e0 100644 --- a/src/lib/components/ConfigSidebar.svelte +++ b/src/lib/components/ConfigSidebar.svelte @@ -3,7 +3,9 @@ configStore, type HikariConfig, type Theme, + type CustomThemeColors, applyFontSize, + applyCustomThemeColors, MIN_FONT_SIZE, MAX_FONT_SIZE, DEFAULT_FONT_SIZE, @@ -33,8 +35,20 @@ profile_name: null, profile_avatar_path: null, profile_bio: null, + custom_theme_colors: { + bg_primary: null, + bg_secondary: null, + bg_terminal: null, + accent_primary: null, + accent_secondary: null, + text_primary: null, + text_secondary: null, + border_color: null, + }, }); + let showCustomThemeEditor = $state(false); + let isOpen = $state(false); let isSaving = $state(false); let saveError: string | null = $state(null); @@ -91,9 +105,33 @@ async function handleThemeChange(theme: Theme) { config.theme = theme; - await configStore.setTheme(theme); + showCustomThemeEditor = theme === "custom"; + await configStore.setTheme(theme, config.custom_theme_colors); } + function handleCustomColorChange(key: keyof CustomThemeColors, value: string) { + config.custom_theme_colors = { + ...config.custom_theme_colors, + [key]: value || null, + }; + // Live preview + if (config.theme === "custom") { + applyCustomThemeColors(config.custom_theme_colors); + } + } + + // Default dark theme colors for color picker defaults + const defaultDarkColors: Required> = { + bg_primary: "#1a1a2e", + bg_secondary: "#16213e", + bg_terminal: "#0f0f1a", + accent_primary: "#e94560", + accent_secondary: "#ff6b9d", + text_primary: "#ffffff", + text_secondary: "#a0a0a0", + border_color: "#2a2a4a", + }; + function toggleTool(tool: string) { if (config.auto_granted_tools.includes(tool)) { config.auto_granted_tools = config.auto_granted_tools.filter((t) => t !== tool); @@ -421,10 +459,10 @@
-
+
+
+ + {#if config.theme === "custom" || showCustomThemeEditor} +
+

Custom Theme Colors

+
+
+ +
+ handleCustomColorChange("bg_primary", e.currentTarget.value)} + class="color-picker" + /> + + {config.custom_theme_colors.bg_primary || defaultDarkColors.bg_primary} + +
+
+
+ +
+ handleCustomColorChange("bg_secondary", e.currentTarget.value)} + class="color-picker" + /> + + {config.custom_theme_colors.bg_secondary || defaultDarkColors.bg_secondary} + +
+
+
+ +
+ handleCustomColorChange("bg_terminal", e.currentTarget.value)} + class="color-picker" + /> + + {config.custom_theme_colors.bg_terminal || defaultDarkColors.bg_terminal} + +
+
+
+ +
+ handleCustomColorChange("border_color", e.currentTarget.value)} + class="color-picker" + /> + + {config.custom_theme_colors.border_color || defaultDarkColors.border_color} + +
+
+
+ +
+ handleCustomColorChange("accent_primary", e.currentTarget.value)} + class="color-picker" + /> + + {config.custom_theme_colors.accent_primary || defaultDarkColors.accent_primary} + +
+
+
+ +
+ handleCustomColorChange("accent_secondary", e.currentTarget.value)} + class="color-picker" + /> + + {config.custom_theme_colors.accent_secondary || defaultDarkColors.accent_secondary} + +
+
+
+ +
+ handleCustomColorChange("text_primary", e.currentTarget.value)} + class="color-picker" + /> + + {config.custom_theme_colors.text_primary || defaultDarkColors.text_primary} + +
+
+
+ +
+ handleCustomColorChange("text_secondary", e.currentTarget.value)} + class="color-picker" + /> + + {config.custom_theme_colors.text_secondary || defaultDarkColors.text_secondary} + +
+
+
+

+ Changes preview live. Click Save Settings to persist. +

+
+ {/if} +