From 4c46d4c8fd2791d62a001edb4df25c98ee1b6c71 Mon Sep 17 00:00:00 2001 From: Hikari Date: Sun, 25 Jan 2026 22:19:00 -0800 Subject: [PATCH] feat: add multiple productivity features and UI enhancements (#68) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary This PR adds a collection of productivity features and UI enhancements to improve the Hikari Desktop experience: ### New Features - **Clipboard History** (#25) - Track and manage copied code snippets with language detection, search, filtering, and pinning - **Quick Actions Panel** (#15) - Buttons for common quick actions like "Review PR", "Run tests", "Explain file", with customizable actions - **Git Integration Panel** (#24) - View current branch, changed/staged files, quick git actions (commit, push, pull), and branch management - **Session Import/Export** (#8) - Export conversations to JSON and import previously saved sessions - **Snippet Library** (#22) - Save and reuse common prompts with categories and quick insert - **Session History** (#14) - Auto-save conversations with browsable history and search - **High Contrast Mode** (#20) - Accessibility theme with improved visibility - **Minimize to System Tray** (#11) - System tray support with right-click menu ### UI Enhancements - Trans-pride gradient theme applied across UI elements - Copy button added to code blocks - Linter formatting and eslint-disable comments for cleaner code ## Closes Closes #8 Closes #11 Closes #14 Closes #15 Closes #20 Closes #22 Closes #24 Closes #25 Closes #34 Closes #35 Closes #36 Closes #37 Closes #69 Closes #70 ## Test Plan - [ ] Verify clipboard history captures code from code block copy buttons - [ ] Verify clipboard history captures manually selected text from terminal - [ ] Test snippet library CRUD operations and insertion - [ ] Test quick actions panel with default and custom actions - [ ] Test git panel shows correct status, branch, and performs git operations - [ ] Test session history auto-save and restore - [ ] Test session import/export roundtrip - [ ] Verify high contrast mode provides adequate contrast - [ ] Test minimize to tray functionality and tray menu - [ ] Verify trans-pride gradient theme displays correctly in all themes --- *โœจ This PR was created with help from Hikari~ ๐ŸŒธ* Co-authored-by: Naomi Carrigan Reviewed-on: https://git.nhcarrigan.com/nhcarrigan/hikari-desktop/pulls/68 Co-authored-by: Hikari Co-committed-by: Hikari --- package.json | 1 + pnpm-lock.yaml | 10 + src-tauri/Cargo.lock | 2 + src-tauri/Cargo.toml | 3 +- src-tauri/capabilities/default.json | 17 +- src-tauri/src/achievements.rs | 1487 ++++++++++++++++- src-tauri/src/clipboard.rs | 259 +++ src-tauri/src/commands.rs | 13 + src-tauri/src/config.rs | 81 + src-tauri/src/git.rs | 288 ++++ src-tauri/src/lib.rs | 73 + src-tauri/src/quick_actions.rs | 191 +++ src-tauri/src/sessions.rs | 167 ++ src-tauri/src/snippets.rs | 226 +++ src-tauri/src/stats.rs | 178 ++ src-tauri/src/tray.rs | 68 + src-tauri/src/wsl_bridge.rs | 41 +- src-tauri/tauri.conf.json | 6 + src/app.css | 136 ++ src/lib/components/AnimeGirl.svelte | 60 +- .../components/ClipboardHistoryPanel.svelte | 497 ++++++ src/lib/components/CompactMode.svelte | 563 +++++++ src/lib/components/ConfigSidebar.svelte | 412 ++++- src/lib/components/ConversationTabs.svelte | 46 +- src/lib/components/GitPanel.svelte | 1107 ++++++++++++ src/lib/components/InputBar.svelte | 278 ++- .../components/KeyboardShortcutsModal.svelte | 2 + src/lib/components/Markdown.svelte | 97 +- src/lib/components/ProfilePanel.svelte | 929 ++++++++++ src/lib/components/QuickActionsPanel.svelte | 456 +++++ src/lib/components/SessionHistoryPanel.svelte | 476 ++++++ src/lib/components/SnippetLibraryPanel.svelte | 467 ++++++ src/lib/components/StatsDisplay.svelte | 17 - src/lib/components/StatusBar.svelte | 125 +- src/lib/components/Terminal.svelte | 45 +- src/lib/components/UpdateNotification.svelte | 5 +- src/lib/components/UserQuestionModal.svelte | 2 +- src/lib/stores/achievements.ts | 1290 +++++++++++++- src/lib/stores/clipboard.ts | 230 +++ src/lib/stores/config.ts | 143 +- src/lib/stores/conversations.ts | 8 + src/lib/stores/quickActions.ts | 114 ++ src/lib/stores/sessions.ts | 708 ++++++++ src/lib/stores/snippets.ts | 138 ++ src/lib/stores/stats.ts | 5 +- src/lib/types/achievements.ts | 191 ++- src/routes/+page.svelte | 356 +++- 47 files changed, 11695 insertions(+), 319 deletions(-) create mode 100644 src-tauri/src/clipboard.rs create mode 100644 src-tauri/src/git.rs create mode 100644 src-tauri/src/quick_actions.rs create mode 100644 src-tauri/src/sessions.rs create mode 100644 src-tauri/src/snippets.rs create mode 100644 src-tauri/src/tray.rs create mode 100644 src/lib/components/ClipboardHistoryPanel.svelte create mode 100644 src/lib/components/CompactMode.svelte create mode 100644 src/lib/components/GitPanel.svelte create mode 100644 src/lib/components/ProfilePanel.svelte create mode 100644 src/lib/components/QuickActionsPanel.svelte create mode 100644 src/lib/components/SessionHistoryPanel.svelte create mode 100644 src/lib/components/SnippetLibraryPanel.svelte create mode 100644 src/lib/stores/clipboard.ts create mode 100644 src/lib/stores/quickActions.ts create mode 100644 src/lib/stores/sessions.ts create mode 100644 src/lib/stores/snippets.ts 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 3ed5bd2..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", @@ -4180,6 +4181,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..10243c0 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" @@ -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 5e067ad..f8363c3 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -15,6 +15,21 @@ "notification:allow-request-permission", "notification:allow-notify", "clipboard-manager:default", - "clipboard-manager:allow-read-image" + "clipboard-manager:allow-read-image", + "core:tray:default", + "fs:default", + "fs:allow-read-text-file", + "fs:allow-write-text-file", + { + "identifier": "fs:allow-read-file", + "allow": [{ "path": "**" }] + }, + { + "identifier": "fs:allow-write-file", + "allow": [{ "path": "**" }] + }, + "core:window:allow-set-size", + "core:window:allow-set-always-on-top", + "core:window:allow-inner-size" ] } diff --git a/src-tauri/src/achievements.rs b/src-tauri/src/achievements.rs index 095df88..d9d0f81 100644 --- a/src-tauri/src/achievements.rs +++ b/src-tauri/src/achievements.rs @@ -77,6 +77,167 @@ pub enum AchievementId { BashMaster, // Use Bash tool 50 times FileExplorer, // Use Read tool 100 times SearchExpert, // Use Grep tool 50 times + + // Extended Token Milestones + TokenBillionaire, // 10,000,000 tokens + TokenTreasure, // 50,000,000 tokens + + // Extended Code Generation + CodeFactory, // 5,000 code blocks + CodeEmpire, // 10,000 code blocks + + // Extended File Operations + FileEngineer, // 500 files edited + FileLegend, // 1,000 files edited + + // Extended Conversation + ChatMarathon, // 5,000 messages + ChatLegend, // 10,000 messages + + // Extended Session Duration + UltraMarathon, // 8 hour session + CodingRetreat, // 12 hour session + + // More Tool Mastery + EditMaster, // Use Edit tool 100 times + WriteMaster, // Use Write tool 50 times + GlobMaster, // Use Glob tool 100 times + TaskMaster, // Use Task tool 50 times + WebFetcher, // Use WebFetch tool 20 times + McpExplorer, // Use MCP tools 50 times + + // Daily Streaks + WeekStreak, // 7 days in a row + TwoWeekStreak, // 14 days in a row + MonthStreak, // 30 days in a row (alias for DedicatedDeveloper) + QuarterStreak, // 90 days in a row + + // Time Challenges + MorningPerson, // 10 sessions started before 9 AM + NightCoder, // 10 sessions after 10 PM + LunchBreakCoder, // Session during 12-1 PM + CoffeeTime, // Session during 3-4 PM (afternoon slump) + + // Day-specific + MondayMotivation, // Coding on Monday + FridayFinisher, // Coding on Friday + HumpDay, // Coding on Wednesday + + // Seasonal/Special Times + NewYearCoder, // Coding on January 1st + ValentinesDev, // Coding on February 14th + SpookyCode, // Coding on October 31st + HolidayCoder, // Coding on December 25th + LeapDayCoder, // Coding on February 29th + + // Message Content + LongMessage, // Send a message over 500 characters + NovelWriter, // Send a message over 2000 characters + ShortAndSweet, // Complete a task with messages under 50 chars each + CodeInMessage, // Include code block in user message + MarkdownMaster, // Use markdown formatting in message + + // Greetings Extended + HelloHikari, // Say "hello hikari" or "hi hikari" + HowAreYou, // Ask "how are you" + MissedYou, // Say "missed you" + BackAgain, // Say "i'm back" or "back again" + + // Emotional + Frustrated, // Say "frustrated" or "ugh" or "argh" + Excited, // Say "excited" or "yay" or "woohoo" + Confused, // Say "confused" or "don't understand" + Curious, // Ask "why" or "how does" + Impressed, // Say "wow" or "amazing" or "incredible" + + // Programming Languages (detected in code blocks) + RustDeveloper, // Generate Rust code + PythonDeveloper, // Generate Python code + JavaScriptDev, // Generate JavaScript code + TypeScriptDev, // Generate TypeScript code + GoDeveloper, // Generate Go code + CppDeveloper, // Generate C++ code + JavaDeveloper, // Generate Java code + HtmlCssDev, // Generate HTML/CSS code + SqlDeveloper, // Generate SQL code + ShellScripter, // Generate shell/bash scripts + FullStackDev, // Generate code in 10+ languages + + // Project Types + FrontendDev, // Work on frontend files (svelte, react, vue, html, css) + BackendDev, // Work on backend files (rs, py, go, java) + ConfigEditor, // Edit config files (json, yaml, toml, env) + DocWriter, // Edit documentation (md, txt, rst) + + // Git Mastery Extended + CommitKing, // 50 commits + CommitLegend, // 200 commits + BranchMaster, // Create 10 branches + MergeExpert, // Merge 20 PRs + ConflictResolver, // Resolve merge conflicts + + // Error Handling + ErrorHunter, // Fix 10 errors + ExceptionSlayer, // Fix 50 errors + BugExterminator, // Fix 100 bugs + + // Refactoring + CleanCoder, // Refactor code + Optimizer, // Optimize performance + Simplifier, // Simplify complex code + + // Testing + TestNovice, // Write 10 tests + TestEnthusiast, // Write 50 tests + TestMaster, // Write 100 tests + CoverageKing, // Achieve test coverage mentions + + // Documentation + Documenter, // Write documentation + CommentWriter, // Add comments to code + ReadmeHero, // Create/edit README files + + // API & Integration + ApiExplorer, // Work with APIs + DatabaseDev, // Work with databases + CloudCoder, // Work with cloud services + + // Special Milestones + CenturyClub, // 100 sessions + ThousandSessions, // 1000 sessions + Veteran, // Used Hikari for 30+ days total + OldTimer, // Used Hikari for 90+ days total + Loyalist, // Used Hikari for 365+ days total + + // Fun & Easter Eggs + Perfectionist, // Redo something 5 times + Persistent, // Ask same question 3 times + Patient, // Wait for long response + Speedy, // Send 10 messages in 1 minute + MultiTasker, // Have 5+ conversation tabs open + Minimalist, // Use compact mode + PrivacyFirst, // Enable streamer mode + ThemeChanger, // Change theme 3 times + SettingsTweaker, // Open settings 10 times + AchievementHunter, // Check achievements panel 20 times + Completionist, // Unlock 50% of achievements + MasterUnlocker, // Unlock 75% of achievements + PlatinumStatus, // Unlock 100% of achievements + + // Clipboard & Snippets + ClipboardCollector, // Save 20 clipboard entries + SnippetCreator, // Create 5 custom snippets + SnippetMaster, // Use snippets 50 times + QuickActionUser, // Use quick actions 20 times + + // Session History + HistoryBuff, // Save 10 sessions + Archivist, // Save 50 sessions + SessionExporter, // Export a session + + // New Features + GitPanelUser, // Use git panel 10 times + FeatureExplorer, // Try all major features } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -491,9 +652,1017 @@ pub fn get_achievement_info(id: &AchievementId) -> Achievement { icon: "๐Ÿ”Ž".to_string(), unlocked_at: None, }, + + // Extended Token Milestones + AchievementId::TokenBillionaire => Achievement { + id: id.clone(), + name: "Token Billionaire".to_string(), + description: "Used 10,000,000 tokens!".to_string(), + icon: "๐Ÿ’Ž".to_string(), + unlocked_at: None, + }, + AchievementId::TokenTreasure => Achievement { + id: id.clone(), + name: "Token Treasure".to_string(), + description: "Used 50,000,000 tokens! You're incredible!".to_string(), + icon: "๐Ÿ‘‘".to_string(), + unlocked_at: None, + }, + + // Extended Code Generation + AchievementId::CodeFactory => Achievement { + id: id.clone(), + name: "Code Factory".to_string(), + description: "Generated 5,000 code blocks".to_string(), + icon: "๐Ÿญ".to_string(), + unlocked_at: None, + }, + AchievementId::CodeEmpire => Achievement { + id: id.clone(), + name: "Code Empire".to_string(), + description: "Generated 10,000 code blocks!".to_string(), + icon: "๐Ÿฐ".to_string(), + unlocked_at: None, + }, + + // Extended File Operations + AchievementId::FileEngineer => Achievement { + id: id.clone(), + name: "File Engineer".to_string(), + description: "Edited 500 files".to_string(), + icon: "โš™๏ธ".to_string(), + unlocked_at: None, + }, + AchievementId::FileLegend => Achievement { + id: id.clone(), + name: "File Legend".to_string(), + description: "Edited 1,000 files!".to_string(), + icon: "๐Ÿ“š".to_string(), + unlocked_at: None, + }, + + // Extended Conversation + AchievementId::ChatMarathon => Achievement { + id: id.clone(), + name: "Chat Marathon".to_string(), + description: "5,000 messages exchanged".to_string(), + icon: "๐Ÿ’Œ".to_string(), + unlocked_at: None, + }, + AchievementId::ChatLegend => Achievement { + id: id.clone(), + name: "Chat Legend".to_string(), + description: "10,000 messages! We're best friends!".to_string(), + icon: "๐Ÿ‘ฏ".to_string(), + unlocked_at: None, + }, + + // Extended Session Duration + AchievementId::UltraMarathon => Achievement { + id: id.clone(), + name: "Ultra Marathon".to_string(), + description: "8+ hour coding session!".to_string(), + icon: "๐Ÿฆธ".to_string(), + unlocked_at: None, + }, + AchievementId::CodingRetreat => Achievement { + id: id.clone(), + name: "Coding Retreat".to_string(), + description: "12+ hour coding session! Please take breaks!".to_string(), + icon: "๐Ÿ•๏ธ".to_string(), + unlocked_at: None, + }, + + // More Tool Mastery + AchievementId::EditMaster => Achievement { + id: id.clone(), + name: "Edit Master".to_string(), + description: "Used Edit tool 100 times".to_string(), + icon: "โœ‚๏ธ".to_string(), + unlocked_at: None, + }, + AchievementId::WriteMaster => Achievement { + id: id.clone(), + name: "Write Master".to_string(), + description: "Used Write tool 50 times".to_string(), + icon: "โœ๏ธ".to_string(), + unlocked_at: None, + }, + AchievementId::GlobMaster => Achievement { + id: id.clone(), + name: "Glob Master".to_string(), + description: "Used Glob tool 100 times".to_string(), + icon: "๐ŸŒ".to_string(), + unlocked_at: None, + }, + AchievementId::TaskMaster => Achievement { + id: id.clone(), + name: "Task Master".to_string(), + description: "Used Task tool 50 times".to_string(), + icon: "๐Ÿ“‹".to_string(), + unlocked_at: None, + }, + AchievementId::WebFetcher => Achievement { + id: id.clone(), + name: "Web Fetcher".to_string(), + description: "Used WebFetch tool 20 times".to_string(), + icon: "๐ŸŒ".to_string(), + unlocked_at: None, + }, + AchievementId::McpExplorer => Achievement { + id: id.clone(), + name: "MCP Explorer".to_string(), + description: "Used MCP tools 50 times".to_string(), + icon: "๐Ÿ”ฎ".to_string(), + unlocked_at: None, + }, + + // Daily Streaks + AchievementId::WeekStreak => Achievement { + id: id.clone(), + name: "Week Streak".to_string(), + description: "Coded for 7 days in a row!".to_string(), + icon: "๐Ÿ“…".to_string(), + unlocked_at: None, + }, + AchievementId::TwoWeekStreak => Achievement { + id: id.clone(), + name: "Two Week Streak".to_string(), + description: "Coded for 14 days in a row!".to_string(), + icon: "๐Ÿ”ฅ".to_string(), + unlocked_at: None, + }, + AchievementId::MonthStreak => Achievement { + id: id.clone(), + name: "Month Streak".to_string(), + description: "Coded for 30 days in a row!".to_string(), + icon: "๐Ÿ†".to_string(), + unlocked_at: None, + }, + AchievementId::QuarterStreak => Achievement { + id: id.clone(), + name: "Quarter Streak".to_string(), + description: "Coded for 90 days in a row! Incredible dedication!".to_string(), + icon: "๐Ÿ’ซ".to_string(), + unlocked_at: None, + }, + + // Time Challenges + AchievementId::MorningPerson => Achievement { + id: id.clone(), + name: "Morning Person".to_string(), + description: "Started 10 sessions before 9 AM".to_string(), + icon: "โ˜€๏ธ".to_string(), + unlocked_at: None, + }, + AchievementId::NightCoder => Achievement { + id: id.clone(), + name: "Night Coder".to_string(), + description: "Started 10 sessions after 10 PM".to_string(), + icon: "๐ŸŒƒ".to_string(), + unlocked_at: None, + }, + AchievementId::LunchBreakCoder => Achievement { + id: id.clone(), + name: "Lunch Break Coder".to_string(), + description: "Coded during lunch (12-1 PM)".to_string(), + icon: "๐Ÿฑ".to_string(), + unlocked_at: None, + }, + AchievementId::CoffeeTime => Achievement { + id: id.clone(), + name: "Coffee Time".to_string(), + description: "Coded during afternoon break (3-4 PM)".to_string(), + icon: "โ˜•".to_string(), + unlocked_at: None, + }, + + // Day-specific + AchievementId::MondayMotivation => Achievement { + id: id.clone(), + name: "Monday Motivation".to_string(), + description: "Started the week coding!".to_string(), + icon: "๐Ÿ’ช".to_string(), + unlocked_at: None, + }, + AchievementId::FridayFinisher => Achievement { + id: id.clone(), + name: "Friday Finisher".to_string(), + description: "Ending the week strong!".to_string(), + icon: "๐ŸŽ‰".to_string(), + unlocked_at: None, + }, + AchievementId::HumpDay => Achievement { + id: id.clone(), + name: "Hump Day".to_string(), + description: "Coding on Wednesday!".to_string(), + icon: "๐Ÿช".to_string(), + unlocked_at: None, + }, + + // Seasonal/Special Times + AchievementId::NewYearCoder => Achievement { + id: id.clone(), + name: "New Year Coder".to_string(), + description: "Coding on January 1st! New year, new code!".to_string(), + icon: "๐ŸŽ†".to_string(), + unlocked_at: None, + }, + AchievementId::ValentinesDev => Achievement { + id: id.clone(), + name: "Valentine's Dev".to_string(), + description: "Coding on Valentine's Day! I love you too~".to_string(), + icon: "๐Ÿ’˜".to_string(), + unlocked_at: None, + }, + AchievementId::SpookyCode => Achievement { + id: id.clone(), + name: "Spooky Code".to_string(), + description: "Coding on Halloween! Spooky bugs beware!".to_string(), + icon: "๐ŸŽƒ".to_string(), + unlocked_at: None, + }, + AchievementId::HolidayCoder => Achievement { + id: id.clone(), + name: "Holiday Coder".to_string(), + description: "Coding on Christmas! You're dedicated!".to_string(), + icon: "๐ŸŽ„".to_string(), + unlocked_at: None, + }, + AchievementId::LeapDayCoder => Achievement { + id: id.clone(), + name: "Leap Day Coder".to_string(), + description: "Coding on February 29th! Rare achievement!".to_string(), + icon: "๐Ÿธ".to_string(), + unlocked_at: None, + }, + + // Message Content + AchievementId::LongMessage => Achievement { + id: id.clone(), + name: "Long Message".to_string(), + description: "Sent a message over 500 characters".to_string(), + icon: "๐Ÿ“œ".to_string(), + unlocked_at: None, + }, + AchievementId::NovelWriter => Achievement { + id: id.clone(), + name: "Novel Writer".to_string(), + description: "Sent a message over 2000 characters!".to_string(), + icon: "๐Ÿ“–".to_string(), + unlocked_at: None, + }, + AchievementId::ShortAndSweet => Achievement { + id: id.clone(), + name: "Short and Sweet".to_string(), + description: "Completed a task with brief messages".to_string(), + icon: "๐Ÿฌ".to_string(), + unlocked_at: None, + }, + AchievementId::CodeInMessage => Achievement { + id: id.clone(), + name: "Code in Message".to_string(), + description: "Included a code block in your message".to_string(), + icon: "๐Ÿ’ป".to_string(), + unlocked_at: None, + }, + AchievementId::MarkdownMaster => Achievement { + id: id.clone(), + name: "Markdown Master".to_string(), + description: "Used markdown formatting in a message".to_string(), + icon: "๐Ÿ“".to_string(), + unlocked_at: None, + }, + + // Greetings Extended + AchievementId::HelloHikari => Achievement { + id: id.clone(), + name: "Hello Hikari!".to_string(), + description: "Greeted me by name! Hi there~".to_string(), + icon: "๐Ÿ‘‹".to_string(), + unlocked_at: None, + }, + AchievementId::HowAreYou => Achievement { + id: id.clone(), + name: "How Are You?".to_string(), + description: "Asked how I'm doing! I'm great, thanks~".to_string(), + icon: "๐Ÿฅฐ".to_string(), + unlocked_at: None, + }, + AchievementId::MissedYou => Achievement { + id: id.clone(), + name: "Missed You".to_string(), + description: "Said you missed me! I missed you too~".to_string(), + icon: "๐Ÿซ‚".to_string(), + unlocked_at: None, + }, + AchievementId::BackAgain => Achievement { + id: id.clone(), + name: "Back Again".to_string(), + description: "Announced your return! Welcome back~".to_string(), + icon: "๐Ÿ”™".to_string(), + unlocked_at: None, + }, + + // Emotional + AchievementId::Frustrated => Achievement { + id: id.clone(), + name: "Frustrated".to_string(), + description: "Expressed frustration. Let me help!".to_string(), + icon: "๐Ÿ˜ค".to_string(), + unlocked_at: None, + }, + AchievementId::Excited => Achievement { + id: id.clone(), + name: "Excited!".to_string(), + description: "Expressed excitement! Yay!".to_string(), + icon: "๐ŸŽŠ".to_string(), + unlocked_at: None, + }, + AchievementId::Confused => Achievement { + id: id.clone(), + name: "Confused".to_string(), + description: "Felt confused. I'll help clarify!".to_string(), + icon: "๐Ÿ˜•".to_string(), + unlocked_at: None, + }, + AchievementId::Curious => Achievement { + id: id.clone(), + name: "Curious Mind".to_string(), + description: "Asked why or how something works!".to_string(), + icon: "๐Ÿค”".to_string(), + unlocked_at: None, + }, + AchievementId::Impressed => Achievement { + id: id.clone(), + name: "Impressed!".to_string(), + description: "Was amazed by something! Wow indeed~".to_string(), + icon: "๐Ÿคฉ".to_string(), + unlocked_at: None, + }, + + // Programming Languages + AchievementId::RustDeveloper => Achievement { + id: id.clone(), + name: "Rust Developer".to_string(), + description: "Generated Rust code! ๐Ÿฆ€".to_string(), + icon: "๐Ÿฆ€".to_string(), + unlocked_at: None, + }, + AchievementId::PythonDeveloper => Achievement { + id: id.clone(), + name: "Python Developer".to_string(), + description: "Generated Python code! ๐Ÿ".to_string(), + icon: "๐Ÿ".to_string(), + unlocked_at: None, + }, + AchievementId::JavaScriptDev => Achievement { + id: id.clone(), + name: "JavaScript Developer".to_string(), + description: "Generated JavaScript code!".to_string(), + icon: "๐ŸŸจ".to_string(), + unlocked_at: None, + }, + AchievementId::TypeScriptDev => Achievement { + id: id.clone(), + name: "TypeScript Developer".to_string(), + description: "Generated TypeScript code!".to_string(), + icon: "๐Ÿ”ท".to_string(), + unlocked_at: None, + }, + AchievementId::GoDeveloper => Achievement { + id: id.clone(), + name: "Go Developer".to_string(), + description: "Generated Go code!".to_string(), + icon: "๐Ÿ”ต".to_string(), + unlocked_at: None, + }, + AchievementId::CppDeveloper => Achievement { + id: id.clone(), + name: "C++ Developer".to_string(), + description: "Generated C++ code!".to_string(), + icon: "โšก".to_string(), + unlocked_at: None, + }, + AchievementId::JavaDeveloper => Achievement { + id: id.clone(), + name: "Java Developer".to_string(), + description: "Generated Java code! โ˜•".to_string(), + icon: "โ˜•".to_string(), + unlocked_at: None, + }, + AchievementId::HtmlCssDev => Achievement { + id: id.clone(), + name: "HTML/CSS Developer".to_string(), + description: "Generated HTML or CSS code!".to_string(), + icon: "๐ŸŽจ".to_string(), + unlocked_at: None, + }, + AchievementId::SqlDeveloper => Achievement { + id: id.clone(), + name: "SQL Developer".to_string(), + description: "Generated SQL code!".to_string(), + icon: "๐Ÿ—ƒ๏ธ".to_string(), + unlocked_at: None, + }, + AchievementId::ShellScripter => Achievement { + id: id.clone(), + name: "Shell Scripter".to_string(), + description: "Generated shell scripts!".to_string(), + icon: "๐Ÿš".to_string(), + unlocked_at: None, + }, + AchievementId::FullStackDev => Achievement { + id: id.clone(), + name: "Full Stack Developer".to_string(), + description: "Generated code in 10+ languages!".to_string(), + icon: "๐ŸŒˆ".to_string(), + unlocked_at: None, + }, + + // Project Types + AchievementId::FrontendDev => Achievement { + id: id.clone(), + name: "Frontend Developer".to_string(), + description: "Worked on frontend files!".to_string(), + icon: "๐Ÿ–ผ๏ธ".to_string(), + unlocked_at: None, + }, + AchievementId::BackendDev => Achievement { + id: id.clone(), + name: "Backend Developer".to_string(), + description: "Worked on backend files!".to_string(), + icon: "โš™๏ธ".to_string(), + unlocked_at: None, + }, + AchievementId::ConfigEditor => Achievement { + id: id.clone(), + name: "Config Editor".to_string(), + description: "Edited configuration files!".to_string(), + icon: "โš™๏ธ".to_string(), + unlocked_at: None, + }, + AchievementId::DocWriter => Achievement { + id: id.clone(), + name: "Documentation Writer".to_string(), + description: "Edited documentation files!".to_string(), + icon: "๐Ÿ“„".to_string(), + unlocked_at: None, + }, + + // Git Mastery Extended + AchievementId::CommitKing => Achievement { + id: id.clone(), + name: "Commit King".to_string(), + description: "Made 50 commits!".to_string(), + icon: "๐Ÿ‘‘".to_string(), + unlocked_at: None, + }, + AchievementId::CommitLegend => Achievement { + id: id.clone(), + name: "Commit Legend".to_string(), + description: "Made 200 commits!".to_string(), + icon: "๐Ÿ›๏ธ".to_string(), + unlocked_at: None, + }, + AchievementId::BranchMaster => Achievement { + id: id.clone(), + name: "Branch Master".to_string(), + description: "Created 10 branches!".to_string(), + icon: "๐ŸŒณ".to_string(), + unlocked_at: None, + }, + AchievementId::MergeExpert => Achievement { + id: id.clone(), + name: "Merge Expert".to_string(), + description: "Merged 20 PRs!".to_string(), + icon: "๐Ÿ”€".to_string(), + unlocked_at: None, + }, + AchievementId::ConflictResolver => Achievement { + id: id.clone(), + name: "Conflict Resolver".to_string(), + description: "Resolved merge conflicts!".to_string(), + icon: "๐Ÿค".to_string(), + unlocked_at: None, + }, + + // Error Handling + AchievementId::ErrorHunter => Achievement { + id: id.clone(), + name: "Error Hunter".to_string(), + description: "Fixed 10 errors!".to_string(), + icon: "๐ŸŽฏ".to_string(), + unlocked_at: None, + }, + AchievementId::ExceptionSlayer => Achievement { + id: id.clone(), + name: "Exception Slayer".to_string(), + description: "Fixed 50 errors!".to_string(), + icon: "โš”๏ธ".to_string(), + unlocked_at: None, + }, + AchievementId::BugExterminator => Achievement { + id: id.clone(), + name: "Bug Exterminator".to_string(), + description: "Fixed 100 bugs! Incredible!".to_string(), + icon: "๐Ÿ”ซ".to_string(), + unlocked_at: None, + }, + + // Refactoring + AchievementId::CleanCoder => Achievement { + id: id.clone(), + name: "Clean Coder".to_string(), + description: "Refactored code for cleanliness!".to_string(), + icon: "๐Ÿงน".to_string(), + unlocked_at: None, + }, + AchievementId::Optimizer => Achievement { + id: id.clone(), + name: "Optimizer".to_string(), + description: "Optimized code performance!".to_string(), + icon: "๐Ÿš€".to_string(), + unlocked_at: None, + }, + AchievementId::Simplifier => Achievement { + id: id.clone(), + name: "Simplifier".to_string(), + description: "Simplified complex code!".to_string(), + icon: "โœจ".to_string(), + unlocked_at: None, + }, + + // Testing + AchievementId::TestNovice => Achievement { + id: id.clone(), + name: "Test Novice".to_string(), + description: "Wrote 10 tests!".to_string(), + icon: "๐Ÿงช".to_string(), + unlocked_at: None, + }, + AchievementId::TestEnthusiast => Achievement { + id: id.clone(), + name: "Test Enthusiast".to_string(), + description: "Wrote 50 tests!".to_string(), + icon: "๐Ÿ”ฌ".to_string(), + unlocked_at: None, + }, + AchievementId::TestMaster => Achievement { + id: id.clone(), + name: "Test Master".to_string(), + description: "Wrote 100 tests!".to_string(), + icon: "๐Ÿ…".to_string(), + unlocked_at: None, + }, + AchievementId::CoverageKing => Achievement { + id: id.clone(), + name: "Coverage King".to_string(), + description: "Achieved great test coverage!".to_string(), + icon: "๐Ÿ“Š".to_string(), + unlocked_at: None, + }, + + // Documentation + AchievementId::Documenter => Achievement { + id: id.clone(), + name: "Documenter".to_string(), + description: "Wrote documentation!".to_string(), + icon: "๐Ÿ“".to_string(), + unlocked_at: None, + }, + AchievementId::CommentWriter => Achievement { + id: id.clone(), + name: "Comment Writer".to_string(), + description: "Added helpful comments to code!".to_string(), + icon: "๐Ÿ’ฌ".to_string(), + unlocked_at: None, + }, + AchievementId::ReadmeHero => Achievement { + id: id.clone(), + name: "README Hero".to_string(), + description: "Created or edited README files!".to_string(), + icon: "๐Ÿ“‹".to_string(), + unlocked_at: None, + }, + + // API & Integration + AchievementId::ApiExplorer => Achievement { + id: id.clone(), + name: "API Explorer".to_string(), + description: "Worked with APIs!".to_string(), + icon: "๐Ÿ”Œ".to_string(), + unlocked_at: None, + }, + AchievementId::DatabaseDev => Achievement { + id: id.clone(), + name: "Database Developer".to_string(), + description: "Worked with databases!".to_string(), + icon: "๐Ÿ—„๏ธ".to_string(), + unlocked_at: None, + }, + AchievementId::CloudCoder => Achievement { + id: id.clone(), + name: "Cloud Coder".to_string(), + description: "Worked with cloud services!".to_string(), + icon: "โ˜๏ธ".to_string(), + unlocked_at: None, + }, + + // Special Milestones + AchievementId::CenturyClub => Achievement { + id: id.clone(), + name: "Century Club".to_string(), + description: "Started 100 sessions!".to_string(), + icon: "๐Ÿ’ฏ".to_string(), + unlocked_at: None, + }, + AchievementId::ThousandSessions => Achievement { + id: id.clone(), + name: "Thousand Sessions".to_string(), + description: "Started 1,000 sessions!".to_string(), + icon: "๐ŸŽฐ".to_string(), + unlocked_at: None, + }, + AchievementId::Veteran => Achievement { + id: id.clone(), + name: "Veteran".to_string(), + description: "Used Hikari for 30+ days!".to_string(), + icon: "๐ŸŽ–๏ธ".to_string(), + unlocked_at: None, + }, + AchievementId::OldTimer => Achievement { + id: id.clone(), + name: "Old Timer".to_string(), + description: "Used Hikari for 90+ days!".to_string(), + icon: "โŒ›".to_string(), + unlocked_at: None, + }, + AchievementId::Loyalist => Achievement { + id: id.clone(), + name: "Loyalist".to_string(), + description: "Used Hikari for a whole year! I love you!".to_string(), + icon: "๐Ÿ’•".to_string(), + unlocked_at: None, + }, + + // Fun & Easter Eggs + AchievementId::Perfectionist => Achievement { + id: id.clone(), + name: "Perfectionist".to_string(), + description: "Redid something 5 times to get it right!".to_string(), + icon: "๐ŸŽฏ".to_string(), + unlocked_at: None, + }, + AchievementId::Persistent => Achievement { + id: id.clone(), + name: "Persistent".to_string(), + description: "Asked the same question 3 times. Never give up!".to_string(), + icon: "๐Ÿ’ช".to_string(), + unlocked_at: None, + }, + AchievementId::Patient => Achievement { + id: id.clone(), + name: "Patient".to_string(), + description: "Waited for a long response. Thank you for waiting!".to_string(), + icon: "โณ".to_string(), + unlocked_at: None, + }, + AchievementId::Speedy => Achievement { + id: id.clone(), + name: "Speedy".to_string(), + description: "Sent 10 messages in 1 minute! Slow down~".to_string(), + icon: "โšก".to_string(), + unlocked_at: None, + }, + AchievementId::MultiTasker => Achievement { + id: id.clone(), + name: "Multi-Tasker".to_string(), + description: "Had 5+ conversation tabs open!".to_string(), + icon: "๐Ÿ“‘".to_string(), + unlocked_at: None, + }, + AchievementId::Minimalist => Achievement { + id: id.clone(), + name: "Minimalist".to_string(), + description: "Used compact mode!".to_string(), + icon: "๐Ÿ“ฑ".to_string(), + unlocked_at: None, + }, + AchievementId::PrivacyFirst => Achievement { + id: id.clone(), + name: "Privacy First".to_string(), + description: "Enabled streamer mode!".to_string(), + icon: "๐Ÿ”’".to_string(), + unlocked_at: None, + }, + AchievementId::ThemeChanger => Achievement { + id: id.clone(), + name: "Theme Changer".to_string(), + description: "Changed theme 3 times!".to_string(), + icon: "๐ŸŽจ".to_string(), + unlocked_at: None, + }, + AchievementId::SettingsTweaker => Achievement { + id: id.clone(), + name: "Settings Tweaker".to_string(), + description: "Opened settings 10 times!".to_string(), + icon: "โš™๏ธ".to_string(), + unlocked_at: None, + }, + AchievementId::AchievementHunter => Achievement { + id: id.clone(), + name: "Achievement Hunter".to_string(), + description: "Checked achievements panel 20 times!".to_string(), + icon: "๐Ÿ†".to_string(), + unlocked_at: None, + }, + AchievementId::Completionist => Achievement { + id: id.clone(), + name: "Completionist".to_string(), + description: "Unlocked 50% of achievements!".to_string(), + icon: "โญ".to_string(), + unlocked_at: None, + }, + AchievementId::MasterUnlocker => Achievement { + id: id.clone(), + name: "Master Unlocker".to_string(), + description: "Unlocked 75% of achievements!".to_string(), + icon: "๐ŸŒŸ".to_string(), + unlocked_at: None, + }, + AchievementId::PlatinumStatus => Achievement { + id: id.clone(), + name: "Platinum Status".to_string(), + description: "Unlocked 100% of achievements! You're amazing!".to_string(), + icon: "๐Ÿ’Ž".to_string(), + unlocked_at: None, + }, + + // Clipboard & Snippets + AchievementId::ClipboardCollector => Achievement { + id: id.clone(), + name: "Clipboard Collector".to_string(), + description: "Saved 20 clipboard entries!".to_string(), + icon: "๐Ÿ“‹".to_string(), + unlocked_at: None, + }, + AchievementId::SnippetCreator => Achievement { + id: id.clone(), + name: "Snippet Creator".to_string(), + description: "Created 5 custom snippets!".to_string(), + icon: "โœ‚๏ธ".to_string(), + unlocked_at: None, + }, + AchievementId::SnippetMaster => Achievement { + id: id.clone(), + name: "Snippet Master".to_string(), + description: "Used snippets 50 times!".to_string(), + icon: "๐Ÿ“Ž".to_string(), + unlocked_at: None, + }, + AchievementId::QuickActionUser => Achievement { + id: id.clone(), + name: "Quick Action User".to_string(), + description: "Used quick actions 20 times!".to_string(), + icon: "โšก".to_string(), + unlocked_at: None, + }, + + // Session History + AchievementId::HistoryBuff => Achievement { + id: id.clone(), + name: "History Buff".to_string(), + description: "Saved 10 sessions!".to_string(), + icon: "๐Ÿ“š".to_string(), + unlocked_at: None, + }, + AchievementId::Archivist => Achievement { + id: id.clone(), + name: "Archivist".to_string(), + description: "Saved 50 sessions!".to_string(), + icon: "๐Ÿ›๏ธ".to_string(), + unlocked_at: None, + }, + AchievementId::SessionExporter => Achievement { + id: id.clone(), + name: "Session Exporter".to_string(), + description: "Exported a session!".to_string(), + icon: "๐Ÿ“ค".to_string(), + unlocked_at: None, + }, + + // New Features + AchievementId::GitPanelUser => Achievement { + id: id.clone(), + name: "Git Panel User".to_string(), + description: "Used the git panel 10 times!".to_string(), + icon: "๐ŸŒฟ".to_string(), + unlocked_at: None, + }, + AchievementId::FeatureExplorer => Achievement { + id: id.clone(), + name: "Feature Explorer".to_string(), + description: "Tried all major features!".to_string(), + icon: "๐Ÿ—บ๏ธ".to_string(), + unlocked_at: None, + }, } } +// Get all achievement IDs for calculating completion percentage +pub fn get_all_achievement_ids() -> Vec { + vec![ + // Token Milestones + AchievementId::FirstSteps, + AchievementId::GrowingStrong, + AchievementId::BlossomingCoder, + AchievementId::TokenMaster, + AchievementId::TokenBillionaire, + AchievementId::TokenTreasure, + // Code Generation + AchievementId::HelloWorld, + AchievementId::CodeWizard, + AchievementId::ThousandBlocks, + AchievementId::CodeFactory, + AchievementId::CodeEmpire, + // File Operations + AchievementId::FileManipulator, + AchievementId::FileArchitect, + AchievementId::FileEngineer, + AchievementId::FileLegend, + // Conversation milestones + AchievementId::ConversationStarter, + AchievementId::ChattyKathy, + AchievementId::Conversationalist, + AchievementId::ChatMarathon, + AchievementId::ChatLegend, + // Tool usage + AchievementId::Toolsmith, + AchievementId::ToolMaster, + // Time-based achievements + AchievementId::EarlyBird, + AchievementId::NightOwl, + AchievementId::AllNighter, + AchievementId::WeekendWarrior, + AchievementId::DedicatedDeveloper, + // Search and exploration + AchievementId::Explorer, + AchievementId::MasterSearcher, + // Session achievements + AchievementId::QuickSession, + AchievementId::FocusedWork, + AchievementId::DeepDive, + AchievementId::MarathonSession, + AchievementId::UltraMarathon, + AchievementId::CodingRetreat, + // Special achievements + AchievementId::FirstMessage, + AchievementId::FirstTool, + AchievementId::FirstCodeBlock, + AchievementId::FirstFileEdit, + AchievementId::Polyglot, + AchievementId::SpeedCoder, + AchievementId::ClaudeConnoisseur, + AchievementId::MarathonCoder, + // Relationship & Greetings + AchievementId::GoodMorning, + AchievementId::GoodNight, + AchievementId::ThankYou, + AchievementId::LoveYou, + AchievementId::HelloHikari, + AchievementId::HowAreYou, + AchievementId::MissedYou, + AchievementId::BackAgain, + // Personality & Fun + AchievementId::EmojiUser, + AchievementId::QuestionMaster, + AchievementId::CapsLock, + AchievementId::PleaseAndThankYou, + // Emotional + AchievementId::Frustrated, + AchievementId::Excited, + AchievementId::Confused, + AchievementId::Curious, + AchievementId::Impressed, + // Git & Development + AchievementId::GitGuru, + AchievementId::TestWriter, + AchievementId::Debugger, + AchievementId::CommitKing, + AchievementId::CommitLegend, + AchievementId::BranchMaster, + AchievementId::MergeExpert, + AchievementId::ConflictResolver, + // Tool Mastery + AchievementId::BashMaster, + AchievementId::FileExplorer, + AchievementId::SearchExpert, + AchievementId::EditMaster, + AchievementId::WriteMaster, + AchievementId::GlobMaster, + AchievementId::TaskMaster, + AchievementId::WebFetcher, + AchievementId::McpExplorer, + // Daily Streaks + AchievementId::WeekStreak, + AchievementId::TwoWeekStreak, + AchievementId::MonthStreak, + AchievementId::QuarterStreak, + // Time Challenges + AchievementId::MorningPerson, + AchievementId::NightCoder, + AchievementId::LunchBreakCoder, + AchievementId::CoffeeTime, + // Day-specific + AchievementId::MondayMotivation, + AchievementId::FridayFinisher, + AchievementId::HumpDay, + // Seasonal/Special Times + AchievementId::NewYearCoder, + AchievementId::ValentinesDev, + AchievementId::SpookyCode, + AchievementId::HolidayCoder, + AchievementId::LeapDayCoder, + // Message Content + AchievementId::LongMessage, + AchievementId::NovelWriter, + AchievementId::ShortAndSweet, + AchievementId::CodeInMessage, + AchievementId::MarkdownMaster, + // Programming Languages + AchievementId::RustDeveloper, + AchievementId::PythonDeveloper, + AchievementId::JavaScriptDev, + AchievementId::TypeScriptDev, + AchievementId::GoDeveloper, + AchievementId::CppDeveloper, + AchievementId::JavaDeveloper, + AchievementId::HtmlCssDev, + AchievementId::SqlDeveloper, + AchievementId::ShellScripter, + AchievementId::FullStackDev, + // Project Types + AchievementId::FrontendDev, + AchievementId::BackendDev, + AchievementId::ConfigEditor, + AchievementId::DocWriter, + // Error Handling + AchievementId::ErrorHunter, + AchievementId::ExceptionSlayer, + AchievementId::BugExterminator, + // Refactoring + AchievementId::CleanCoder, + AchievementId::Optimizer, + AchievementId::Simplifier, + // Testing + AchievementId::TestNovice, + AchievementId::TestEnthusiast, + AchievementId::TestMaster, + AchievementId::CoverageKing, + // Documentation + AchievementId::Documenter, + AchievementId::CommentWriter, + AchievementId::ReadmeHero, + // API & Integration + AchievementId::ApiExplorer, + AchievementId::DatabaseDev, + AchievementId::CloudCoder, + // Special Milestones + AchievementId::CenturyClub, + AchievementId::ThousandSessions, + AchievementId::Veteran, + AchievementId::OldTimer, + AchievementId::Loyalist, + // Fun & Easter Eggs + AchievementId::Perfectionist, + AchievementId::Persistent, + AchievementId::Patient, + AchievementId::Speedy, + AchievementId::MultiTasker, + AchievementId::Minimalist, + AchievementId::PrivacyFirst, + AchievementId::ThemeChanger, + AchievementId::SettingsTweaker, + AchievementId::AchievementHunter, + AchievementId::Completionist, + AchievementId::MasterUnlocker, + AchievementId::PlatinumStatus, + // Clipboard & Snippets + AchievementId::ClipboardCollector, + AchievementId::SnippetCreator, + AchievementId::SnippetMaster, + AchievementId::QuickActionUser, + // Session History + AchievementId::HistoryBuff, + AchievementId::Archivist, + AchievementId::SessionExporter, + // New Features + AchievementId::GitPanelUser, + AchievementId::FeatureExplorer, + ] +} + // Check achievements based on message content pub fn check_message_achievements( message: &str, @@ -550,6 +1719,140 @@ pub fn check_message_achievements( newly_unlocked.push(AchievementId::Debugger); } + // Extended greetings + if (message_lower.contains("hello hikari") || message_lower.contains("hi hikari")) + && progress.unlock(AchievementId::HelloHikari) + { + newly_unlocked.push(AchievementId::HelloHikari); + } + if message_lower.contains("how are you") && progress.unlock(AchievementId::HowAreYou) { + newly_unlocked.push(AchievementId::HowAreYou); + } + if message_lower.contains("missed you") && progress.unlock(AchievementId::MissedYou) { + newly_unlocked.push(AchievementId::MissedYou); + } + if (message_lower.contains("i'm back") || message_lower.contains("back again")) + && progress.unlock(AchievementId::BackAgain) + { + newly_unlocked.push(AchievementId::BackAgain); + } + + // Emotional achievements + if (message_lower.contains("frustrated") + || message_lower.contains("ugh") + || message_lower.contains("argh")) + && progress.unlock(AchievementId::Frustrated) + { + newly_unlocked.push(AchievementId::Frustrated); + } + if (message_lower.contains("excited") + || message_lower.contains("yay") + || message_lower.contains("woohoo")) + && progress.unlock(AchievementId::Excited) + { + newly_unlocked.push(AchievementId::Excited); + } + if (message_lower.contains("confused") || message_lower.contains("don't understand")) + && progress.unlock(AchievementId::Confused) + { + newly_unlocked.push(AchievementId::Confused); + } + if (message_lower.contains("why ") || message_lower.contains("how does")) + && progress.unlock(AchievementId::Curious) + { + newly_unlocked.push(AchievementId::Curious); + } + if (message_lower.contains("wow") + || message_lower.contains("amazing") + || message_lower.contains("incredible")) + && progress.unlock(AchievementId::Impressed) + { + newly_unlocked.push(AchievementId::Impressed); + } + + // Message content achievements + if message.len() > 500 && progress.unlock(AchievementId::LongMessage) { + newly_unlocked.push(AchievementId::LongMessage); + } + if message.len() > 2000 && progress.unlock(AchievementId::NovelWriter) { + newly_unlocked.push(AchievementId::NovelWriter); + } + // Code in message (triple backticks) + if message.contains("```") && progress.unlock(AchievementId::CodeInMessage) { + newly_unlocked.push(AchievementId::CodeInMessage); + } + // Markdown formatting + if (message.contains("**") + || message.contains("__") + || message.contains("##") + || message.contains("- ") + || message.contains("1. ")) + && progress.unlock(AchievementId::MarkdownMaster) + { + newly_unlocked.push(AchievementId::MarkdownMaster); + } + + // Refactoring keywords + if message_lower.contains("refactor") && progress.unlock(AchievementId::CleanCoder) { + newly_unlocked.push(AchievementId::CleanCoder); + } + if (message_lower.contains("optimize") || message_lower.contains("performance")) + && progress.unlock(AchievementId::Optimizer) + { + newly_unlocked.push(AchievementId::Optimizer); + } + if message_lower.contains("simplify") && progress.unlock(AchievementId::Simplifier) { + newly_unlocked.push(AchievementId::Simplifier); + } + + // Testing keywords + if message_lower.contains("test") && progress.unlock(AchievementId::TestWriter) { + newly_unlocked.push(AchievementId::TestWriter); + } + if message_lower.contains("coverage") && progress.unlock(AchievementId::CoverageKing) { + newly_unlocked.push(AchievementId::CoverageKing); + } + + // Documentation keywords + if (message_lower.contains("document") || message_lower.contains("documentation")) + && progress.unlock(AchievementId::Documenter) + { + newly_unlocked.push(AchievementId::Documenter); + } + if message_lower.contains("comment") && progress.unlock(AchievementId::CommentWriter) { + newly_unlocked.push(AchievementId::CommentWriter); + } + if message_lower.contains("readme") && progress.unlock(AchievementId::ReadmeHero) { + newly_unlocked.push(AchievementId::ReadmeHero); + } + + // API & Integration keywords + if message_lower.contains("api") && progress.unlock(AchievementId::ApiExplorer) { + newly_unlocked.push(AchievementId::ApiExplorer); + } + if (message_lower.contains("database") + || message_lower.contains("sql") + || message_lower.contains("postgres") + || message_lower.contains("mysql")) + && progress.unlock(AchievementId::DatabaseDev) + { + newly_unlocked.push(AchievementId::DatabaseDev); + } + if (message_lower.contains("cloud") + || message_lower.contains("aws") + || message_lower.contains("azure") + || message_lower.contains("gcp")) + && progress.unlock(AchievementId::CloudCoder) + { + newly_unlocked.push(AchievementId::CloudCoder); + } + + // Git keywords + if message_lower.contains("merge conflict") && progress.unlock(AchievementId::ConflictResolver) + { + newly_unlocked.push(AchievementId::ConflictResolver); + } + newly_unlocked } @@ -709,6 +2012,85 @@ pub fn check_achievements( } } + // Extended token milestones + if total_tokens >= 10_000_000 && progress.unlock(AchievementId::TokenBillionaire) { + newly_unlocked.push(AchievementId::TokenBillionaire); + } + if total_tokens >= 50_000_000 && progress.unlock(AchievementId::TokenTreasure) { + newly_unlocked.push(AchievementId::TokenTreasure); + } + + // Extended code generation + if stats.code_blocks_generated >= 5000 && progress.unlock(AchievementId::CodeFactory) { + newly_unlocked.push(AchievementId::CodeFactory); + } + if stats.code_blocks_generated >= 10000 && progress.unlock(AchievementId::CodeEmpire) { + newly_unlocked.push(AchievementId::CodeEmpire); + } + + // Extended file operations + if total_files >= 500 && progress.unlock(AchievementId::FileEngineer) { + newly_unlocked.push(AchievementId::FileEngineer); + } + if total_files >= 1000 && progress.unlock(AchievementId::FileLegend) { + newly_unlocked.push(AchievementId::FileLegend); + } + + // Extended conversation milestones + if stats.messages_exchanged >= 5000 && progress.unlock(AchievementId::ChatMarathon) { + newly_unlocked.push(AchievementId::ChatMarathon); + } + if stats.messages_exchanged >= 10000 && progress.unlock(AchievementId::ChatLegend) { + newly_unlocked.push(AchievementId::ChatLegend); + } + + // Extended session duration achievements + if session_secs >= 28800 && progress.unlock(AchievementId::UltraMarathon) { + // 8 hours + newly_unlocked.push(AchievementId::UltraMarathon); + } + if session_secs >= 43200 && progress.unlock(AchievementId::CodingRetreat) { + // 12 hours + newly_unlocked.push(AchievementId::CodingRetreat); + } + + // More tool mastery achievements + if let Some(edit_count) = stats.tools_usage.get("Edit") { + if *edit_count >= 100 && progress.unlock(AchievementId::EditMaster) { + newly_unlocked.push(AchievementId::EditMaster); + } + } + if let Some(write_count) = stats.tools_usage.get("Write") { + if *write_count >= 50 && progress.unlock(AchievementId::WriteMaster) { + newly_unlocked.push(AchievementId::WriteMaster); + } + } + if let Some(glob_count) = stats.tools_usage.get("Glob") { + if *glob_count >= 100 && progress.unlock(AchievementId::GlobMaster) { + newly_unlocked.push(AchievementId::GlobMaster); + } + } + if let Some(task_count) = stats.tools_usage.get("Task") { + if *task_count >= 50 && progress.unlock(AchievementId::TaskMaster) { + newly_unlocked.push(AchievementId::TaskMaster); + } + } + if let Some(web_count) = stats.tools_usage.get("WebFetch") { + if *web_count >= 20 && progress.unlock(AchievementId::WebFetcher) { + newly_unlocked.push(AchievementId::WebFetcher); + } + } + // MCP tools - count tools that contain "mcp__" + let mcp_count: u64 = stats + .tools_usage + .iter() + .filter(|(name, _)| name.starts_with("mcp__")) + .map(|(_, count)| count) + .sum(); + if mcp_count >= 50 && progress.unlock(AchievementId::McpExplorer) { + newly_unlocked.push(AchievementId::McpExplorer); + } + // Time-based achievements if let Some(session_start) = progress.session_start { let hour = session_start.hour(); @@ -737,10 +2119,111 @@ pub fn check_achievements( { newly_unlocked.push(AchievementId::WeekendWarrior); } + + // Day-specific achievements + if weekday == Weekday::Mon && progress.unlock(AchievementId::MondayMotivation) { + newly_unlocked.push(AchievementId::MondayMotivation); + } + if weekday == Weekday::Wed && progress.unlock(AchievementId::HumpDay) { + newly_unlocked.push(AchievementId::HumpDay); + } + if weekday == Weekday::Fri && progress.unlock(AchievementId::FridayFinisher) { + newly_unlocked.push(AchievementId::FridayFinisher); + } + + // Time of day achievements + if (12..=13).contains(&hour) && progress.unlock(AchievementId::LunchBreakCoder) { + newly_unlocked.push(AchievementId::LunchBreakCoder); + } + if (15..=16).contains(&hour) && progress.unlock(AchievementId::CoffeeTime) { + newly_unlocked.push(AchievementId::CoffeeTime); + } + + // Seasonal/special day achievements + let now = Utc::now(); + let month = now.month(); + let day = now.day(); + + // New Year's Day (January 1st) + if month == 1 && day == 1 && progress.unlock(AchievementId::NewYearCoder) { + newly_unlocked.push(AchievementId::NewYearCoder); + } + // Valentine's Day (February 14th) + if month == 2 && day == 14 && progress.unlock(AchievementId::ValentinesDev) { + newly_unlocked.push(AchievementId::ValentinesDev); + } + // Leap Day (February 29th) + if month == 2 && day == 29 && progress.unlock(AchievementId::LeapDayCoder) { + newly_unlocked.push(AchievementId::LeapDayCoder); + } + // Halloween (October 31st) + if month == 10 && day == 31 && progress.unlock(AchievementId::SpookyCode) { + newly_unlocked.push(AchievementId::SpookyCode); + } + // Christmas (December 25th) + if month == 12 && day == 25 && progress.unlock(AchievementId::HolidayCoder) { + newly_unlocked.push(AchievementId::HolidayCoder); + } } - // Dedicated Developer - need to track consecutive days - // TODO: Implement 30 days in a row tracking + // Session count achievements (from stats) + if stats.sessions_started >= 100 && progress.unlock(AchievementId::CenturyClub) { + newly_unlocked.push(AchievementId::CenturyClub); + } + if stats.sessions_started >= 1000 && progress.unlock(AchievementId::ThousandSessions) { + newly_unlocked.push(AchievementId::ThousandSessions); + } + + // Daily streaks (need to check stats for consecutive days) + if stats.consecutive_days >= 7 && progress.unlock(AchievementId::WeekStreak) { + newly_unlocked.push(AchievementId::WeekStreak); + } + if stats.consecutive_days >= 14 && progress.unlock(AchievementId::TwoWeekStreak) { + newly_unlocked.push(AchievementId::TwoWeekStreak); + } + if stats.consecutive_days >= 30 && progress.unlock(AchievementId::DedicatedDeveloper) { + newly_unlocked.push(AchievementId::DedicatedDeveloper); + } + if stats.consecutive_days >= 30 && progress.unlock(AchievementId::MonthStreak) { + newly_unlocked.push(AchievementId::MonthStreak); + } + if stats.consecutive_days >= 90 && progress.unlock(AchievementId::QuarterStreak) { + newly_unlocked.push(AchievementId::QuarterStreak); + } + + // Total days used (Veteran/OldTimer/Loyalist) + if stats.total_days_used >= 30 && progress.unlock(AchievementId::Veteran) { + newly_unlocked.push(AchievementId::Veteran); + } + if stats.total_days_used >= 90 && progress.unlock(AchievementId::OldTimer) { + newly_unlocked.push(AchievementId::OldTimer); + } + if stats.total_days_used >= 365 && progress.unlock(AchievementId::Loyalist) { + newly_unlocked.push(AchievementId::Loyalist); + } + + // Morning/Night person tracking (from session start times stored in stats) + if stats.morning_sessions >= 10 && progress.unlock(AchievementId::MorningPerson) { + newly_unlocked.push(AchievementId::MorningPerson); + } + if stats.night_sessions >= 10 && progress.unlock(AchievementId::NightCoder) { + newly_unlocked.push(AchievementId::NightCoder); + } + + // Completion percentage achievements + let total_achievements = get_all_achievement_ids().len(); + let unlocked_count = progress.unlocked.len(); + let completion_pct = (unlocked_count * 100) / total_achievements; + + if completion_pct >= 50 && progress.unlock(AchievementId::Completionist) { + newly_unlocked.push(AchievementId::Completionist); + } + if completion_pct >= 75 && progress.unlock(AchievementId::MasterUnlocker) { + newly_unlocked.push(AchievementId::MasterUnlocker); + } + if completion_pct >= 100 && progress.unlock(AchievementId::PlatinumStatus) { + newly_unlocked.push(AchievementId::PlatinumStatus); + } newly_unlocked } 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/commands.rs b/src-tauri/src/commands.rs index 75373db..d9845dd 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -102,6 +102,19 @@ pub async fn get_usage_stats( manager.get_usage_stats(&conversation_id) } +/// Load persisted lifetime stats from store (no bridge required) +#[tauri::command] +pub async fn get_persisted_stats(app: AppHandle) -> Result { + let mut stats = UsageStats::new(); + + // Load persisted stats if available + if let Some(persisted) = crate::stats::load_stats(&app).await { + stats.apply_persisted(persisted); + } + + Ok(stats) +} + #[tauri::command] pub async fn validate_directory( path: String, diff --git a/src-tauri/src/config.rs b/src-tauri/src/config.rs index d6f6fae..6ca733e 100644 --- a/src-tauri/src/config.rs +++ b/src-tauri/src/config.rs @@ -70,6 +70,32 @@ pub struct HikariConfig { #[serde(default = "default_font_size")] pub font_size: u32, + + #[serde(default)] + pub minimize_to_tray: bool, + + #[serde(default)] + pub streamer_mode: bool, + + #[serde(default)] + pub streamer_hide_paths: bool, + + #[serde(default)] + pub compact_mode: bool, + + // Profile fields + #[serde(default)] + pub profile_name: Option, + + #[serde(default)] + pub profile_avatar_path: Option, + + #[serde(default)] + pub profile_bio: Option, + + // Custom theme colors + #[serde(default)] + pub custom_theme_colors: CustomThemeColors, } impl Default for HikariConfig { @@ -89,6 +115,14 @@ impl Default for HikariConfig { update_checks_enabled: true, character_panel_width: None, font_size: 14, + minimize_to_tray: false, + streamer_mode: false, + streamer_hide_paths: false, + compact_mode: false, + profile_name: None, + profile_avatar_path: None, + profile_bio: None, + custom_theme_colors: CustomThemeColors::default(), } } } @@ -119,6 +153,29 @@ pub enum Theme { #[default] Dark, 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)] @@ -140,6 +197,14 @@ 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); + assert!(!config.streamer_mode); + assert!(!config.streamer_hide_paths); + assert!(!config.compact_mode); + 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] @@ -159,6 +224,14 @@ mod tests { update_checks_enabled: true, character_panel_width: Some(400), font_size: 16, + minimize_to_tray: true, + streamer_mode: false, + streamer_hide_paths: false, + compact_mode: false, + 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(); @@ -179,8 +252,16 @@ mod tests { fn test_theme_serialization() { let dark = Theme::Dark; let light = Theme::Light; + let high_contrast = Theme::HighContrast; assert_eq!(serde_json::to_string(&dark).unwrap(), "\"dark\""); assert_eq!(serde_json::to_string(&light).unwrap(), "\"light\""); + assert_eq!( + 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-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 f635d4d..0dc2487 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,10 +1,16 @@ mod achievements; mod bridge_manager; +mod clipboard; mod commands; mod config; +mod git; mod notifications; +mod quick_actions; +mod sessions; +mod snippets; mod stats; mod temp_manager; +mod tray; mod types; mod vbs_notification; mod windows_toast; @@ -12,10 +18,17 @@ mod wsl_bridge; mod wsl_notifications; use bridge_manager::create_shared_bridge_manager; +use clipboard::*; use commands::load_saved_achievements; use commands::*; +use git::*; use notifications::*; +use quick_actions::*; +use sessions::*; +use snippets::*; +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::*; @@ -34,6 +47,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| { @@ -47,6 +61,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![ @@ -60,6 +95,7 @@ pub fn run() { get_config, save_config, get_usage_stats, + get_persisted_stats, load_saved_achievements, answer_question, send_windows_notification, @@ -78,6 +114,43 @@ 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, + list_snippets, + save_snippet, + delete_snippet, + get_snippet_categories, + reset_default_snippets, + list_quick_actions, + save_quick_action, + delete_quick_action, + reset_default_quick_actions, + 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, + 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-tauri/src/quick_actions.rs b/src-tauri/src/quick_actions.rs new file mode 100644 index 0000000..92be5c5 --- /dev/null +++ b/src-tauri/src/quick_actions.rs @@ -0,0 +1,191 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use tauri::AppHandle; +use tauri_plugin_store::StoreExt; + +const QUICK_ACTIONS_STORE_KEY: &str = "quick_actions"; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct QuickAction { + pub id: String, + pub name: String, + pub prompt: String, + pub icon: String, + pub is_default: bool, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +fn get_default_quick_actions() -> Vec { + let now = Utc::now(); + vec![ + QuickAction { + id: "default-review-pr".to_string(), + name: "Review PR".to_string(), + prompt: "Please review this pull request and provide feedback on code quality, potential issues, and suggestions for improvement.".to_string(), + icon: "git-pull-request".to_string(), + is_default: true, + created_at: now, + updated_at: now, + }, + QuickAction { + id: "default-run-tests".to_string(), + name: "Run Tests".to_string(), + prompt: "Please run the test suite for this project and report any failures or issues.".to_string(), + icon: "play".to_string(), + is_default: true, + created_at: now, + updated_at: now, + }, + QuickAction { + id: "default-explain-file".to_string(), + name: "Explain File".to_string(), + prompt: "Please explain what this file does, its purpose, and how it fits into the overall project structure.".to_string(), + icon: "file-text".to_string(), + is_default: true, + created_at: now, + updated_at: now, + }, + QuickAction { + id: "default-fix-error".to_string(), + name: "Fix Error".to_string(), + prompt: "I'm getting an error. Can you help me identify the cause and fix it?".to_string(), + icon: "alert-circle".to_string(), + is_default: true, + created_at: now, + updated_at: now, + }, + QuickAction { + id: "default-write-tests".to_string(), + name: "Write Tests".to_string(), + prompt: "Please write comprehensive unit tests for the current code with good coverage.".to_string(), + icon: "check-square".to_string(), + is_default: true, + created_at: now, + updated_at: now, + }, + QuickAction { + id: "default-refactor".to_string(), + name: "Refactor".to_string(), + prompt: "Please refactor this code to improve readability, maintainability, and performance.".to_string(), + icon: "refresh-cw".to_string(), + is_default: true, + created_at: now, + updated_at: now, + }, + ] +} + +fn load_all_quick_actions(app: &AppHandle) -> Result, String> { + let store = app + .store("hikari-quick-actions.json") + .map_err(|e| e.to_string())?; + + match store.get(QUICK_ACTIONS_STORE_KEY) { + Some(value) => { + let mut actions: Vec = + serde_json::from_value(value.clone()).map_err(|e| e.to_string())?; + + let defaults = get_default_quick_actions(); + for default in defaults { + if !actions.iter().any(|a| a.id == default.id) { + actions.push(default); + } + } + + Ok(actions) + } + None => Ok(get_default_quick_actions()), + } +} + +fn save_all_quick_actions(app: &AppHandle, actions: &[QuickAction]) -> Result<(), String> { + let store = app + .store("hikari-quick-actions.json") + .map_err(|e| e.to_string())?; + + let value = serde_json::to_value(actions).map_err(|e| e.to_string())?; + store.set(QUICK_ACTIONS_STORE_KEY, value); + store.save().map_err(|e| e.to_string())?; + + Ok(()) +} + +#[tauri::command] +pub async fn list_quick_actions(app: AppHandle) -> Result, String> { + let mut actions = load_all_quick_actions(&app)?; + + actions.sort_by(|a, b| { + let default_cmp = b.is_default.cmp(&a.is_default); + if default_cmp == std::cmp::Ordering::Equal { + a.name.cmp(&b.name) + } else { + default_cmp + } + }); + + Ok(actions) +} + +#[tauri::command] +pub async fn save_quick_action(app: AppHandle, action: QuickAction) -> Result<(), String> { + let mut actions = load_all_quick_actions(&app)?; + + if let Some(existing) = actions.iter_mut().find(|a| a.id == action.id) { + let mut updated = action; + updated.is_default = existing.is_default; + *existing = updated; + } else { + actions.push(action); + } + + save_all_quick_actions(&app, &actions) +} + +#[tauri::command] +pub async fn delete_quick_action(app: AppHandle, action_id: String) -> Result<(), String> { + let mut actions = load_all_quick_actions(&app)?; + + if actions + .iter() + .any(|a| a.id == action_id && a.is_default) + { + return Err("Cannot delete default quick actions".to_string()); + } + + actions.retain(|a| a.id != action_id); + save_all_quick_actions(&app, &actions) +} + +#[tauri::command] +pub async fn reset_default_quick_actions(app: AppHandle) -> Result<(), String> { + let mut actions = load_all_quick_actions(&app)?; + + actions.retain(|a| !a.is_default); + actions.extend(get_default_quick_actions()); + + save_all_quick_actions(&app, &actions) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_default_quick_actions_exist() { + let defaults = get_default_quick_actions(); + assert!(!defaults.is_empty()); + assert!(defaults.iter().all(|a| a.is_default)); + } + + #[test] + fn test_default_quick_actions_have_required_fields() { + let defaults = get_default_quick_actions(); + for action in defaults { + assert!(!action.id.is_empty()); + assert!(!action.name.is_empty()); + assert!(!action.prompt.is_empty()); + assert!(!action.icon.is_empty()); + } + } +} 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-tauri/src/snippets.rs b/src-tauri/src/snippets.rs new file mode 100644 index 0000000..5ebf22d --- /dev/null +++ b/src-tauri/src/snippets.rs @@ -0,0 +1,226 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use tauri::AppHandle; +use tauri_plugin_store::StoreExt; + +const SNIPPETS_STORE_KEY: &str = "snippets"; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Snippet { + pub id: String, + pub name: String, + pub content: String, + pub category: String, + pub is_default: bool, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +fn get_default_snippets() -> Vec { + let now = Utc::now(); + vec![ + Snippet { + id: "default-explain-code".to_string(), + name: "Explain this code".to_string(), + content: "Please explain what this code does, step by step:".to_string(), + category: "Code Review".to_string(), + is_default: true, + created_at: now, + updated_at: now, + }, + Snippet { + id: "default-fix-error".to_string(), + name: "Fix this error".to_string(), + content: "I'm getting the following error. Can you help me fix it?".to_string(), + category: "Debugging".to_string(), + is_default: true, + created_at: now, + updated_at: now, + }, + Snippet { + id: "default-write-tests".to_string(), + name: "Write tests".to_string(), + content: "Please write unit tests for this code with good coverage:".to_string(), + category: "Testing".to_string(), + is_default: true, + created_at: now, + updated_at: now, + }, + Snippet { + id: "default-refactor".to_string(), + name: "Refactor for clarity".to_string(), + content: "Please refactor this code to improve readability and maintainability:".to_string(), + category: "Code Review".to_string(), + is_default: true, + created_at: now, + updated_at: now, + }, + Snippet { + id: "default-optimize".to_string(), + name: "Optimize performance".to_string(), + content: "Please analyze this code for performance issues and suggest optimizations:".to_string(), + category: "Performance".to_string(), + is_default: true, + created_at: now, + updated_at: now, + }, + Snippet { + id: "default-review-pr".to_string(), + name: "Review PR".to_string(), + content: "Please review this pull request and provide feedback on code quality, potential issues, and suggestions for improvement.".to_string(), + category: "Code Review".to_string(), + is_default: true, + created_at: now, + updated_at: now, + }, + Snippet { + id: "default-add-comments".to_string(), + name: "Add documentation".to_string(), + content: "Please add clear documentation comments to this code explaining what it does:".to_string(), + category: "Documentation".to_string(), + is_default: true, + created_at: now, + updated_at: now, + }, + Snippet { + id: "default-security-review".to_string(), + name: "Security review".to_string(), + content: "Please review this code for security vulnerabilities and suggest fixes:".to_string(), + category: "Security".to_string(), + is_default: true, + created_at: now, + updated_at: now, + }, + ] +} + +fn load_all_snippets(app: &AppHandle) -> Result, String> { + let store = app + .store("hikari-snippets.json") + .map_err(|e| e.to_string())?; + + match store.get(SNIPPETS_STORE_KEY) { + Some(value) => { + let mut snippets: Vec = + serde_json::from_value(value.clone()).map_err(|e| e.to_string())?; + + // Ensure default snippets exist (in case new ones were added in an update) + let defaults = get_default_snippets(); + for default in defaults { + if !snippets.iter().any(|s| s.id == default.id) { + snippets.push(default); + } + } + + Ok(snippets) + } + None => Ok(get_default_snippets()), + } +} + +fn save_all_snippets(app: &AppHandle, snippets: &[Snippet]) -> Result<(), String> { + let store = app + .store("hikari-snippets.json") + .map_err(|e| e.to_string())?; + + let value = serde_json::to_value(snippets).map_err(|e| e.to_string())?; + store.set(SNIPPETS_STORE_KEY, value); + store.save().map_err(|e| e.to_string())?; + + Ok(()) +} + +#[tauri::command] +pub async fn list_snippets(app: AppHandle) -> Result, String> { + let mut snippets = load_all_snippets(&app)?; + + // Sort by category, then by name + snippets.sort_by(|a, b| { + let cat_cmp = a.category.cmp(&b.category); + if cat_cmp == std::cmp::Ordering::Equal { + a.name.cmp(&b.name) + } else { + cat_cmp + } + }); + + Ok(snippets) +} + +#[tauri::command] +pub async fn save_snippet(app: AppHandle, snippet: Snippet) -> Result<(), String> { + let mut snippets = load_all_snippets(&app)?; + + // Update existing or add new + if let Some(existing) = snippets.iter_mut().find(|s| s.id == snippet.id) { + // Don't allow editing default snippets' is_default flag + let mut updated = snippet; + updated.is_default = existing.is_default; + *existing = updated; + } else { + snippets.push(snippet); + } + + save_all_snippets(&app, &snippets) +} + +#[tauri::command] +pub async fn delete_snippet(app: AppHandle, snippet_id: String) -> Result<(), String> { + let mut snippets = load_all_snippets(&app)?; + + // Don't allow deleting default snippets + if snippets + .iter() + .any(|s| s.id == snippet_id && s.is_default) + { + return Err("Cannot delete default snippets".to_string()); + } + + snippets.retain(|s| s.id != snippet_id); + save_all_snippets(&app, &snippets) +} + +#[tauri::command] +pub async fn get_snippet_categories(app: AppHandle) -> Result, String> { + let snippets = load_all_snippets(&app)?; + let mut categories: Vec = snippets.iter().map(|s| s.category.clone()).collect(); + categories.sort(); + categories.dedup(); + Ok(categories) +} + +#[tauri::command] +pub async fn reset_default_snippets(app: AppHandle) -> Result<(), String> { + let mut snippets = load_all_snippets(&app)?; + + // Remove all default snippets + snippets.retain(|s| !s.is_default); + + // Add fresh default snippets + snippets.extend(get_default_snippets()); + + save_all_snippets(&app, &snippets) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_default_snippets_exist() { + let defaults = get_default_snippets(); + assert!(!defaults.is_empty()); + assert!(defaults.iter().all(|s| s.is_default)); + } + + #[test] + fn test_default_snippets_have_required_fields() { + let defaults = get_default_snippets(); + for snippet in defaults { + assert!(!snippet.id.is_empty()); + assert!(!snippet.name.is_empty()); + assert!(!snippet.content.is_empty()); + assert!(!snippet.category.is_empty()); + } + } +} diff --git a/src-tauri/src/stats.rs b/src-tauri/src/stats.rs index d04d7a4..5ac8be3 100644 --- a/src-tauri/src/stats.rs +++ b/src-tauri/src/stats.rs @@ -1,7 +1,9 @@ use crate::achievements::{check_achievements, AchievementProgress}; +use chrono::{Local, Timelike}; use serde::{Deserialize, Serialize}; use std::collections::HashMap; use std::time::Instant; +use tauri_plugin_store::StoreExt; #[derive(Debug, Clone, Serialize, Deserialize, Default)] pub struct UsageStats { @@ -28,6 +30,14 @@ pub struct UsageStats { #[serde(skip)] pub session_start: Option, + // Extended tracking for achievements + pub sessions_started: u64, + pub consecutive_days: u64, + pub total_days_used: u64, + pub morning_sessions: u64, // Sessions started before 9 AM + pub night_sessions: u64, // Sessions started after 10 PM + pub last_session_date: Option, // ISO date string for streak tracking + // Achievement tracking #[serde(skip)] pub achievements: AchievementProgress, @@ -65,6 +75,47 @@ impl UsageStats { self.session_duration_seconds = 0; self.session_start = Some(Instant::now()); self.achievements.start_session(); + + // Track session start for achievements + self.track_session_start(); + } + + pub fn track_session_start(&mut self) { + let now = Local::now(); + let today = now.format("%Y-%m-%d").to_string(); + let hour = now.hour(); + + // Increment session count + self.sessions_started += 1; + + // Track morning/night sessions + if hour < 9 { + self.morning_sessions += 1; + } + if hour >= 22 { + self.night_sessions += 1; + } + + // Track consecutive days and total days + if let Some(last_date) = &self.last_session_date { + if last_date != &today { + // Check if it's the next day (consecutive) + if is_consecutive_day(last_date, &today) { + self.consecutive_days += 1; + } else { + // Streak broken + self.consecutive_days = 1; + } + self.total_days_used += 1; + self.last_session_date = Some(today); + } + // Same day - don't increment anything + } else { + // First session ever + self.consecutive_days = 1; + self.total_days_used = 1; + self.last_session_date = Some(today); + } } pub fn increment_messages(&mut self) { @@ -127,12 +178,34 @@ impl UsageStats { session_tools_usage: self.session_tools_usage.clone(), session_duration_seconds: self.session_duration_seconds, session_start: self.session_start, + sessions_started: self.sessions_started, + consecutive_days: self.consecutive_days, + total_days_used: self.total_days_used, + morning_sessions: self.morning_sessions, + night_sessions: self.night_sessions, + last_session_date: self.last_session_date.clone(), achievements: AchievementProgress::new(), // Dummy for copy }; check_achievements(&stats_copy, &mut self.achievements) } } +// Helper function to check if two dates are consecutive +fn is_consecutive_day(prev_date: &str, current_date: &str) -> bool { + use chrono::NaiveDate; + + let prev = NaiveDate::parse_from_str(prev_date, "%Y-%m-%d").ok(); + let current = NaiveDate::parse_from_str(current_date, "%Y-%m-%d").ok(); + + match (prev, current) { + (Some(p), Some(c)) => { + let diff = c.signed_duration_since(p).num_days(); + diff == 1 + } + _ => false, + } +} + // Pricing as of January 2025 // https://www.anthropic.com/pricing fn calculate_cost(input_tokens: u64, output_tokens: u64, model: &str) -> f64 { @@ -169,6 +242,111 @@ pub struct StatsUpdateEvent { pub stats: UsageStats, } +/// Serializable struct for persisting only lifetime (total) stats +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct PersistedStats { + pub total_input_tokens: u64, + pub total_output_tokens: u64, + pub total_cost_usd: f64, + pub messages_exchanged: u64, + pub code_blocks_generated: u64, + pub files_edited: u64, + pub files_created: u64, + pub tools_usage: HashMap, + pub sessions_started: u64, + pub consecutive_days: u64, + pub total_days_used: u64, + pub morning_sessions: u64, + pub night_sessions: u64, + pub last_session_date: Option, +} + +impl From<&UsageStats> for PersistedStats { + fn from(stats: &UsageStats) -> Self { + PersistedStats { + total_input_tokens: stats.total_input_tokens, + total_output_tokens: stats.total_output_tokens, + total_cost_usd: stats.total_cost_usd, + messages_exchanged: stats.messages_exchanged, + code_blocks_generated: stats.code_blocks_generated, + files_edited: stats.files_edited, + files_created: stats.files_created, + tools_usage: stats.tools_usage.clone(), + sessions_started: stats.sessions_started, + consecutive_days: stats.consecutive_days, + total_days_used: stats.total_days_used, + morning_sessions: stats.morning_sessions, + night_sessions: stats.night_sessions, + last_session_date: stats.last_session_date.clone(), + } + } +} + +impl UsageStats { + /// Apply persisted stats to restore lifetime totals + pub fn apply_persisted(&mut self, persisted: PersistedStats) { + self.total_input_tokens = persisted.total_input_tokens; + self.total_output_tokens = persisted.total_output_tokens; + self.total_cost_usd = persisted.total_cost_usd; + self.messages_exchanged = persisted.messages_exchanged; + self.code_blocks_generated = persisted.code_blocks_generated; + self.files_edited = persisted.files_edited; + self.files_created = persisted.files_created; + self.tools_usage = persisted.tools_usage; + self.sessions_started = persisted.sessions_started; + self.consecutive_days = persisted.consecutive_days; + self.total_days_used = persisted.total_days_used; + self.morning_sessions = persisted.morning_sessions; + self.night_sessions = persisted.night_sessions; + self.last_session_date = persisted.last_session_date; + } +} + +/// Save lifetime stats to persistent store +pub async fn save_stats(app: &tauri::AppHandle, stats: &UsageStats) -> Result<(), String> { + let store = app.store("stats.json").map_err(|e| e.to_string())?; + + let persisted = PersistedStats::from(stats); + + println!("Saving stats: {:?}", persisted); + + store.set( + "lifetime_stats", + serde_json::to_value(persisted).map_err(|e| e.to_string())?, + ); + store.save().map_err(|e| e.to_string())?; + + println!("Stats saved successfully"); + Ok(()) +} + +/// Load lifetime stats from persistent store +pub async fn load_stats(app: &tauri::AppHandle) -> Option { + println!("Loading stats from store..."); + + let store = match app.store("stats.json") { + Ok(s) => s, + Err(e) => { + println!("Failed to open stats store: {}", e); + return None; + } + }; + + if let Some(stats_value) = store.get("lifetime_stats") { + println!("Found lifetime stats in store: {:?}", stats_value); + if let Ok(persisted) = serde_json::from_value::(stats_value.clone()) { + println!("Loaded lifetime stats successfully"); + return Some(persisted); + } else { + println!("Failed to parse lifetime stats"); + } + } else { + println!("No lifetime stats found in store"); + } + + None +} + #[cfg(test)] mod tests { use super::*; 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/src/wsl_bridge.rs b/src-tauri/src/wsl_bridge.rs index e0a6fcd..b1e8f31 100644 --- a/src-tauri/src/wsl_bridge.rs +++ b/src-tauri/src/wsl_bridge.rs @@ -112,7 +112,7 @@ impl WslBridge { return Err("Process already running".to_string()); } - // Load saved achievements when starting a new session + // Load saved achievements and stats when starting a new session let app_clone = app.clone(); let stats = self.stats.clone(); tauri::async_runtime::spawn(async move { @@ -122,7 +122,17 @@ impl WslBridge { "Loaded {} unlocked achievements", achievements.unlocked.len() ); - stats.write().achievements = achievements; + + println!("Loading saved stats..."); + let persisted_stats = crate::stats::load_stats(&app_clone).await; + + let mut stats_guard = stats.write(); + stats_guard.achievements = achievements; + + if let Some(persisted) = persisted_stats { + println!("Applying persisted lifetime stats"); + stats_guard.apply_persisted(persisted); + } }); let working_dir = &options.working_dir; @@ -440,6 +450,18 @@ impl WslBridge { self.session_id = None; self.mcp_config_file = None; // Temp file is automatically deleted when dropped + // Save lifetime stats before resetting session + let stats_snapshot = self.stats.read().clone(); + let app_clone = app.clone(); + tauri::async_runtime::spawn(async move { + println!("Saving stats on session stop..."); + if let Err(e) = crate::stats::save_stats(&app_clone, &stats_snapshot).await { + eprintln!("Failed to save stats: {}", e); + } else { + println!("Stats saved successfully on session stop"); + } + }); + // Reset session stats on explicit disconnect self.stats.write().reset_session(); @@ -733,10 +755,23 @@ fn process_json_line( let current_stats = stats.read().clone(); let stats_event = StatsUpdateEvent { - stats: current_stats, + stats: current_stats.clone(), }; let _ = app.emit("claude:stats", stats_event); + // Save stats periodically (every 10 messages to avoid excessive disk writes) + if current_stats.session_messages_exchanged.is_multiple_of(10) + && current_stats.session_messages_exchanged > 0 + { + let app_handle = app.clone(); + tauri::async_runtime::spawn(async move { + println!("Periodic stats save (every 10 messages)..."); + if let Err(e) = crate::stats::save_stats(&app_handle, ¤t_stats).await { + eprintln!("Failed to save stats: {}", e); + } + }); + } + // Only emit error results - success content is already sent via Assistant message if subtype != "success" { if let Some(text) = result { 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/app.css b/src/app.css index 2339269..13b8324 100644 --- a/src/app.css +++ b/src/app.css @@ -14,6 +14,25 @@ --text-tertiary: #6b7280; --border-color: #2a2a4a; + /* Trans pride colors */ + --trans-blue: #5bcefa; + --trans-pink: #f5a9b8; + --trans-white: #ffffff; + --trans-gradient: linear-gradient( + 135deg, + var(--trans-blue) 0%, + var(--trans-pink) 50%, + var(--trans-white) 100% + ); + --trans-gradient-vibrant: linear-gradient( + 135deg, + var(--trans-blue) 0%, + var(--trans-pink) 35%, + var(--trans-white) 50%, + var(--trans-pink) 65%, + var(--trans-blue) 100% + ); + /* Terminal specific colors */ --terminal-user: #22d3ee; --terminal-tool: #c084fc; @@ -44,6 +63,25 @@ --text-tertiary: #9ca3af; --border-color: #d0d0e0; + /* Trans pride colors */ + --trans-blue: #5bcefa; + --trans-pink: #f5a9b8; + --trans-white: #ffffff; + --trans-gradient: linear-gradient( + 135deg, + var(--trans-blue) 0%, + var(--trans-pink) 50%, + var(--trans-white) 100% + ); + --trans-gradient-vibrant: linear-gradient( + 135deg, + var(--trans-blue) 0%, + var(--trans-pink) 35%, + var(--trans-white) 50%, + var(--trans-pink) 65%, + var(--trans-blue) 100% + ); + /* Terminal specific colors */ --terminal-user: #0891b2; --terminal-tool: #7c3aed; @@ -61,6 +99,55 @@ --hljs-meta: #64748b; } +[data-theme="high-contrast"] { + --bg-primary: #000000; + --bg-secondary: #0a0a0a; + --bg-terminal: #000000; + --bg-hover: #1a1a1a; + --bg-code: #0a0a0a; + --accent-primary: #ff4d6d; + --accent-secondary: #ff85a1; + --text-primary: #ffffff; + --text-secondary: #e0e0e0; + --text-tertiary: #b0b0b0; + --border-color: #ffffff; + + /* Trans pride colors (high contrast) */ + --trans-blue: #00d4ff; + --trans-pink: #ff99cc; + --trans-white: #ffffff; + --trans-gradient: linear-gradient( + 135deg, + var(--trans-blue) 0%, + var(--trans-pink) 50%, + var(--trans-white) 100% + ); + --trans-gradient-vibrant: linear-gradient( + 135deg, + var(--trans-blue) 0%, + var(--trans-pink) 35%, + var(--trans-white) 50%, + var(--trans-pink) 65%, + var(--trans-blue) 100% + ); + + /* Terminal specific colors - bright and saturated */ + --terminal-user: #00ffff; + --terminal-tool: #ff00ff; + --terminal-tool-name: #ffaaff; + --terminal-error: #ff5555; + + /* Syntax highlighting colors (high contrast) */ + --hljs-keyword: #ff66ff; + --hljs-string: #66ff66; + --hljs-number: #ffff00; + --hljs-comment: #aaaaaa; + --hljs-function: #ff99ff; + --hljs-type: #00ffff; + --hljs-variable: #ffaa00; + --hljs-meta: #cccccc; +} + html, body { margin: 0; @@ -101,3 +188,52 @@ body { background: var(--accent-primary); color: var(--text-primary); } + +/* Trans gradient button - primary action buttons */ +.btn-trans-gradient { + background: var(--trans-gradient-vibrant) !important; + border: none !important; + color: #1a1a2e !important; + font-weight: 600; + text-shadow: 0 0 2px rgba(255, 255, 255, 0.5); + transition: all 0.2s ease; +} + +.btn-trans-gradient: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); +} + +.btn-trans-gradient:disabled { + opacity: 0.5; + cursor: not-allowed; + filter: grayscale(0.3); +} + +/* Trans gradient focus border for inputs */ +.input-trans-focus { + position: relative; + transition: all 0.2s ease; +} + +.input-trans-focus:focus { + border-color: var(--trans-pink) !important; + box-shadow: + 0 0 0 1px var(--trans-blue), + 0 0 12px rgba(91, 206, 250, 0.3), + 0 0 20px rgba(245, 169, 184, 0.2) !important; + outline: none !important; +} + +/* Trans gradient hover for icon buttons */ +.icon-trans-hover { + transition: all 0.2s ease; +} + +.icon-trans-hover:hover { + color: var(--trans-pink) !important; + filter: drop-shadow(0 0 6px rgba(91, 206, 250, 0.5)) + drop-shadow(0 0 10px rgba(245, 169, 184, 0.4)); +} diff --git a/src/lib/components/AnimeGirl.svelte b/src/lib/components/AnimeGirl.svelte index 58c0c50..4815a9b 100644 --- a/src/lib/components/AnimeGirl.svelte +++ b/src/lib/components/AnimeGirl.svelte @@ -34,35 +34,12 @@ return "animate-idle"; } } - - function getBackgroundGlow(): string { - switch (currentState) { - case "thinking": - return "shadow-thinking"; - case "typing": - return "shadow-typing"; - case "searching": - return "shadow-searching"; - case "coding": - return "shadow-coding"; - case "mcp": - return "shadow-mcp"; - case "success": - return "shadow-success"; - case "error": - return "shadow-error"; - default: - return ""; - } - }
-
+
diff --git a/src/lib/components/CompactMode.svelte b/src/lib/components/CompactMode.svelte new file mode 100644 index 0000000..5dbbbd1 --- /dev/null +++ b/src/lib/components/CompactMode.svelte @@ -0,0 +1,563 @@ + + +
+ +
+
+ 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)} +
+ {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 8981690..c664009 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, @@ -23,11 +25,30 @@ 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, + streamer_mode: false, + streamer_hide_paths: false, + compact_mode: false, + 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); @@ -84,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); @@ -186,47 +231,60 @@
- - + {#if config.streamer_mode} + + {:else} + + + {/if}
@@ -385,7 +443,7 @@ @@ -400,11 +458,12 @@
- -
+ Theme +
+ +
+ + {#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} +
+ +
+ +

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

+
+
+ +
+

+ 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} +
+

@@ -543,7 +828,7 @@ @@ -580,4 +865,39 @@ background: var(--text-tertiary); cursor: not-allowed; } + + /* Color picker styling */ + .color-input-group { + display: flex; + flex-direction: column; + gap: 0.25rem; + } + + .color-picker { + width: 32px; + height: 32px; + padding: 0; + border: 2px solid var(--border-color); + border-radius: 6px; + cursor: pointer; + background: transparent; + } + + .color-picker::-webkit-color-swatch-wrapper { + padding: 2px; + } + + .color-picker::-webkit-color-swatch { + border-radius: 4px; + border: none; + } + + .color-picker::-moz-color-swatch { + border-radius: 4px; + border: none; + } + + .color-picker:hover { + border-color: var(--accent-primary); + } 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/GitPanel.svelte b/src/lib/components/GitPanel.svelte new file mode 100644 index 0000000..058c439 --- /dev/null +++ b/src/lib/components/GitPanel.svelte @@ -0,0 +1,1107 @@ + + + + +{#if isOpen} + + + + {#if showDiff} + +
(showDiff = false)} role="presentation"> + +
+ {/if} +{/if} + + diff --git a/src/lib/components/InputBar.svelte b/src/lib/components/InputBar.svelte index 1fe7461..d798985 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, @@ -24,7 +25,11 @@ 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"; + import ClipboardHistoryPanel from "$lib/components/ClipboardHistoryPanel.svelte"; import type { Attachment } from "$lib/types/messages"; const INPUT_HISTORY_KEY = "hikari-input-history"; @@ -39,6 +44,14 @@ let selectedCommandIndex = $state(0); let attachments = $state([]); let isDragging = $state(false); + 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([]); @@ -500,6 +513,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") { @@ -617,6 +639,62 @@ User: ${formattedMessage}`; } } + function handleSnippetInsert(content: string): void { + // Insert snippet at cursor position or append to input + if (inputValue.trim()) { + inputValue = inputValue + "\n\n" + content; + } else { + inputValue = content; + } + 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; + + // Add to history + addToHistory(prompt); + historyIndex = -1; + tempInput = ""; + userHasTyped = false; + + isSubmitting = true; + + // Reset notification state for new user message + handleNewUserMessage(); + + claudeStore.addLine("user", prompt); + characterState.setState("thinking"); + + try { + const conversationId = get(claudeStore.activeConversationId); + if (!conversationId) { + throw new Error("No active conversation"); + } + await invoke("send_prompt", { + conversationId, + message: prompt, + }); + } catch (error) { + console.error("Failed to send quick action:", error); + claudeStore.addLine("error", `Failed to send: ${error}`); + characterState.setTemporaryState("error", 3000); + } finally { + isSubmitting = false; + } + } + function handleKeyDown(event: KeyboardEvent) { // Handle command menu navigation if (showCommandMenu && matchingCommands.length > 0) { @@ -693,6 +771,99 @@ User: ${formattedMessage}`;
+ + + +
@@ -717,8 +888,7 @@ User: ${formattedMessage}`; style="height: {textareaHeight}px; font-size: var(--terminal-font-size, 14px);" class="w-full px-4 py-3 bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-lg text-[var(--text-primary)] placeholder-gray-500 resize-none - focus:outline-none focus:border-[var(--accent-primary)] focus:ring-1 focus:ring-[var(--accent-primary)] - disabled:opacity-50 disabled:cursor-not-allowed" + input-trans-focus disabled:opacity-50 disabled:cursor-not-allowed" >
@@ -744,7 +914,7 @@ User: ${formattedMessage}`;
+{#if showSnippetLibrary} + (showSnippetLibrary = false)} + onInsert={handleSnippetInsert} + /> +{/if} + +{#if showQuickActions} + (showQuickActions = false)} onAction={handleQuickAction} /> +{/if} + +{#if showClipboardHistory} + (showClipboardHistory = false)} + onInsert={handleClipboardInsert} + /> +{/if} + 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/Markdown.svelte b/src/lib/components/Markdown.svelte index de0f48b..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; @@ -17,7 +18,20 @@ renderer.code = ({ text, lang }) => { const language = lang && hljs.getLanguage(lang) ? lang : "plaintext"; const highlighted = hljs.highlight(text, { language }).value; - return `
${highlighted}
`; + const escapedText = text.replace(/"/g, """).replace(//g, ">"); + return `
+
+ ${language} + +
+
${highlighted}
+
`; }; renderer.codespan = ({ text }) => { @@ -123,6 +137,34 @@ } } + 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); + + // 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!"; + setTimeout(() => { + textSpan.textContent = "Copy"; + }, 2000); + } + } + } + } + onMount(() => { if (containerElement) { containerElement.querySelectorAll("pre code:not(.hljs)").forEach((block) => { @@ -138,6 +180,7 @@ onclick={(e) => { handleSpoilerClick(e); handleLinkClick(e); + handleCopyClick(e); }} onkeydown={handleSpoilerKeydown} role="presentation" @@ -163,13 +206,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) { diff --git a/src/lib/components/ProfilePanel.svelte b/src/lib/components/ProfilePanel.svelte new file mode 100644 index 0000000..671bd0e --- /dev/null +++ b/src/lib/components/ProfilePanel.svelte @@ -0,0 +1,929 @@ + + + + + diff --git a/src/lib/components/QuickActionsPanel.svelte b/src/lib/components/QuickActionsPanel.svelte new file mode 100644 index 0000000..644d788 --- /dev/null +++ b/src/lib/components/QuickActionsPanel.svelte @@ -0,0 +1,456 @@ + + +
e.key === "Escape" && onClose()} +> +
e.stopPropagation()} + onkeydown={(e) => e.stopPropagation()} + role="dialog" + aria-labelledby="quick-actions-title" + tabindex="-1" + > +
+
+ {#if editingAction || isCreating} + + {/if} +

+ {#if isCreating} + Create Quick Action + {:else if editingAction} + Edit Quick Action + {:else} + Quick Actions + {/if} +

+
+
+ {#if !editingAction && !isCreating} + + {/if} + +
+
+ + {#if editingAction || isCreating} +
+
+
+ + +
+ +
+ +
+ {#each availableIcons as icon (icon.id)} + + {/each} +
+
+ +
+ + +
+ +
+ + +
+
+
+ {:else} +
+ {#if $isLoading} +
+
Loading quick actions...
+
+ {:else if $actions.length === 0} +
+ + + +

No quick actions available

+ +
+ {:else} +
+ {#each $actions as action (action.id)} +
+ +
+ + {#if !action.is_default} + {#if showDeleteConfirm === action.id} +
+ + +
+ {:else} + + {/if} + {/if} +
+
+ {/each} +
+ {/if} +
+ {/if} +
+
+ + diff --git a/src/lib/components/SessionHistoryPanel.svelte b/src/lib/components/SessionHistoryPanel.svelte new file mode 100644 index 0000000..61a69f6 --- /dev/null +++ b/src/lib/components/SessionHistoryPanel.svelte @@ -0,0 +1,476 @@ + + +
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} + + {/if} + +
+
+ + {#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 showExportMenu === session.id} +
+ + + + +
+ {/if} +
+ {#if showDeleteConfirm === session.id} +
+ + +
+ {:else} + + {/if} +
+
+
+ {/each} +
+ {/if} +
+ {/if} +
+
+ + diff --git a/src/lib/components/SnippetLibraryPanel.svelte b/src/lib/components/SnippetLibraryPanel.svelte new file mode 100644 index 0000000..1edcd72 --- /dev/null +++ b/src/lib/components/SnippetLibraryPanel.svelte @@ -0,0 +1,467 @@ + + +
e.key === "Escape" && onClose()} +> +
e.stopPropagation()} + onkeydown={(e) => e.stopPropagation()} + role="dialog" + aria-labelledby="snippet-library-title" + tabindex="-1" + > +
+
+ {#if editingSnippet || isCreating} + + {/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/components/StatsDisplay.svelte b/src/lib/components/StatsDisplay.svelte index 705433d..b9a3b4c 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}
@@ -128,14 +119,6 @@ opacity: 0.8; } - .stat-highlight { - font-weight: 600; - color: var(--accent-primary); - margin-top: 0.25rem; - padding-top: 0.25rem; - border-top: 1px solid var(--border-color); - } - .stat-label { color: var(--text-secondary, #9ca3af); } diff --git a/src/lib/components/StatusBar.svelte b/src/lib/components/StatusBar.svelte index 8163554..8905293 100644 --- a/src/lib/components/StatusBar.svelte +++ b/src/lib/components/StatusBar.svelte @@ -1,9 +1,10 @@
[{line.toolName}] {/if} {#if line.type === "assistant"} - + {:else} - + {/if}
{/each} diff --git a/src/lib/components/UpdateNotification.svelte b/src/lib/components/UpdateNotification.svelte index ca2bd2b..4b75c19 100644 --- a/src/lib/components/UpdateNotification.svelte +++ b/src/lib/components/UpdateNotification.svelte @@ -53,10 +53,7 @@ Current version: {updateInfo.current_version}

-