From fa906684c203406500ead3c69233fc8793c33900 Mon Sep 17 00:00:00 2001 From: Hikari Date: Tue, 3 Mar 2026 20:21:58 -0800 Subject: [PATCH] feat: multiple UI improvements, font settings, and memory file display names (#175) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - **fix**: `show_thinking_blocks` setting now persists across sessions — it was defined on the TypeScript side but missing from the Rust `HikariConfig` struct, so serde silently dropped it on every save/load - **feat**: Tool calls are now rendered as collapsible blocks matching the Extended Thinking block aesthetic, replacing the old inline dropdown approach - **feat**: Add configurable max output tokens setting - **feat**: Use random creative names for conversation tabs - **test**: Significantly expanded frontend unit test coverage - **docs**: Require tests for all changes in CLAUDE.md - **feat**: Allow users to specify a custom terminal font (Closes #176) - **feat**: Display friendly names for memory files derived from the first heading (Closes #177) - **feat**: Add custom UI font support for the app chrome (buttons, labels, tabs) - **fix**: Apply custom UI font to the full app interface — `.app-container` was hardcoded, blocking inheritance from `body`; also renamed "Custom Font" to "Custom Terminal Font" for clarity ✨ This PR was created with help from Hikari~ 🌸 Reviewed-on: https://git.nhcarrigan.com/nhcarrigan/hikari-desktop/pulls/175 Co-authored-by: Hikari Co-committed-by: Hikari --- CLAUDE.md | 19 +- src-tauri/src/commands.rs | 138 +++- src-tauri/src/config.rs | 40 ++ src-tauri/src/wsl_bridge.rs | 10 + src/app.css | 6 +- src/lib/commands/slashCommands.test.ts | 450 +++++++++++- src/lib/commands/slashCommands.ts | 2 + .../AchievementNotification.test.ts | 153 ++++ src/lib/components/CliVersion.test.ts | 134 ++++ src/lib/components/ConfigSidebar.svelte | 160 +++++ src/lib/components/ConversationTabs.test.ts | 111 +++ src/lib/components/HighlightedText.test.ts | 195 +++++ src/lib/components/InputBar.svelte | 2 +- src/lib/components/MemoryBrowserPanel.svelte | 42 +- src/lib/components/MemoryBrowserPanel.test.ts | 98 +++ src/lib/components/StatusBar.svelte | 7 + src/lib/components/StatusBar.test.ts | 89 +++ src/lib/components/Terminal.svelte | 74 +- src/lib/components/ToolCallBlock.svelte | 141 ++++ .../notifications/notificationManager.test.ts | 242 +++++++ src/lib/notifications/notifications.test.ts | 47 ++ src/lib/notifications/rules.test.ts | 150 ++++ .../notifications/terminalNotifier.test.ts | 64 ++ .../notifications/testNotifications.test.ts | 58 ++ .../wslNotificationHelper.test.ts | 40 ++ src/lib/sounds/achievement.test.ts | 48 ++ src/lib/stores/achievements.test.ts | 185 +++++ src/lib/stores/character.test.ts | 124 ++++ src/lib/stores/claude.test.ts | 139 ++++ src/lib/stores/clipboard.test.ts | 675 ++++++++++++++++++ src/lib/stores/config.test.ts | 633 +++++++++++++++- src/lib/stores/config.ts | 117 +++ src/lib/stores/conversations.ts | 86 ++- src/lib/stores/costTracking.test.ts | 292 ++++++++ src/lib/stores/debugConsole.test.ts | 282 ++++++++ src/lib/stores/drafts.test.ts | 10 + src/lib/stores/editor.test.ts | 639 +++++++++++++++++ src/lib/stores/historyRestore.test.ts | 72 ++ src/lib/stores/messageMode.test.ts | 56 ++ src/lib/stores/notifications.test.ts | 104 +++ src/lib/stores/search.test.ts | 187 +++++ src/lib/stores/sessions.test.ts | 569 +++++++++++++++ src/lib/stores/stats.test.ts | 295 +++++++- src/lib/stores/todos.test.ts | 93 +++ src/lib/types/messageMode.test.ts | 125 ++++ src/lib/utils/stateMapper.test.ts | 24 + src/routes/+page.svelte | 17 +- vitest.setup.ts | 5 +- 48 files changed, 7148 insertions(+), 101 deletions(-) create mode 100644 src/lib/components/AchievementNotification.test.ts create mode 100644 src/lib/components/CliVersion.test.ts create mode 100644 src/lib/components/ConversationTabs.test.ts create mode 100644 src/lib/components/HighlightedText.test.ts create mode 100644 src/lib/components/MemoryBrowserPanel.test.ts create mode 100644 src/lib/components/StatusBar.test.ts create mode 100644 src/lib/components/ToolCallBlock.svelte create mode 100644 src/lib/notifications/notificationManager.test.ts create mode 100644 src/lib/notifications/rules.test.ts create mode 100644 src/lib/notifications/terminalNotifier.test.ts create mode 100644 src/lib/notifications/testNotifications.test.ts create mode 100644 src/lib/notifications/wslNotificationHelper.test.ts create mode 100644 src/lib/sounds/achievement.test.ts create mode 100644 src/lib/stores/achievements.test.ts create mode 100644 src/lib/stores/character.test.ts create mode 100644 src/lib/stores/claude.test.ts create mode 100644 src/lib/stores/clipboard.test.ts create mode 100644 src/lib/stores/costTracking.test.ts create mode 100644 src/lib/stores/debugConsole.test.ts create mode 100644 src/lib/stores/editor.test.ts create mode 100644 src/lib/stores/historyRestore.test.ts create mode 100644 src/lib/stores/messageMode.test.ts create mode 100644 src/lib/stores/notifications.test.ts create mode 100644 src/lib/stores/search.test.ts create mode 100644 src/lib/stores/sessions.test.ts create mode 100644 src/lib/stores/todos.test.ts create mode 100644 src/lib/types/messageMode.test.ts diff --git a/CLAUDE.md b/CLAUDE.md index c6459de..c56c41b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -20,19 +20,26 @@ When working with issues, pull requests, or other repository operations for this When asked to commit changes for this project: - **Always commit as Hikari** using: `--author="Hikari "` -- **Always use `--no-gpg-sign`** since Hikari doesn't have GPG signing set up +- **Always sign commits** with Hikari's GPG key: `--gpg-sign=5380E4EE7307C808` - **Never add `Co-Authored-By` lines** for Gitea commits - **Always ask for confirmation** before committing +- **Always ask for confirmation** before pushing Example commit command: ```bash -git commit --author="Hikari " --no-gpg-sign -m "your commit message" +git commit --author="Hikari " --gpg-sign=5380E4EE7307C808 -m "your commit message" +``` + +Example push command: + +```bash +git push https://hikari:TOKEN@git.nhcarrigan.com/nhcarrigan/hikari-desktop.git ``` ## Testing Requirements -All new features, fixes, and significant changes should include tests whenever possible: +**All changes MUST include tests.** This is non-negotiable — no feature, bug fix, or refactor should be committed without corresponding test coverage. If a change cannot be tested (e.g. pure UI layout, Tauri IPC calls that are impossible to mock), document why in a comment. - **Frontend tests**: Use Vitest with `@testing-library/svelte` for component tests - **Test files**: Place test files next to the code they test with `.test.ts` or `.spec.ts` extension @@ -130,16 +137,16 @@ describe("FeatureName", () => { }); ``` -### Adding Tests for New Features +### Adding Tests for All Changes -When developing new features, always add corresponding tests: +Every change — features, bug fixes, refactors — must include tests: 1. **Before implementing**: Consider what needs testing (happy path, edge cases, errors) 2. **During implementation**: Write tests alongside the code 3. **After implementation**: Run `pnpm test:coverage` to verify coverage remains high 4. **Before committing**: Ensure `check-all.sh` passes (includes all tests) -The goal is to maintain our near-100% coverage as the codebase grows, so future refactoring and changes can be made with confidence! +**Do not commit changes without tests.** The goal is to maintain near-100% coverage as the codebase grows, so future refactoring can be done with confidence! ## Quality Assurance diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index efc58c3..acddc96 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -862,6 +862,26 @@ pub async fn read_file_content(path: String) -> Result { .map_err(|e| format!("Failed to read file: {}", e)) } +/// Read the first `# Heading` from a WSL file path (for Windows). +/// Returns `None` if the file cannot be read or has no top-level heading. +#[cfg(target_os = "windows")] +fn read_wsl_file_first_heading(path: &str) -> Option { + use std::process::Command; + + let output = Command::new("wsl") + .hide_window() + .args(["-e", "bash", "-c", &format!("head -20 '{}'", path)]) + .output() + .ok()?; + + if !output.status.success() { + return None; + } + + let content = String::from_utf8_lossy(&output.stdout); + extract_first_heading(&content) +} + /// Read file content via WSL (for Windows with WSL paths) #[allow(dead_code)] async fn read_file_via_wsl(path: &str) -> Result { @@ -1353,9 +1373,29 @@ pub async fn close_application(app_handle: AppHandle) -> Result<(), String> { Ok(()) } +#[derive(serde::Serialize)] +pub struct MemoryFileInfo { + pub path: String, + pub heading: Option, +} + #[derive(serde::Serialize)] pub struct MemoryFilesResponse { - pub files: Vec, + pub files: Vec, +} + +/// Extract the first `# Heading` from a string of file content. +fn extract_first_heading(content: &str) -> Option { + content.lines().find_map(|line| { + let trimmed = line.trim(); + if let Some(rest) = trimmed.strip_prefix("# ") { + let heading = rest.trim().to_string(); + if !heading.is_empty() { + return Some(heading); + } + } + None + }) } #[tauri::command] @@ -1398,12 +1438,19 @@ async fn list_memory_files_via_wsl() -> Result { } let stdout = String::from_utf8_lossy(&output.stdout); - let files: Vec = stdout + let paths: Vec = stdout .lines() .filter(|line| !line.trim().is_empty()) .map(|line| line.trim().to_string()) .collect(); + // Read first heading from each file via WSL + let mut files = Vec::new(); + for path in paths { + let heading = read_wsl_file_first_heading(&path); + files.push(MemoryFileInfo { path, heading }); + } + Ok(MemoryFilesResponse { files }) } @@ -1425,10 +1472,13 @@ async fn list_memory_files_native() -> Result { return Ok(MemoryFilesResponse { files: Vec::new() }); } - let mut memory_files = Vec::new(); + let mut memory_paths = Vec::new(); // Recursively find all memory directories - fn find_memory_files(dir: &std::path::Path, files: &mut Vec) -> std::io::Result<()> { + fn find_memory_files( + dir: &std::path::Path, + files: &mut Vec, + ) -> std::io::Result<()> { if !dir.is_dir() { return Ok(()); } @@ -1461,16 +1511,25 @@ async fn list_memory_files_native() -> Result { Ok(()) } - if let Err(e) = find_memory_files(&projects_dir, &mut memory_files) { + if let Err(e) = find_memory_files(&projects_dir, &mut memory_paths) { return Err(format!("Failed to list memory files: {}", e)); } // Sort files alphabetically - memory_files.sort(); + memory_paths.sort(); - Ok(MemoryFilesResponse { - files: memory_files, - }) + // Read first heading from each file + let files = memory_paths + .into_iter() + .map(|path| { + let heading = fs::read_to_string(&path) + .ok() + .and_then(|content| extract_first_heading(&content)); + MemoryFileInfo { path, heading } + }) + .collect(); + + Ok(MemoryFilesResponse { files }) } #[tauri::command] @@ -2902,4 +2961,65 @@ gitea: gitea-mcp -t stdio (STDIO) - ✓ Connected"#; assert_eq!(servers[0].name, "asana"); assert_eq!(servers[1].name, "gitea"); } + + // ==================== extract_first_heading tests ==================== + + #[test] + fn test_extract_first_heading_returns_heading() { + let content = "# My Memory File\n\nSome content here."; + assert_eq!( + extract_first_heading(content), + Some("My Memory File".to_string()) + ); + } + + #[test] + fn test_extract_first_heading_ignores_non_h1() { + let content = "## Section Header\n### Sub-section\nSome content."; + assert_eq!(extract_first_heading(content), None); + } + + #[test] + fn test_extract_first_heading_finds_first_h1_after_other_lines() { + let content = "Some intro text\n\n# The Real Title\n\nMore content."; + assert_eq!( + extract_first_heading(content), + Some("The Real Title".to_string()) + ); + } + + #[test] + fn test_extract_first_heading_trims_whitespace() { + let content = "# Trimmed Heading \n\nContent."; + assert_eq!( + extract_first_heading(content), + Some("Trimmed Heading".to_string()) + ); + } + + #[test] + fn test_extract_first_heading_returns_none_for_empty_content() { + assert_eq!(extract_first_heading(""), None); + } + + #[test] + fn test_extract_first_heading_returns_none_for_empty_heading() { + let content = "# \n\nContent after empty heading."; + assert_eq!(extract_first_heading(content), None); + } + + #[test] + fn test_extract_first_heading_returns_none_when_no_headings() { + let content = "Just some plain text.\nNo headings here at all."; + assert_eq!(extract_first_heading(content), None); + } + + #[test] + fn test_extract_first_heading_handles_leading_whitespace_on_line() { + let content = " # Indented Heading\n\nContent."; + assert_eq!( + extract_first_heading(content), + Some("Indented Heading".to_string()) + ); + } } diff --git a/src-tauri/src/config.rs b/src-tauri/src/config.rs index b9af21a..cfb4f7f 100644 --- a/src-tauri/src/config.rs +++ b/src-tauri/src/config.rs @@ -31,6 +31,9 @@ pub struct ClaudeStartOptions { #[serde(default)] pub disable_1m_context: bool, + + #[serde(default)] + pub max_output_tokens: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -126,6 +129,9 @@ pub struct HikariConfig { #[serde(default)] pub disable_1m_context: bool, + #[serde(default)] + pub max_output_tokens: Option, + #[serde(default)] pub trusted_workspaces: Vec, @@ -135,6 +141,23 @@ pub struct HikariConfig { #[serde(default = "default_background_image_opacity")] pub background_image_opacity: f32, + + #[serde(default)] + pub show_thinking_blocks: bool, + + // Custom terminal font settings + #[serde(default)] + pub custom_font_path: Option, + + #[serde(default)] + pub custom_font_family: Option, + + // Custom UI font settings + #[serde(default)] + pub custom_ui_font_path: Option, + + #[serde(default)] + pub custom_ui_font_family: Option, } impl Default for HikariConfig { @@ -169,9 +192,15 @@ impl Default for HikariConfig { discord_rpc_enabled: true, use_worktree: false, disable_1m_context: false, + max_output_tokens: None, trusted_workspaces: Vec::new(), background_image_path: None, background_image_opacity: 0.3, + show_thinking_blocks: false, + custom_font_path: None, + custom_font_family: None, + custom_ui_font_path: None, + custom_ui_font_family: None, } } } @@ -286,6 +315,11 @@ mod tests { assert!(!config.use_worktree); assert!(!config.disable_1m_context); assert!(config.trusted_workspaces.is_empty()); + assert!(!config.show_thinking_blocks); + assert!(config.custom_font_path.is_none()); + assert!(config.custom_font_family.is_none()); + assert!(config.custom_ui_font_path.is_none()); + assert!(config.custom_ui_font_family.is_none()); } #[test] @@ -320,9 +354,15 @@ mod tests { discord_rpc_enabled: true, use_worktree: true, disable_1m_context: false, + max_output_tokens: Some(32000), trusted_workspaces: vec!["/home/naomi/projects/trusted".to_string()], background_image_path: Some("/home/naomi/bg.png".to_string()), background_image_opacity: 0.25, + show_thinking_blocks: true, + custom_font_path: Some("/home/naomi/.fonts/MyFont.ttf".to_string()), + custom_font_family: Some("MyFont".to_string()), + custom_ui_font_path: None, + custom_ui_font_family: None, }; let json = serde_json::to_string(&config).unwrap(); diff --git a/src-tauri/src/wsl_bridge.rs b/src-tauri/src/wsl_bridge.rs index 5df9852..c2da718 100644 --- a/src-tauri/src/wsl_bridge.rs +++ b/src-tauri/src/wsl_bridge.rs @@ -296,6 +296,11 @@ impl WslBridge { cmd.env("CLAUDE_CODE_DISABLE_1M_CONTEXT", "1"); } + // Set max output tokens if specified + if let Some(max_tokens) = options.max_output_tokens { + cmd.env("CLAUDE_CODE_MAX_OUTPUT_TOKENS", max_tokens.to_string()); + } + cmd } else { // Running on Windows - use wsl with bash login shell to ensure PATH is loaded @@ -343,6 +348,11 @@ impl WslBridge { claude_cmd.push_str("CLAUDE_CODE_DISABLE_1M_CONTEXT=1 "); } + // Set max output tokens if specified + if let Some(max_tokens) = options.max_output_tokens { + claude_cmd.push_str(&format!("CLAUDE_CODE_MAX_OUTPUT_TOKENS={} ", max_tokens)); + } + claude_cmd.push_str( "claude --output-format stream-json --input-format stream-json --verbose", ); diff --git a/src/app.css b/src/app.css index 13b8324..88bf299 100644 --- a/src/app.css +++ b/src/app.css @@ -154,11 +154,7 @@ body { padding: 0; height: 100%; overflow: hidden; - font-family: - "Segoe UI", - system-ui, - -apple-system, - sans-serif; + font-family: var(--ui-font-family, "Segoe UI", system-ui, -apple-system, sans-serif); background: var(--bg-primary); color: var(--text-primary); } diff --git a/src/lib/commands/slashCommands.test.ts b/src/lib/commands/slashCommands.test.ts index 74c7de5..af08a7b 100644 --- a/src/lib/commands/slashCommands.test.ts +++ b/src/lib/commands/slashCommands.test.ts @@ -1,4 +1,9 @@ -import { describe, it, expect, vi } from "vitest"; +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { get } from "svelte/store"; +import { invoke } from "@tauri-apps/api/core"; +import { claudeStore } from "$lib/stores/claude"; +import { searchState } from "$lib/stores/search"; +import { characterState } from "$lib/stores/character"; import { slashCommands, parseSlashCommand, @@ -40,6 +45,28 @@ vi.mock("$lib/stores/character", () => ({ vi.mock("$lib/tauri", () => ({ setSkipNextGreeting: vi.fn(), + updateDiscordRpc: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock("$lib/stores/conversations", () => ({ + conversationsStore: { + activeConversation: { subscribe: vi.fn() }, + }, +})); + +vi.mock("$lib/stores/config", () => ({ + configStore: { + getConfig: vi.fn().mockReturnValue({ + auto_granted_tools: [], + model: "claude-sonnet", + api_key: null, + custom_instructions: null, + mcp_servers_json: null, + use_worktree: false, + disable_1m_context: false, + max_output_tokens: null, + }), + }, })); vi.mock("$lib/stores/search", () => ({ @@ -415,4 +442,425 @@ describe("slashCommands", () => { expect(result).toBeUndefined(); }); }); + + describe("command execute functions", () => { + let getMock: ReturnType; + let invokeMock: ReturnType; + + beforeEach(() => { + vi.clearAllMocks(); + getMock = vi.mocked(get); + invokeMock = vi.mocked(invoke); + }); + + describe("/clear execute", () => { + it("clears terminal and shows confirmation message", () => { + const clearCmd = slashCommands.find((cmd) => cmd.name === "clear")!; + clearCmd.execute(""); + expect(claudeStore.clearTerminal).toHaveBeenCalledWith(); + expect(claudeStore.addLine).toHaveBeenCalledWith("system", "Terminal cleared"); + }); + }); + + describe("/help execute", () => { + it("shows available commands header", () => { + const helpCmd = slashCommands.find((cmd) => cmd.name === "help")!; + helpCmd.execute(""); + expect(claudeStore.addLine).toHaveBeenCalledWith( + "system", + expect.stringContaining("Available commands:") + ); + }); + + it("includes all command usages in help text", () => { + const helpCmd = slashCommands.find((cmd) => cmd.name === "help")!; + helpCmd.execute(""); + const callArgs = vi.mocked(claudeStore.addLine).mock.calls[0]; + const helpText = callArgs[1] as string; + expect(helpText).toContain("/cd"); + expect(helpText).toContain("/clear"); + expect(helpText).toContain("/help"); + expect(helpText).toContain("/search"); + expect(helpText).toContain("/new"); + expect(helpText).toContain("/summarise"); + expect(helpText).toContain("/skill"); + }); + + it("includes command descriptions in help text", () => { + const helpCmd = slashCommands.find((cmd) => cmd.name === "help")!; + helpCmd.execute(""); + const callArgs = vi.mocked(claudeStore.addLine).mock.calls[0]; + const helpText = callArgs[1] as string; + expect(helpText).toContain("Change the working directory"); + expect(helpText).toContain("Show available slash commands"); + }); + }); + + describe("/search execute", () => { + it("clears search when called with empty args", () => { + const searchCmd = slashCommands.find((cmd) => cmd.name === "search")!; + searchCmd.execute(""); + expect(searchState.clear).toHaveBeenCalledWith(); + expect(claudeStore.addLine).toHaveBeenCalledWith("system", "Search cleared"); + }); + + it("clears search when called with whitespace-only args", () => { + const searchCmd = slashCommands.find((cmd) => cmd.name === "search")!; + searchCmd.execute(" "); + expect(searchState.clear).toHaveBeenCalledWith(); + expect(claudeStore.addLine).toHaveBeenCalledWith("system", "Search cleared"); + }); + + it("sets query when called with a search term", () => { + const searchCmd = slashCommands.find((cmd) => cmd.name === "search")!; + searchCmd.execute("hello world"); + expect(searchState.setQuery).toHaveBeenCalledWith("hello world"); + expect(claudeStore.addLine).toHaveBeenCalledWith("system", 'Searching for: "hello world"'); + }); + + it("trims whitespace from query before setting", () => { + const searchCmd = slashCommands.find((cmd) => cmd.name === "search")!; + searchCmd.execute(" hello "); + expect(searchState.setQuery).toHaveBeenCalledWith("hello"); + expect(claudeStore.addLine).toHaveBeenCalledWith("system", 'Searching for: "hello"'); + }); + }); + + describe("/cd execute", () => { + it("shows error when no active conversation", async () => { + getMock.mockReturnValue(null); + const cdCmd = slashCommands.find((cmd) => cmd.name === "cd")!; + await cdCmd.execute("/some/path"); + expect(claudeStore.addLine).toHaveBeenCalledWith("error", "No active conversation"); + }); + + it("shows current directory when called with empty args", async () => { + getMock.mockReturnValueOnce("conv-123").mockReturnValueOnce("/home/naomi/code"); + const cdCmd = slashCommands.find((cmd) => cmd.name === "cd")!; + await cdCmd.execute(""); + expect(claudeStore.addLine).toHaveBeenCalledWith( + "system", + "Current directory: /home/naomi/code" + ); + }); + + it("shows current directory when called with whitespace-only args", async () => { + getMock.mockReturnValueOnce("conv-123").mockReturnValueOnce("/home/naomi/code"); + const cdCmd = slashCommands.find((cmd) => cmd.name === "cd")!; + await cdCmd.execute(" "); + expect(claudeStore.addLine).toHaveBeenCalledWith( + "system", + "Current directory: /home/naomi/code" + ); + }); + + it("validates path and changes directory on success", async () => { + getMock.mockReturnValueOnce("conv-123").mockReturnValueOnce(null); + invokeMock.mockResolvedValue("/validated/path"); + const cdCmd = slashCommands.find((cmd) => cmd.name === "cd")!; + await cdCmd.execute("/new/path"); + expect(invokeMock).toHaveBeenCalledWith( + "validate_directory", + expect.objectContaining({ path: "/new/path" }) + ); + }); + + it("shows error when directory change fails", async () => { + getMock.mockReturnValueOnce("conv-123"); + invokeMock.mockRejectedValueOnce(new Error("invalid path")); + const cdCmd = slashCommands.find((cmd) => cmd.name === "cd")!; + await cdCmd.execute("/bad/path"); + expect(claudeStore.addLine).toHaveBeenCalledWith( + "error", + expect.stringContaining("Failed to change directory:") + ); + expect(characterState.setTemporaryState).toHaveBeenCalledWith("error", 3000); + }); + }); + + describe("/new execute", () => { + it("shows error when no active conversation", async () => { + getMock.mockReturnValue(null); + const newCmd = slashCommands.find((cmd) => cmd.name === "new")!; + await newCmd.execute(""); + expect(claudeStore.addLine).toHaveBeenCalledWith("error", "No active conversation"); + }); + + it("shows error when starting new conversation fails", async () => { + getMock.mockReturnValueOnce("conv-123"); + invokeMock.mockRejectedValueOnce(new Error("invoke failed")); + const newCmd = slashCommands.find((cmd) => cmd.name === "new")!; + await newCmd.execute(""); + expect(claudeStore.addLine).toHaveBeenCalledWith( + "error", + expect.stringContaining("Failed to start new conversation:") + ); + expect(characterState.setTemporaryState).toHaveBeenCalledWith("error", 3000); + }); + }); + + describe("/summarise execute", () => { + it("shows error when no active conversation", async () => { + getMock.mockReturnValue(null); + const summariseCmd = slashCommands.find((cmd) => cmd.name === "summarise")!; + await summariseCmd.execute(""); + expect(claudeStore.addLine).toHaveBeenCalledWith("error", "No active conversation"); + }); + + it("sends a summary prompt when there is an active conversation", async () => { + getMock.mockReturnValue("conv-123"); + invokeMock.mockResolvedValue(undefined); + const summariseCmd = slashCommands.find((cmd) => cmd.name === "summarise")!; + await summariseCmd.execute(""); + expect(claudeStore.addLine).toHaveBeenCalledWith( + "system", + "Requesting conversation summary..." + ); + expect(invokeMock).toHaveBeenCalledWith( + "send_prompt", + expect.objectContaining({ conversationId: "conv-123" }) + ); + }); + + it("shows error when send_prompt invoke fails", async () => { + getMock.mockReturnValue("conv-123"); + invokeMock.mockRejectedValue(new Error("network error")); + const summariseCmd = slashCommands.find((cmd) => cmd.name === "summarise")!; + await summariseCmd.execute(""); + expect(claudeStore.addLine).toHaveBeenCalledWith( + "error", + expect.stringContaining("Failed to request summary:") + ); + }); + }); + + describe("/skill execute", () => { + it("shows error when no active conversation", async () => { + getMock.mockReturnValue(null); + const skillCmd = slashCommands.find((cmd) => cmd.name === "skill")!; + await skillCmd.execute("onboard-mentee"); + expect(claudeStore.addLine).toHaveBeenCalledWith("error", "No active conversation"); + }); + + it("lists available skills when called with no name", async () => { + getMock.mockReturnValue("conv-123"); + invokeMock.mockResolvedValue(["onboard-mentee", "other-skill"]); + const skillCmd = slashCommands.find((cmd) => cmd.name === "skill")!; + await skillCmd.execute(""); + expect(invokeMock).toHaveBeenCalledWith("list_skills"); + expect(claudeStore.addLine).toHaveBeenCalledWith( + "system", + expect.stringContaining("onboard-mentee") + ); + }); + + it("shows empty message when no skills are found", async () => { + getMock.mockReturnValue("conv-123"); + invokeMock.mockResolvedValue([]); + const skillCmd = slashCommands.find((cmd) => cmd.name === "skill")!; + await skillCmd.execute(""); + expect(claudeStore.addLine).toHaveBeenCalledWith( + "system", + expect.stringContaining("No skills found") + ); + }); + + it("invokes skill when called with a name and no data", async () => { + getMock.mockReturnValue("conv-123"); + invokeMock.mockResolvedValue(undefined); + const skillCmd = slashCommands.find((cmd) => cmd.name === "skill")!; + await skillCmd.execute("onboard-mentee"); + expect(claudeStore.addLine).toHaveBeenCalledWith( + "system", + "Invoking skill: onboard-mentee" + ); + expect(invokeMock).toHaveBeenCalledWith( + "send_prompt", + expect.objectContaining({ conversationId: "conv-123" }) + ); + }); + + it("invokes skill with additional data in the prompt", async () => { + getMock.mockReturnValue("conv-123"); + invokeMock.mockResolvedValue(undefined); + const skillCmd = slashCommands.find((cmd) => cmd.name === "skill")!; + await skillCmd.execute("onboard-mentee some extra data"); + expect(invokeMock).toHaveBeenCalledWith("send_prompt", { + conversationId: "conv-123", + message: expect.stringContaining("some extra data"), + }); + }); + + it("shows error when listing skills fails", async () => { + getMock.mockReturnValue("conv-123"); + invokeMock.mockRejectedValue(new Error("list error")); + const skillCmd = slashCommands.find((cmd) => cmd.name === "skill")!; + await skillCmd.execute(""); + expect(claudeStore.addLine).toHaveBeenCalledWith( + "error", + expect.stringContaining("Failed to list skills:") + ); + }); + + it("shows error and resets character state when invoking skill fails", async () => { + getMock.mockReturnValue("conv-123"); + invokeMock.mockRejectedValue(new Error("invoke error")); + const skillCmd = slashCommands.find((cmd) => cmd.name === "skill")!; + await skillCmd.execute("onboard-mentee"); + expect(claudeStore.addLine).toHaveBeenCalledWith( + "error", + expect.stringContaining("Failed to invoke skill:") + ); + expect(characterState.setTemporaryState).toHaveBeenCalledWith("error", 3000); + }); + }); + + describe("/cd success path", () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("changes directory and shows success message", async () => { + getMock + .mockReturnValueOnce("conv-123") // get(claudeStore.activeConversationId) + .mockReturnValueOnce("/current") // get(claudeStore.currentWorkingDirectory) + .mockReturnValueOnce(null); // get(conversationsStore.activeConversation) + vi.mocked(claudeStore.getConversationHistory).mockReturnValue(""); + invokeMock + .mockResolvedValueOnce("/new/path") // validate_directory + .mockResolvedValueOnce(undefined) // stop_claude + .mockResolvedValueOnce(undefined); // start_claude + + const cdCmd = slashCommands.find((cmd) => cmd.name === "cd")!; + const promise = cdCmd.execute("/new/path"); + await vi.runAllTimersAsync(); + await promise; + + expect(claudeStore.addLine).toHaveBeenCalledWith( + "system", + "Changed directory to: /new/path" + ); + expect(characterState.setState).toHaveBeenCalledWith("idle"); + }); + + it("sends context restoration message when conversation history exists", async () => { + getMock + .mockReturnValueOnce("conv-123") // get(claudeStore.activeConversationId) + .mockReturnValueOnce("/current") // get(claudeStore.currentWorkingDirectory) + .mockReturnValueOnce(null); // get(conversationsStore.activeConversation) + vi.mocked(claudeStore.getConversationHistory).mockReturnValue( + "previous conversation history" + ); + invokeMock + .mockResolvedValueOnce("/new/path") // validate_directory + .mockResolvedValueOnce(undefined) // stop_claude + .mockResolvedValueOnce(undefined) // start_claude + .mockResolvedValueOnce(undefined); // send_prompt + + const cdCmd = slashCommands.find((cmd) => cmd.name === "cd")!; + const promise = cdCmd.execute("/new/path"); + await vi.runAllTimersAsync(); + await promise; + + expect(invokeMock).toHaveBeenCalledWith( + "send_prompt", + expect.objectContaining({ + message: expect.stringContaining("previous conversation history"), + }) + ); + expect(claudeStore.addLine).toHaveBeenCalledWith( + "system", + "Changed directory to: /new/path" + ); + }); + + it("calls updateDiscordRpc when activeConversation is available", async () => { + const activeConv = { + name: "Test Conversation", + model: "claude-sonnet", + startedAt: new Date("2026-03-03T12:00:00Z"), + grantedTools: new Set(), + }; + getMock + .mockReturnValueOnce("conv-123") // get(claudeStore.activeConversationId) + .mockReturnValueOnce("/current") // get(claudeStore.currentWorkingDirectory) + .mockReturnValueOnce(activeConv); // get(conversationsStore.activeConversation) + vi.mocked(claudeStore.getConversationHistory).mockReturnValue(""); + invokeMock + .mockResolvedValueOnce("/new/path") + .mockResolvedValueOnce(undefined) + .mockResolvedValueOnce(undefined); + const { updateDiscordRpc } = await import("$lib/tauri"); + + const cdCmd = slashCommands.find((cmd) => cmd.name === "cd")!; + const promise = cdCmd.execute("/new/path"); + await vi.runAllTimersAsync(); + await promise; + + expect(updateDiscordRpc).toHaveBeenCalledWith( + "Test Conversation", + expect.any(String), + expect.any(Date) + ); + }); + }); + + describe("/new success path", () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("starts a new conversation and shows success message", async () => { + getMock + .mockReturnValueOnce("conv-123") // get(claudeStore.activeConversationId) + .mockReturnValueOnce(null); // get(conversationsStore.activeConversation) + invokeMock + .mockResolvedValueOnce("/working/dir") // get_working_directory + .mockResolvedValueOnce(undefined) // interrupt_claude + .mockResolvedValueOnce(undefined); // start_claude + + const newCmd = slashCommands.find((cmd) => cmd.name === "new")!; + const promise = newCmd.execute(""); + await vi.runAllTimersAsync(); + await promise; + + expect(claudeStore.addLine).toHaveBeenCalledWith("system", "New conversation started!"); + expect(characterState.setState).toHaveBeenCalledWith("idle"); + }); + + it("calls updateDiscordRpc when activeConversation is available", async () => { + const activeConv = { + name: "My Conv", + model: "claude-sonnet", + startedAt: new Date("2026-03-03T12:00:00Z"), + grantedTools: new Set(["tool1"]), + }; + getMock.mockReturnValueOnce("conv-123").mockReturnValueOnce(activeConv); + invokeMock + .mockResolvedValueOnce("/working/dir") + .mockResolvedValueOnce(undefined) + .mockResolvedValueOnce(undefined); + const { updateDiscordRpc } = await import("$lib/tauri"); + + const newCmd = slashCommands.find((cmd) => cmd.name === "new")!; + const promise = newCmd.execute(""); + await vi.runAllTimersAsync(); + await promise; + + expect(updateDiscordRpc).toHaveBeenCalledWith( + "My Conv", + expect.any(String), + expect.any(Date) + ); + }); + }); + }); }); diff --git a/src/lib/commands/slashCommands.ts b/src/lib/commands/slashCommands.ts index 8d252c8..3e97bb2 100644 --- a/src/lib/commands/slashCommands.ts +++ b/src/lib/commands/slashCommands.ts @@ -63,6 +63,7 @@ async function changeDirectory(path: string): Promise { allowed_tools: allAllowedTools, use_worktree: config.use_worktree ?? false, disable_1m_context: config.disable_1m_context ?? false, + max_output_tokens: config.max_output_tokens ?? null, }, }); @@ -139,6 +140,7 @@ async function startNewConversation(): Promise { allowed_tools: allAllowedTools, use_worktree: config.use_worktree ?? false, disable_1m_context: config.disable_1m_context ?? false, + max_output_tokens: config.max_output_tokens ?? null, }, }); diff --git a/src/lib/components/AchievementNotification.test.ts b/src/lib/components/AchievementNotification.test.ts new file mode 100644 index 0000000..ed971f2 --- /dev/null +++ b/src/lib/components/AchievementNotification.test.ts @@ -0,0 +1,153 @@ +/** + * AchievementNotification Component Tests + * + * Tests the rarity classification and colour mapping logic used by the + * AchievementNotification component. + * + * What this component does: + * - Listens for "achievement:unlocked" Tauri events + * - Queues and displays achievement notifications one at a time + * - Each notification shows the achievement's name, icon, description, and rarity + * - A gradient border and badge colour correspond to the achievement's rarity + * + * Manual testing checklist: + * - [ ] Achievement notification slides in from the right + * - [ ] Notification auto-dismisses after 5 seconds + * - [ ] Dismiss button works immediately + * - [ ] Multiple achievements queue and display sequentially + * - [ ] Legendary achievements have a yellow-orange gradient + * - [ ] Epic achievements have a purple-pink gradient + * - [ ] Rare achievements have a blue-indigo gradient + * - [ ] Common achievements have a green-emerald gradient + */ + +import { describe, it, expect } from "vitest"; + +function getAchievementRarity(id: string): string { + if (id === "TokenMaster") return "legendary"; + if (["CodeMachine", "Unstoppable"].includes(id)) return "epic"; + if ( + [ + "BlossomingCoder", + "CodeWizard", + "MasterBuilder", + "EnduranceChamp", + "DeepDive", + "CreativeCoder", + ].includes(id) + ) + return "rare"; + return "common"; +} + +function getRarityColor(rarity: string): string { + switch (rarity) { + case "legendary": + return "from-yellow-400 to-orange-500"; + case "epic": + return "from-purple-400 to-pink-500"; + case "rare": + return "from-blue-400 to-indigo-500"; + default: + return "from-green-400 to-emerald-500"; + } +} + +// --- + +describe("getAchievementRarity", () => { + describe("legendary tier", () => { + it("classifies TokenMaster as legendary", () => { + expect(getAchievementRarity("TokenMaster")).toBe("legendary"); + }); + }); + + describe("epic tier", () => { + it("classifies CodeMachine as epic", () => { + expect(getAchievementRarity("CodeMachine")).toBe("epic"); + }); + + it("classifies Unstoppable as epic", () => { + expect(getAchievementRarity("Unstoppable")).toBe("epic"); + }); + }); + + describe("rare tier", () => { + it("classifies BlossomingCoder as rare", () => { + expect(getAchievementRarity("BlossomingCoder")).toBe("rare"); + }); + + it("classifies CodeWizard as rare", () => { + expect(getAchievementRarity("CodeWizard")).toBe("rare"); + }); + + it("classifies MasterBuilder as rare", () => { + expect(getAchievementRarity("MasterBuilder")).toBe("rare"); + }); + + it("classifies EnduranceChamp as rare", () => { + expect(getAchievementRarity("EnduranceChamp")).toBe("rare"); + }); + + it("classifies DeepDive as rare", () => { + expect(getAchievementRarity("DeepDive")).toBe("rare"); + }); + + it("classifies CreativeCoder as rare", () => { + expect(getAchievementRarity("CreativeCoder")).toBe("rare"); + }); + }); + + describe("common tier", () => { + it("classifies unknown IDs as common", () => { + expect(getAchievementRarity("FirstChat")).toBe("common"); + expect(getAchievementRarity("SomeNewAchievement")).toBe("common"); + expect(getAchievementRarity("")).toBe("common"); + }); + }); +}); + +describe("getRarityColor", () => { + it("returns yellow-to-orange gradient for legendary", () => { + expect(getRarityColor("legendary")).toBe("from-yellow-400 to-orange-500"); + }); + + it("returns purple-to-pink gradient for epic", () => { + expect(getRarityColor("epic")).toBe("from-purple-400 to-pink-500"); + }); + + it("returns blue-to-indigo gradient for rare", () => { + expect(getRarityColor("rare")).toBe("from-blue-400 to-indigo-500"); + }); + + it("returns green-to-emerald gradient for common", () => { + expect(getRarityColor("common")).toBe("from-green-400 to-emerald-500"); + }); + + it("falls back to green-to-emerald gradient for unknown rarities", () => { + expect(getRarityColor("mythic")).toBe("from-green-400 to-emerald-500"); + expect(getRarityColor("")).toBe("from-green-400 to-emerald-500"); + }); + + describe("end-to-end rarity pipeline", () => { + it("produces the correct colour for a legendary achievement", () => { + const color = getRarityColor(getAchievementRarity("TokenMaster")); + expect(color).toBe("from-yellow-400 to-orange-500"); + }); + + it("produces the correct colour for an epic achievement", () => { + const color = getRarityColor(getAchievementRarity("CodeMachine")); + expect(color).toBe("from-purple-400 to-pink-500"); + }); + + it("produces the correct colour for a rare achievement", () => { + const color = getRarityColor(getAchievementRarity("CodeWizard")); + expect(color).toBe("from-blue-400 to-indigo-500"); + }); + + it("produces the correct colour for a common achievement", () => { + const color = getRarityColor(getAchievementRarity("FirstChat")); + expect(color).toBe("from-green-400 to-emerald-500"); + }); + }); +}); diff --git a/src/lib/components/CliVersion.test.ts b/src/lib/components/CliVersion.test.ts new file mode 100644 index 0000000..a16516f --- /dev/null +++ b/src/lib/components/CliVersion.test.ts @@ -0,0 +1,134 @@ +/** + * CliVersion Component Tests + * + * Tests the version comparison logic used by the CliVersion component, + * which compares the installed CLI version against the supported version. + * + * What this component does: + * - Displays the installed Claude CLI version + * - Displays the highest audited/supported CLI version + * - Shows a warning when the installed version is ahead of or behind supported + * + * Manual testing checklist: + * - [ ] Installed version is fetched and displayed on mount + * - [ ] "current" badge shows in green when versions match + * - [ ] "ahead" badge shows in amber when installed is newer than supported + * - [ ] "behind" badge shows in red when installed is older than supported + * - [ ] Warning message appears for "ahead" and "behind" states + */ + +import { describe, it, expect } from "vitest"; + +const SUPPORTED_CLI_VERSION = "2.1.53"; + +function compareVersions(a: string, b: string): number { + const aParts = a.split(".").map(Number); + const bParts = b.split(".").map(Number); + for (let i = 0; i < Math.max(aParts.length, bParts.length); i++) { + const aVal = aParts[i] ?? 0; + const bVal = bParts[i] ?? 0; + if (aVal > bVal) return 1; + if (aVal < bVal) return -1; + } + return 0; +} + +// --- + +describe("SUPPORTED_CLI_VERSION", () => { + it("is defined and non-empty", () => { + expect(SUPPORTED_CLI_VERSION).toBeTruthy(); + }); + + it("matches the expected audited version", () => { + expect(SUPPORTED_CLI_VERSION).toBe("2.1.53"); + }); +}); + +describe("compareVersions", () => { + describe("equal versions", () => { + it("returns 0 for identical versions", () => { + expect(compareVersions("1.0.0", "1.0.0")).toBe(0); + }); + + it("returns 0 for the supported CLI version against itself", () => { + expect(compareVersions(SUPPORTED_CLI_VERSION, SUPPORTED_CLI_VERSION)).toBe(0); + }); + + it("returns 0 for 0.0.0 vs 0.0.0", () => { + expect(compareVersions("0.0.0", "0.0.0")).toBe(0); + }); + }); + + describe("major version differences", () => { + it("returns 1 when a has a higher major version", () => { + expect(compareVersions("2.0.0", "1.0.0")).toBe(1); + }); + + it("returns -1 when a has a lower major version", () => { + expect(compareVersions("1.0.0", "2.0.0")).toBe(-1); + }); + }); + + describe("minor version differences", () => { + it("returns 1 when a has a higher minor version", () => { + expect(compareVersions("1.2.0", "1.1.0")).toBe(1); + }); + + it("returns -1 when a has a lower minor version", () => { + expect(compareVersions("1.1.0", "1.2.0")).toBe(-1); + }); + }); + + describe("patch version differences", () => { + it("returns 1 when a has a higher patch version", () => { + expect(compareVersions("1.0.2", "1.0.1")).toBe(1); + }); + + it("returns -1 when a has a lower patch version", () => { + expect(compareVersions("1.0.1", "1.0.2")).toBe(-1); + }); + }); + + describe("major version takes precedence", () => { + it("returns 1 when a has a higher major but lower minor", () => { + expect(compareVersions("2.0.0", "1.9.9")).toBe(1); + }); + + it("returns -1 when a has a lower major but higher minor", () => { + expect(compareVersions("1.9.9", "2.0.0")).toBe(-1); + }); + }); + + describe("unequal segment counts", () => { + it("treats missing segments as 0 (a shorter than b)", () => { + expect(compareVersions("1.0", "1.0.0")).toBe(0); + }); + + it("treats missing segments as 0 (a longer than b)", () => { + expect(compareVersions("1.0.0", "1.0")).toBe(0); + }); + + it("correctly compares when a has an extra non-zero segment", () => { + expect(compareVersions("1.0.1", "1.0")).toBe(1); + }); + + it("correctly compares when b has an extra non-zero segment", () => { + expect(compareVersions("1.0", "1.0.1")).toBe(-1); + }); + }); + + describe("supported CLI version comparisons", () => { + it("returns 1 for a version ahead of supported", () => { + expect(compareVersions("2.2.0", SUPPORTED_CLI_VERSION)).toBe(1); + }); + + it("returns -1 for a version behind supported", () => { + expect(compareVersions("2.1.0", SUPPORTED_CLI_VERSION)).toBe(-1); + }); + + it("returns 0 for exactly the supported version", () => { + expect(compareVersions("2.1.53", SUPPORTED_CLI_VERSION)).toBe(0); + }); + }); +}); diff --git a/src/lib/components/ConfigSidebar.svelte b/src/lib/components/ConfigSidebar.svelte index dd26102..1328fbb 100644 --- a/src/lib/components/ConfigSidebar.svelte +++ b/src/lib/components/ConfigSidebar.svelte @@ -5,6 +5,8 @@ type Theme, type CustomThemeColors, applyFontSize, + applyCustomFont, + applyCustomUiFont, applyCustomThemeColors, MIN_FONT_SIZE, MAX_FONT_SIZE, @@ -56,12 +58,23 @@ show_thinking_blocks: true, use_worktree: false, disable_1m_context: false, + max_output_tokens: null, trusted_workspaces: [], background_image_path: null, background_image_opacity: 0.3, + custom_font_path: null, + custom_font_family: null, + custom_ui_font_path: null, + custom_ui_font_family: null, }); let showCustomThemeEditor = $state(false); + let customFontPathInput = $state(""); + let customFontFamilyInput = $state(""); + let customFontStatus: string | null = $state(null); + let customUiFontPathInput = $state(""); + let customUiFontFamilyInput = $state(""); + let customUiFontStatus: string | null = $state(null); interface AuthStatus { is_logged_in: boolean; @@ -87,6 +100,10 @@ configStore.config.subscribe((c) => { config = { ...c }; + customFontPathInput = c.custom_font_path ?? ""; + customFontFamilyInput = c.custom_font_family ?? ""; + customUiFontPathInput = c.custom_ui_font_path ?? ""; + customUiFontFamilyInput = c.custom_ui_font_family ?? ""; }); configStore.isSidebarOpen.subscribe((open) => { @@ -533,6 +550,25 @@ context window

+ + +
+ + +

+ Sets CLAUDE_CODE_MAX_OUTPUT_TOKENS — increase if responses are + being cut off mid-reply +

+
@@ -917,6 +953,130 @@

+ +
+ Custom Terminal Font +
+ + +
+ + +
+ {#if customFontStatus} +

{customFontStatus}

+ {/if} +
+

+ Supports Google Fonts URLs, direct font file URLs, or local file paths. Family name is + required to apply the font. +

+
+ + +
+ Custom UI Font +
+ + +
+ + +
+ {#if customUiFontStatus} +

{customUiFontStatus}

+ {/if} +
+

+ Applies to the entire app interface (menus, labels, buttons). Supports Google Fonts URLs, + direct font file URLs, or local file paths. +

+
+