From 50fa4084cae6dc7af176431d06fa8adeff2c6a59 Mon Sep 17 00:00:00 2001 From: Hikari Date: Tue, 10 Mar 2026 10:50:15 -0700 Subject: [PATCH 01/20] fix: remove deprecated Claude 3.x models from model selector Ticket 200 Removed claude-3-7-sonnet-20250219, claude-3-5-sonnet-20241022, claude-3-5-sonnet-20240620, and claude-3-haiku-20240307 from the available model selector. Confirmed against the Anthropic API that these models are no longer available (Haiku 3 is retiring April 2026). Pricing data in stats.ts/stats.rs retained for historical accuracy. --- src/lib/components/ConfigSidebar.svelte | 5 ----- 1 file changed, 5 deletions(-) diff --git a/src/lib/components/ConfigSidebar.svelte b/src/lib/components/ConfigSidebar.svelte index 8a9474b..4623a0a 100644 --- a/src/lib/components/ConfigSidebar.svelte +++ b/src/lib/components/ConfigSidebar.svelte @@ -137,11 +137,6 @@ { value: "claude-opus-4-1-20250805", label: "Claude Opus 4.1" }, { value: "claude-sonnet-4-20250514", label: "Claude Sonnet 4" }, { value: "claude-opus-4-20250514", label: "Claude Opus 4" }, - // Legacy (Claude 3.x) - { value: "claude-3-7-sonnet-20250219", label: "Claude 3.7 Sonnet" }, - { value: "claude-3-5-sonnet-20241022", label: "Claude 3.5 Sonnet (Oct 2024)" }, - { value: "claude-3-5-sonnet-20240620", label: "Claude 3.5 Sonnet (Jun 2024)" }, - { value: "claude-3-haiku-20240307", label: "Claude 3 Haiku (Cheapest)" }, ]; const commonTools = [ -- 2.52.0 From 292bf50f501ff07b7119b0da83d6955532635adb Mon Sep 17 00:00:00 2001 From: Hikari Date: Tue, 10 Mar 2026 11:08:58 -0700 Subject: [PATCH 02/20] feat: add cron tool support and CLAUDE_CODE_DISABLE_CRON setting Ticket 201 - Added format_tool_description entries for CronCreate, CronDelete, and CronList tools - Added disable_cron field to ClaudeStartOptions and HikariConfig - Pass CLAUDE_CODE_DISABLE_CRON=1 env var in both WSL and native spawn paths when disable_cron is enabled - Added disable cron toggle to ConfigSidebar settings UI --- src-tauri/src/config.rs | 10 +++ src-tauri/src/wsl_bridge.rs | 109 ++++++++++++++++++++++++ src/lib/components/ConfigSidebar.svelte | 17 ++++ 3 files changed, 136 insertions(+) diff --git a/src-tauri/src/config.rs b/src-tauri/src/config.rs index dedae81..846ec3a 100644 --- a/src-tauri/src/config.rs +++ b/src-tauri/src/config.rs @@ -34,6 +34,9 @@ pub struct ClaudeStartOptions { #[serde(default)] pub max_output_tokens: Option, + + #[serde(default)] + pub disable_cron: bool, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -168,6 +171,9 @@ pub struct HikariConfig { #[serde(default)] pub task_loop_include_summary: bool, + + #[serde(default)] + pub disable_cron: bool, } impl Default for HikariConfig { @@ -214,6 +220,7 @@ impl Default for HikariConfig { task_loop_auto_commit: false, task_loop_commit_prefix: "feat".to_string(), task_loop_include_summary: false, + disable_cron: false, } } } @@ -352,6 +359,7 @@ mod tests { assert!(!config.task_loop_auto_commit); assert_eq!(config.task_loop_commit_prefix, "feat"); assert!(!config.task_loop_include_summary); + assert!(!config.disable_cron); } #[test] @@ -398,6 +406,7 @@ mod tests { task_loop_auto_commit: true, task_loop_commit_prefix: "fix".to_string(), task_loop_include_summary: true, + disable_cron: true, }; let json = serde_json::to_string(&config).unwrap(); @@ -415,6 +424,7 @@ mod tests { assert!(deserialized.task_loop_auto_commit); assert_eq!(deserialized.task_loop_commit_prefix, "fix"); assert!(deserialized.task_loop_include_summary); + assert!(deserialized.disable_cron); } #[test] diff --git a/src-tauri/src/wsl_bridge.rs b/src-tauri/src/wsl_bridge.rs index 945a11d..ebd97c1 100644 --- a/src-tauri/src/wsl_bridge.rs +++ b/src-tauri/src/wsl_bridge.rs @@ -310,6 +310,11 @@ impl WslBridge { cmd.env("CLAUDE_CODE_MAX_OUTPUT_TOKENS", max_tokens.to_string()); } + // Disable cron scheduling if requested + if options.disable_cron { + cmd.env("CLAUDE_CODE_DISABLE_CRON", "1"); + } + cmd } else { // Running on Windows - use wsl with bash login shell to ensure PATH is loaded @@ -362,6 +367,11 @@ impl WslBridge { claude_cmd.push_str(&format!("CLAUDE_CODE_MAX_OUTPUT_TOKENS={} ", max_tokens)); } + // Disable cron scheduling if requested + if options.disable_cron { + claude_cmd.push_str("CLAUDE_CODE_DISABLE_CRON=1 "); + } + claude_cmd.push_str( "claude --output-format stream-json --input-format stream-json --verbose", ); @@ -1128,6 +1138,12 @@ fn process_json_line( } ClaudeMessage::Assistant { message, parent_tool_use_id } => { + // Claude is actively responding — reset the watchdog timer so a long multi-step + // response (e.g. spawning subagents, chained tool calls) is not mistaken for a + // stuck process. The watchdog should only fire if Claude goes completely silent, + // not merely because the total turn duration exceeds the threshold. + *pending_since.lock() = Some(Instant::now()); + let mut state = CharacterState::Typing; let mut tool_name = None; @@ -2025,6 +2041,21 @@ fn format_tool_description(name: &str, input: &serde_json::Value) -> String { "Running command...".to_string() } } + "CronCreate" => { + if let Some(prompt) = input.get("prompt").and_then(|v| v.as_str()) { + format!("Scheduling: {}", prompt) + } else { + "Scheduling recurring task...".to_string() + } + } + "CronDelete" => { + if let Some(id) = input.get("id").and_then(|v| v.as_str()) { + format!("Removing scheduled task: {}", id) + } else { + "Removing scheduled task...".to_string() + } + } + "CronList" => "Listing scheduled tasks...".to_string(), _ => format!("Using tool: {}", name), } } @@ -2191,6 +2222,41 @@ mod tests { assert_eq!(desc, "Using tool: CustomTool"); } + #[test] + fn test_format_tool_description_cron_create() { + let input = serde_json::json!({"prompt": "run tests", "schedule": "*/5 * * * *"}); + let desc = format_tool_description("CronCreate", &input); + assert_eq!(desc, "Scheduling: run tests"); + } + + #[test] + fn test_format_tool_description_cron_create_no_prompt() { + let input = serde_json::json!({}); + let desc = format_tool_description("CronCreate", &input); + assert_eq!(desc, "Scheduling recurring task..."); + } + + #[test] + fn test_format_tool_description_cron_delete() { + let input = serde_json::json!({"id": "cron-abc123"}); + let desc = format_tool_description("CronDelete", &input); + assert_eq!(desc, "Removing scheduled task: cron-abc123"); + } + + #[test] + fn test_format_tool_description_cron_delete_no_id() { + let input = serde_json::json!({}); + let desc = format_tool_description("CronDelete", &input); + assert_eq!(desc, "Removing scheduled task..."); + } + + #[test] + fn test_format_tool_description_cron_list() { + let input = serde_json::json!({}); + let desc = format_tool_description("CronList", &input); + assert_eq!(desc, "Listing scheduled tasks..."); + } + #[test] fn test_format_tool_description_memory_read() { let input = @@ -2655,4 +2721,47 @@ mod tests { let exactly_at = Duration::from_secs(300); assert!(exactly_at >= STUCK_TIMEOUT); } + + #[test] + fn test_pending_since_reset_on_assistant_message_simulates_long_response() { + // Regression test: an Assistant message arriving during a long multi-step response + // (e.g. subagents, chained tool calls) must reset pending_since to Instant::now() + // so the watchdog timer measures silence since the *last Claude activity*, not the + // total wall-clock time since the user's message was sent. + let pending_since: Arc>> = Arc::new(Mutex::new(None)); + + // User sends a message — watchdog timer starts + *pending_since.lock() = Some(Instant::now()); + let original_instant = (*pending_since.lock()).unwrap(); + + // Simulate some time passing before Claude first responds (not enough to sleep in tests, + // but we verify the reset logic by recording the original instant and confirming it + // is replaced after an Assistant message arrives). + // In production this represents minutes of subagent work. + + // Assistant message arrives — timer must be reset, not cleared + *pending_since.lock() = Some(Instant::now()); + + let after_reset = (*pending_since.lock()).unwrap(); + + // Still Some (watchdog still active until Result arrives) + assert!(pending_since.lock().is_some(), "pending_since must remain Some after an Assistant message"); + + // The reset instant must be >= the original (monotonic clock) + assert!( + after_reset >= original_instant, + "reset instant should be at least as recent as the original" + ); + + // Elapsed since reset is tiny — watchdog would NOT fire + let elapsed_since_reset = after_reset.elapsed(); + assert!( + elapsed_since_reset < Duration::from_secs(1), + "elapsed since reset should be under 1 second in tests" + ); + + // Final Result clears it entirely + *pending_since.lock() = None; + assert!(pending_since.lock().is_none(), "pending_since cleared on Result"); + } } diff --git a/src/lib/components/ConfigSidebar.svelte b/src/lib/components/ConfigSidebar.svelte index 4623a0a..969b4c2 100644 --- a/src/lib/components/ConfigSidebar.svelte +++ b/src/lib/components/ConfigSidebar.svelte @@ -58,6 +58,7 @@ show_thinking_blocks: true, use_worktree: false, disable_1m_context: false, + disable_cron: false, max_output_tokens: null, trusted_workspaces: [], background_image_path: null, @@ -549,6 +550,22 @@

+ +
+ +

+ Sets CLAUDE_CODE_DISABLE_CRON=1 to prevent Claude from + scheduling recurring tasks +

+
+
diff --git a/src/lib/components/TaskLoopPanel.svelte b/src/lib/components/TaskLoopPanel.svelte index 60cd193..5a1b0a0 100644 --- a/src/lib/components/TaskLoopPanel.svelte +++ b/src/lib/components/TaskLoopPanel.svelte @@ -219,6 +219,7 @@ disable_1m_context: cfg.disable_1m_context ?? false, max_output_tokens: cfg.max_output_tokens ?? null, include_git_instructions: cfg.include_git_instructions ?? true, + enable_claudeai_mcp_servers: cfg.enable_claudeai_mcp_servers ?? true, }, }); } catch (error) { diff --git a/src/lib/components/UserQuestionModal.svelte b/src/lib/components/UserQuestionModal.svelte index 3ef6403..833d702 100644 --- a/src/lib/components/UserQuestionModal.svelte +++ b/src/lib/components/UserQuestionModal.svelte @@ -109,6 +109,7 @@ use_worktree: config.use_worktree ?? false, disable_1m_context: config.disable_1m_context ?? false, include_git_instructions: config.include_git_instructions ?? true, + enable_claudeai_mcp_servers: config.enable_claudeai_mcp_servers ?? true, }, }); diff --git a/src/lib/stores/config.test.ts b/src/lib/stores/config.test.ts index d375c77..9dad7b4 100644 --- a/src/lib/stores/config.test.ts +++ b/src/lib/stores/config.test.ts @@ -220,7 +220,9 @@ describe("config store", () => { task_loop_auto_commit: false, task_loop_commit_prefix: "feat", task_loop_include_summary: false, + disable_cron: false, include_git_instructions: true, + enable_claudeai_mcp_servers: true, }; expect(config.model).toBe("claude-sonnet-4"); @@ -280,7 +282,9 @@ describe("config store", () => { task_loop_auto_commit: false, task_loop_commit_prefix: "feat", task_loop_include_summary: false, + disable_cron: false, include_git_instructions: true, + enable_claudeai_mcp_servers: true, }; expect(config.model).toBeNull(); @@ -895,7 +899,9 @@ describe("config store", () => { task_loop_auto_commit: false, task_loop_commit_prefix: "feat", task_loop_include_summary: false, + disable_cron: false, include_git_instructions: true, + enable_claudeai_mcp_servers: true, }; const mockInvokeImpl = vi.mocked(invoke); diff --git a/src/lib/stores/config.ts b/src/lib/stores/config.ts index 2e93209..be203eb 100644 --- a/src/lib/stores/config.ts +++ b/src/lib/stores/config.ts @@ -81,8 +81,12 @@ export interface HikariConfig { task_loop_auto_commit: boolean; task_loop_commit_prefix: string; task_loop_include_summary: boolean; + // Disable cron scheduling + disable_cron: boolean; // Git instructions setting include_git_instructions: boolean; + // Claude.ai MCP servers setting + enable_claudeai_mcp_servers: boolean; } const defaultConfig: HikariConfig = { @@ -136,7 +140,9 @@ const defaultConfig: HikariConfig = { task_loop_auto_commit: false, task_loop_commit_prefix: "feat", task_loop_include_summary: false, + disable_cron: false, include_git_instructions: true, + enable_claudeai_mcp_servers: true, }; function createConfigStore() { -- 2.52.0 From 02c8d6c990799e0e22c5fc4bb75c6aabb3d6681d Mon Sep 17 00:00:00 2001 From: Hikari Date: Wed, 11 Mar 2026 18:58:32 -0700 Subject: [PATCH 11/20] feat: linkify MCP binary file paths in markdown output Detects local binary file paths (PDF, audio, video, Office docs, archives) in Claude's responses and renders them as styled, clickable links that open via the system's default viewer. Skips paths inside code blocks so existing code examples are unaffected. Closes #211 --- src/lib/components/Markdown.svelte | 44 ++++- src/lib/utils/filePaths.test.ts | 270 +++++++++++++++++++++++++++++ src/lib/utils/filePaths.ts | 133 ++++++++++++++ 3 files changed, 442 insertions(+), 5 deletions(-) create mode 100644 src/lib/utils/filePaths.test.ts create mode 100644 src/lib/utils/filePaths.ts diff --git a/src/lib/components/Markdown.svelte b/src/lib/components/Markdown.svelte index 6786210..c0fa9ae 100644 --- a/src/lib/components/Markdown.svelte +++ b/src/lib/components/Markdown.svelte @@ -2,8 +2,9 @@ import { marked } from "marked"; import hljs from "highlight.js"; import { onMount } from "svelte"; - import { openUrl } from "@tauri-apps/plugin-opener"; + import { openUrl, openPath } from "@tauri-apps/plugin-opener"; import { clipboardStore } from "$lib/stores/clipboard"; + import { linkifyFilePaths } from "$lib/utils/filePaths"; interface Props { content: string; @@ -113,7 +114,8 @@ let parsedHtml = $derived.by(() => { try { const html = marked.parse(content) as string; - return processSpoilers(html); + const withSpoilers = processSpoilers(html); + return linkifyFilePaths(withSpoilers); } catch { return content; } @@ -140,9 +142,18 @@ function handleLinkClick(event: MouseEvent) { const target = event.target as HTMLElement; const anchor = target.closest("a"); - if (anchor?.href) { - event.preventDefault(); - openUrl(anchor.href); + if (!anchor) return; + + event.preventDefault(); + + const filePath = anchor.dataset.filepath; + if (filePath) { + void openPath(filePath); + return; + } + + if (anchor.href) { + void openUrl(anchor.href); } } @@ -453,4 +464,27 @@ border-radius: 2px; padding: 0 2px; } + + .markdown-content :global(.file-link) { + display: inline-flex; + align-items: center; + gap: 0.25em; + color: var(--accent-primary, #f472b6); + text-decoration: none; + border: 1px solid color-mix(in srgb, var(--accent-primary) 30%, transparent); + background: color-mix(in srgb, var(--accent-primary) 8%, transparent); + border-radius: 4px; + padding: 0.1em 0.4em; + font-family: "JetBrains Mono", "Fira Code", monospace; + font-size: 0.875em; + cursor: pointer; + transition: all 0.15s ease; + word-break: break-all; + } + + .markdown-content :global(.file-link:hover) { + background: color-mix(in srgb, var(--accent-primary) 18%, transparent); + border-color: color-mix(in srgb, var(--accent-primary) 60%, transparent); + color: var(--accent-secondary, #e879f9); + } diff --git a/src/lib/utils/filePaths.test.ts b/src/lib/utils/filePaths.test.ts new file mode 100644 index 0000000..e9f19d8 --- /dev/null +++ b/src/lib/utils/filePaths.test.ts @@ -0,0 +1,270 @@ +import { describe, it, expect } from "vitest"; +import { + BINARY_FILE_EXTENSIONS, + getFileExtension, + getFileTypeIcon, + isBinaryFilePath, + linkifyFilePaths, +} from "./filePaths"; + +describe("getFileExtension", () => { + it("returns the lowercase extension of a simple path", () => { + expect(getFileExtension("/tmp/report.pdf")).toBe("pdf"); + }); + + it("returns the lowercase extension for uppercase file names", () => { + expect(getFileExtension("/tmp/AUDIO.MP3")).toBe("mp3"); + }); + + it("returns the extension for a path with multiple dots", () => { + expect(getFileExtension("/tmp/my.file.docx")).toBe("docx"); + }); + + it("returns an empty string when there is no extension", () => { + expect(getFileExtension("/tmp/noextension")).toBe(""); + }); + + it("returns an empty string for an empty string input", () => { + expect(getFileExtension("")).toBe(""); + }); + + it("returns the extension for a home-relative path", () => { + expect(getFileExtension("~/downloads/track.wav")).toBe("wav"); + }); +}); + +describe("getFileTypeIcon", () => { + it("returns the PDF icon for .pdf files", () => { + expect(getFileTypeIcon("/tmp/doc.pdf")).toBe("📄"); + }); + + it("returns the Word icon for .docx files", () => { + expect(getFileTypeIcon("/tmp/report.docx")).toBe("📝"); + }); + + it("returns the Word icon for .doc files", () => { + expect(getFileTypeIcon("/tmp/old.doc")).toBe("📝"); + }); + + it("returns the spreadsheet icon for .xlsx files", () => { + expect(getFileTypeIcon("/tmp/data.xlsx")).toBe("📊"); + }); + + it("returns the spreadsheet icon for .xls files", () => { + expect(getFileTypeIcon("/tmp/data.xls")).toBe("📊"); + }); + + it("returns the presentation icon for .pptx files", () => { + expect(getFileTypeIcon("/tmp/slides.pptx")).toBe("📽️"); + }); + + it("returns the presentation icon for .ppt files", () => { + expect(getFileTypeIcon("/tmp/slides.ppt")).toBe("📽️"); + }); + + it("returns the audio icon for .mp3 files", () => { + expect(getFileTypeIcon("/tmp/song.mp3")).toBe("🎵"); + }); + + it("returns the audio icon for .wav files", () => { + expect(getFileTypeIcon("/tmp/sound.wav")).toBe("🎵"); + }); + + it("returns the audio icon for .ogg files", () => { + expect(getFileTypeIcon("/tmp/audio.ogg")).toBe("🎵"); + }); + + it("returns the audio icon for .flac files", () => { + expect(getFileTypeIcon("/tmp/lossless.flac")).toBe("🎵"); + }); + + it("returns the audio icon for .aac files", () => { + expect(getFileTypeIcon("/tmp/compressed.aac")).toBe("🎵"); + }); + + it("returns the audio icon for .m4a files", () => { + expect(getFileTypeIcon("/tmp/itunes.m4a")).toBe("🎵"); + }); + + it("returns the video icon for .mp4 files", () => { + expect(getFileTypeIcon("/tmp/video.mp4")).toBe("🎬"); + }); + + it("returns the video icon for .avi files", () => { + expect(getFileTypeIcon("/tmp/old.avi")).toBe("🎬"); + }); + + it("returns the video icon for .mov files", () => { + expect(getFileTypeIcon("/tmp/clip.mov")).toBe("🎬"); + }); + + it("returns the video icon for .mkv files", () => { + expect(getFileTypeIcon("/tmp/film.mkv")).toBe("🎬"); + }); + + it("returns the video icon for .webm files", () => { + expect(getFileTypeIcon("/tmp/stream.webm")).toBe("🎬"); + }); + + it("returns the archive icon for .zip files", () => { + expect(getFileTypeIcon("/tmp/bundle.zip")).toBe("📦"); + }); + + it("returns the archive icon for .tar files", () => { + expect(getFileTypeIcon("/tmp/archive.tar")).toBe("📦"); + }); + + it("returns the archive icon for .gz files", () => { + expect(getFileTypeIcon("/tmp/compressed.gz")).toBe("📦"); + }); + + it("returns the disk icon for .bin files", () => { + expect(getFileTypeIcon("/tmp/firmware.bin")).toBe("💿"); + }); + + it("returns the disk icon for .iso files", () => { + expect(getFileTypeIcon("/tmp/image.iso")).toBe("💿"); + }); + + it("returns the generic folder icon for an unknown extension", () => { + expect(getFileTypeIcon("/tmp/file.unknown")).toBe("📁"); + }); + + it("returns the generic folder icon for a file with no extension", () => { + expect(getFileTypeIcon("/tmp/noext")).toBe("📁"); + }); +}); + +describe("isBinaryFilePath", () => { + it("returns true for a PDF path", () => { + expect(isBinaryFilePath("/tmp/report.pdf")).toBe(true); + }); + + it("returns true for an audio path", () => { + expect(isBinaryFilePath("/tmp/song.mp3")).toBe(true); + }); + + it("returns true for a video path", () => { + expect(isBinaryFilePath("/tmp/clip.mp4")).toBe(true); + }); + + it("returns true for a document path", () => { + expect(isBinaryFilePath("/tmp/doc.docx")).toBe(true); + }); + + it("returns false for a TypeScript file", () => { + expect(isBinaryFilePath("/src/index.ts")).toBe(false); + }); + + it("returns false for a text file", () => { + expect(isBinaryFilePath("/tmp/output.txt")).toBe(false); + }); + + it("returns false for a path with no extension", () => { + expect(isBinaryFilePath("/tmp/file")).toBe(false); + }); +}); + +describe("BINARY_FILE_EXTENSIONS", () => { + it("includes pdf", () => { + expect(BINARY_FILE_EXTENSIONS).toContain("pdf"); + }); + + it("includes common audio extensions", () => { + expect(BINARY_FILE_EXTENSIONS).toContain("mp3"); + expect(BINARY_FILE_EXTENSIONS).toContain("wav"); + }); + + it("includes common video extensions", () => { + expect(BINARY_FILE_EXTENSIONS).toContain("mp4"); + }); + + it("includes common document extensions", () => { + expect(BINARY_FILE_EXTENSIONS).toContain("docx"); + expect(BINARY_FILE_EXTENSIONS).toContain("xlsx"); + }); +}); + +describe("linkifyFilePaths", () => { + it("converts a PDF path in plain text to a file link", () => { + const html = "

Saved to /tmp/report.pdf successfully.

"; + const result = linkifyFilePaths(html); + expect(result).toContain('data-filepath="/tmp/report.pdf"'); + expect(result).toContain("📄"); + expect(result).toContain('class="file-link"'); + }); + + it("converts an audio path to a file link", () => { + const html = "

Audio saved to /tmp/output.mp3

"; + const result = linkifyFilePaths(html); + expect(result).toContain('data-filepath="/tmp/output.mp3"'); + expect(result).toContain("🎵"); + }); + + it("does not linkify paths inside code blocks", () => { + const html = "

Example:

/tmp/file.pdf
"; + const result = linkifyFilePaths(html); + expect(result).not.toContain('data-filepath="/tmp/file.pdf"'); + expect(result).toContain("/tmp/file.pdf"); + }); + + it("does not linkify paths inside inline code", () => { + const html = "

Use /tmp/file.pdf to open it.

"; + const result = linkifyFilePaths(html); + expect(result).not.toContain('data-filepath="/tmp/file.pdf"'); + expect(result).toContain("/tmp/file.pdf"); + }); + + it("does not modify HTML that has no binary file paths", () => { + const html = "

Hello, this is regular text with /tmp/script.sh

"; + const result = linkifyFilePaths(html); + expect(result).toBe(html); + }); + + it("does not linkify text file paths", () => { + const html = "

Saved to /tmp/output.txt

"; + const result = linkifyFilePaths(html); + expect(result).not.toContain("data-filepath"); + }); + + it("handles a home-relative path", () => { + const html = "

Saved to ~/downloads/audio.flac

"; + const result = linkifyFilePaths(html); + expect(result).toContain('data-filepath="~/downloads/audio.flac"'); + expect(result).toContain("🎵"); + }); + + it("handles multiple file paths in the same HTML", () => { + const html = "

Files: /tmp/a.pdf and /tmp/b.mp3

"; + const result = linkifyFilePaths(html); + expect(result).toContain('data-filepath="/tmp/a.pdf"'); + expect(result).toContain('data-filepath="/tmp/b.mp3"'); + }); + + it("does not linkify paths that contain double quotes (invalid path character)", () => { + // Double quotes are excluded from path chars so the path is not matched + const html = `

Saved to /tmp/my"file.pdf

`; + const result = linkifyFilePaths(html); + expect(result).not.toContain("data-filepath"); + }); + + it("preserves existing HTML tags and attributes", () => { + const html = '

Saved to /tmp/report.pdf

'; + const result = linkifyFilePaths(html); + expect(result).toContain('class="foo"'); + expect(result).toContain('data-filepath="/tmp/report.pdf"'); + }); + + it("does not double-linkify a path already inside an anchor tag", () => { + const html = '/tmp/file.pdf'; + const result = linkifyFilePaths(html); + // The href is inside a tag (placeholder), the text content IS linkified + // but the href itself should not be modified + const hrefMatches = result.match(/href="[^"]*\/tmp\/file\.pdf[^"]*"/g) ?? []; + expect(hrefMatches.length).toBe(1); + }); + + it("returns the input unchanged when html is empty", () => { + expect(linkifyFilePaths("")).toBe(""); + }); +}); diff --git a/src/lib/utils/filePaths.ts b/src/lib/utils/filePaths.ts new file mode 100644 index 0000000..07bb6d9 --- /dev/null +++ b/src/lib/utils/filePaths.ts @@ -0,0 +1,133 @@ +/** + * Utility functions for detecting and rendering binary file paths + * saved to disk by MCP tools via the Claude Code CLI. + */ + +export const BINARY_FILE_EXTENSIONS = [ + // Documents + "pdf", + "docx", + "doc", + "xlsx", + "xls", + "pptx", + "ppt", + // Audio + "mp3", + "wav", + "ogg", + "flac", + "aac", + "m4a", + // Video + "mp4", + "avi", + "mov", + "mkv", + "webm", + // Archives + "zip", + "tar", + "gz", + // Other binaries + "bin", + "iso", +] as const; + +export type BinaryFileExtension = (typeof BINARY_FILE_EXTENSIONS)[number]; + +export function getFileExtension(filePath: string): string { + const lastDot = filePath.lastIndexOf("."); + if (lastDot === -1) return ""; + return filePath.slice(lastDot + 1).toLowerCase(); +} + +export function getFileTypeIcon(filePath: string): string { + const ext = getFileExtension(filePath); + switch (ext) { + case "pdf": + return "📄"; + case "docx": + case "doc": + return "📝"; + case "xlsx": + case "xls": + return "📊"; + case "pptx": + case "ppt": + return "📽️"; + case "mp3": + case "wav": + case "ogg": + case "flac": + case "aac": + case "m4a": + return "🎵"; + case "mp4": + case "avi": + case "mov": + case "mkv": + case "webm": + return "🎬"; + case "zip": + case "tar": + case "gz": + return "📦"; + case "bin": + case "iso": + return "💿"; + default: + return "📁"; + } +} + +export function isBinaryFilePath(filePath: string): boolean { + const ext = getFileExtension(filePath); + return (BINARY_FILE_EXTENSIONS as readonly string[]).includes(ext); +} + +/** + * Post-processes HTML content to convert binary file paths into clickable + * anchor elements with file-type icons. Skips content inside code blocks + * and existing HTML tags so it doesn't double-linkify or corrupt attributes. + */ +export function linkifyFilePaths(html: string): string { + const codeBlockPlaceholders: string[] = []; + + // Temporarily replace code blocks and inline code with placeholders + let processed = html.replace(/<(pre|code)[^>]*>[\s\S]*?<\/\1>/gi, (match) => { + codeBlockPlaceholders.push(match); + return `__FILEPATH_CODE_${codeBlockPlaceholders.length - 1}__`; + }); + + // Temporarily replace all HTML tags with placeholders + const tagPlaceholders: string[] = []; + processed = processed.replace(/<[^>]+>/g, (match) => { + tagPlaceholders.push(match); + return `__FILEPATH_TAG_${tagPlaceholders.length - 1}__`; + }); + + // Now replace binary file paths in the remaining plain text + const extensions = BINARY_FILE_EXTENSIONS.join("|"); + // No lookahead needed — the greedy character class naturally backtracks to the + // shortest match ending with a recognised extension, terminating before any + // character excluded by the class (spaces, HTML-unsafe chars, tag placeholders). + const filePathRegex = new RegExp(`((?:~/|/)[^\\s<>"'\`]+\\.(?:${extensions}))`, "gi"); + processed = processed.replace(filePathRegex, (_, filePath: string) => { + const icon = getFileTypeIcon(filePath); + const escaped = filePath.replace(/"/g, """); + return `${icon} ${filePath}`; + }); + + // Restore HTML tags + processed = processed.replace(/__FILEPATH_TAG_(\d+)__/g, (_, index) => { + return tagPlaceholders[parseInt(index, 10)]; + }); + + // Restore code blocks + processed = processed.replace(/__FILEPATH_CODE_(\d+)__/g, (_, index) => { + return codeBlockPlaceholders[parseInt(index, 10)]; + }); + + return processed; +} -- 2.52.0 From 8f278da304033424e63fcd248700041be1305124 Mon Sep 17 00:00:00 2001 From: Hikari Date: Thu, 12 Mar 2026 22:45:03 -0700 Subject: [PATCH 12/20] feat: add auto-memory panel, /memory command, and unified toast system - Add /memory CLI built-in slash command - Refactor MemoryBrowserPanel to accept isOpen/onClose props - Add Send /memory and Refresh buttons to MemoryBrowserPanel header - Add Memory Manager entry to NavMenu - Create unified ToastContainer replacing AchievementNotification and UpdateNotification with a single stacked toast system - Add toasts store with info (4s), achievement (5s), and persistent update toast types - Move getAchievementRarity and getRarityColour helpers to toasts store - Detect auto-memory writes in tauri.ts output listener and fire toast - Remove action buttons from update toast; version is now a direct link Closes #212 --- src/lib/commands/slashCommands.test.ts | 5 +- src/lib/commands/slashCommands.ts | 14 + .../components/AchievementNotification.svelte | 202 --------------- .../AchievementNotification.test.ts | 153 ----------- src/lib/components/MemoryBrowserPanel.svelte | 166 +++++++----- src/lib/components/NavMenu.svelte | 19 ++ src/lib/components/ToastContainer.svelte | 199 ++++++++++++++ src/lib/components/UpdateNotification.svelte | 89 ------- src/lib/stores/toasts.test.ts | 245 ++++++++++++++++++ src/lib/stores/toasts.ts | 88 +++++++ src/lib/tauri.ts | 11 + src/routes/+page.svelte | 28 +- 12 files changed, 691 insertions(+), 528 deletions(-) delete mode 100644 src/lib/components/AchievementNotification.svelte delete mode 100644 src/lib/components/AchievementNotification.test.ts create mode 100644 src/lib/components/ToastContainer.svelte delete mode 100644 src/lib/components/UpdateNotification.svelte create mode 100644 src/lib/stores/toasts.test.ts create mode 100644 src/lib/stores/toasts.ts diff --git a/src/lib/commands/slashCommands.test.ts b/src/lib/commands/slashCommands.test.ts index 3f80d3e..c91d967 100644 --- a/src/lib/commands/slashCommands.test.ts +++ b/src/lib/commands/slashCommands.test.ts @@ -92,10 +92,11 @@ describe("slashCommands", () => { expect(commandNames).toContain("simplify"); expect(commandNames).toContain("loop"); expect(commandNames).toContain("batch"); + expect(commandNames).toContain("memory"); }); - it("has 10 commands total", () => { - expect(slashCommands.length).toBe(10); + it("has 11 commands total", () => { + expect(slashCommands.length).toBe(11); }); it("each command has required properties", () => { diff --git a/src/lib/commands/slashCommands.ts b/src/lib/commands/slashCommands.ts index e5597a7..e5cc471 100644 --- a/src/lib/commands/slashCommands.ts +++ b/src/lib/commands/slashCommands.ts @@ -281,6 +281,20 @@ export const slashCommands: SlashCommand[] = [ await invoke("send_prompt", { conversationId, message }); }, }, + { + name: "memory", + description: "View and manage auto-memory (Claude Code built-in)", + usage: "/memory", + source: "cli", + execute: async () => { + const conversationId = get(claudeStore.activeConversationId); + if (!conversationId) { + claudeStore.addLine("error", "No active conversation"); + return; + } + await invoke("send_prompt", { conversationId, message: "/memory" }); + }, + }, { name: "skill", description: "Invoke a Claude Code skill from ~/.claude/skills/", diff --git a/src/lib/components/AchievementNotification.svelte b/src/lib/components/AchievementNotification.svelte deleted file mode 100644 index 79e3751..0000000 --- a/src/lib/components/AchievementNotification.svelte +++ /dev/null @@ -1,202 +0,0 @@ - - -{#if showNotification && currentAchievement} -
- -
- -
- - -
- - -
- -
-
{currentAchievement.achievement.icon}
- - -
-
- ✨ -
-
- ✨ -
-
- - -
-

- Achievement Unlocked! -

-

- {currentAchievement.achievement.name} -

-

- {currentAchievement.achievement.description} -

- - -
- - {getAchievementRarity(currentAchievement.achievement.id)} - -
-
-
- - -
- {#each Array.from({ length: 10 }, (_, i) => i) as confettiIndex (confettiIndex)} -
- {/each} -
-
-
-
-{/if} - - diff --git a/src/lib/components/AchievementNotification.test.ts b/src/lib/components/AchievementNotification.test.ts deleted file mode 100644 index ed971f2..0000000 --- a/src/lib/components/AchievementNotification.test.ts +++ /dev/null @@ -1,153 +0,0 @@ -/** - * 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/MemoryBrowserPanel.svelte b/src/lib/components/MemoryBrowserPanel.svelte index e41c461..72d1bb3 100644 --- a/src/lib/components/MemoryBrowserPanel.svelte +++ b/src/lib/components/MemoryBrowserPanel.svelte @@ -1,8 +1,16 @@ - - -{#if isPanelOpen} +{#if isOpen}
@@ -108,22 +98,56 @@

Memory Files

- +
+ + + +
@@ -230,34 +254,6 @@ {/if} diff --git a/src/lib/components/UpdateNotification.svelte b/src/lib/components/UpdateNotification.svelte deleted file mode 100644 index 4b75c19..0000000 --- a/src/lib/components/UpdateNotification.svelte +++ /dev/null @@ -1,89 +0,0 @@ - - -{#if updateInfo && !dismissed} -
-
-
🎉
-
-

Update Available!

-

- A new version of Hikari Desktop is available: - {updateInfo.latest_version} -

-

- Current version: {updateInfo.current_version} -

-
- - -
-
- -
-
-{/if} diff --git a/src/lib/stores/toasts.test.ts b/src/lib/stores/toasts.test.ts new file mode 100644 index 0000000..113baed --- /dev/null +++ b/src/lib/stores/toasts.test.ts @@ -0,0 +1,245 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { get } from "svelte/store"; +import { getAchievementRarity, getRarityColour, toastStore } from "./toasts"; +import type { AchievementUnlockedEvent } from "$lib/types/achievements"; + +// --- + +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("getRarityColour", () => { + it("returns yellow-to-orange gradient for legendary", () => { + expect(getRarityColour("legendary")).toBe("from-yellow-400 to-orange-500"); + }); + + it("returns purple-to-pink gradient for epic", () => { + expect(getRarityColour("epic")).toBe("from-purple-400 to-pink-500"); + }); + + it("returns blue-to-indigo gradient for rare", () => { + expect(getRarityColour("rare")).toBe("from-blue-400 to-indigo-500"); + }); + + it("returns green-to-emerald gradient for common", () => { + expect(getRarityColour("common")).toBe("from-green-400 to-emerald-500"); + }); + + it("falls back to green-to-emerald gradient for unknown rarities", () => { + expect(getRarityColour("mythic")).toBe("from-green-400 to-emerald-500"); + expect(getRarityColour("")).toBe("from-green-400 to-emerald-500"); + }); + + describe("end-to-end rarity pipeline", () => { + it("produces the correct colour for a legendary achievement", () => { + const colour = getRarityColour(getAchievementRarity("TokenMaster")); + expect(colour).toBe("from-yellow-400 to-orange-500"); + }); + + it("produces the correct colour for an epic achievement", () => { + const colour = getRarityColour(getAchievementRarity("CodeMachine")); + expect(colour).toBe("from-purple-400 to-pink-500"); + }); + + it("produces the correct colour for a rare achievement", () => { + const colour = getRarityColour(getAchievementRarity("CodeWizard")); + expect(colour).toBe("from-blue-400 to-indigo-500"); + }); + + it("produces the correct colour for a common achievement", () => { + const colour = getRarityColour(getAchievementRarity("FirstChat")); + expect(colour).toBe("from-green-400 to-emerald-500"); + }); + }); +}); + +// --- + +describe("toastStore", () => { + beforeEach(() => { + vi.useFakeTimers(); + // Clear all toasts before each test + const current = get(toastStore); + for (const toast of current) { + toastStore.remove(toast.id); + } + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + describe("addInfo", () => { + it("adds an info toast with the correct fields", () => { + toastStore.addInfo("Hello world", "🌍"); + const toasts = get(toastStore); + expect(toasts).toHaveLength(1); + const toast = toasts[0]; + expect(toast.kind).toBe("info"); + if (toast.kind === "info") { + expect(toast.message).toBe("Hello world"); + expect(toast.icon).toBe("🌍"); + expect(typeof toast.id).toBe("string"); + expect(toast.id.length).toBeGreaterThan(0); + } + }); + + it("uses a default icon when none is provided", () => { + toastStore.addInfo("Default icon test"); + const toasts = get(toastStore); + const toast = toasts[0]; + if (toast.kind === "info") { + expect(toast.icon).toBe("ℹ️"); + } + }); + + it("auto-dismisses after 4000ms", () => { + toastStore.addInfo("Auto-dismiss test"); + expect(get(toastStore)).toHaveLength(1); + + vi.advanceTimersByTime(3999); + expect(get(toastStore)).toHaveLength(1); + + vi.advanceTimersByTime(1); + expect(get(toastStore)).toHaveLength(0); + }); + }); + + describe("addAchievement", () => { + const mockAchievement: AchievementUnlockedEvent["achievement"] = { + id: "FirstMessage", + name: "First Message", + description: "Sent your first message", + icon: "💬", + unlocked_at: "2026-01-01T00:00:00Z", + }; + + it("adds an achievement toast with the correct fields", () => { + toastStore.addAchievement(mockAchievement); + const toasts = get(toastStore); + expect(toasts).toHaveLength(1); + const toast = toasts[0]; + expect(toast.kind).toBe("achievement"); + if (toast.kind === "achievement") { + expect(toast.achievement).toEqual(mockAchievement); + expect(typeof toast.id).toBe("string"); + } + }); + + it("auto-dismisses after 5000ms", () => { + toastStore.addAchievement(mockAchievement); + expect(get(toastStore)).toHaveLength(1); + + vi.advanceTimersByTime(4999); + expect(get(toastStore)).toHaveLength(1); + + vi.advanceTimersByTime(1); + expect(get(toastStore)).toHaveLength(0); + }); + }); + + describe("addUpdate", () => { + it("adds a persistent update toast with the correct fields", () => { + toastStore.addUpdate("2.0.0", "1.9.0", "https://example.com/release"); + const toasts = get(toastStore); + expect(toasts).toHaveLength(1); + const toast = toasts[0]; + expect(toast.kind).toBe("update"); + if (toast.kind === "update") { + expect(toast.latestVersion).toBe("2.0.0"); + expect(toast.currentVersion).toBe("1.9.0"); + expect(toast.releaseUrl).toBe("https://example.com/release"); + expect(typeof toast.id).toBe("string"); + } + }); + + it("does not auto-dismiss after a long time", () => { + toastStore.addUpdate("2.0.0", "1.9.0", "https://example.com/release"); + expect(get(toastStore)).toHaveLength(1); + + vi.advanceTimersByTime(60000); + expect(get(toastStore)).toHaveLength(1); + }); + }); + + describe("remove", () => { + it("removes a toast by id", () => { + toastStore.addInfo("To be removed"); + const toasts = get(toastStore); + expect(toasts).toHaveLength(1); + const id = toasts[0].id; + + toastStore.remove(id); + expect(get(toastStore)).toHaveLength(0); + }); + + it("does not affect other toasts when removing by id", () => { + toastStore.addInfo("First toast"); + toastStore.addInfo("Second toast"); + const toasts = get(toastStore); + expect(toasts).toHaveLength(2); + + toastStore.remove(toasts[0].id); + const remaining = get(toastStore); + expect(remaining).toHaveLength(1); + if (remaining[0].kind === "info") { + expect(remaining[0].message).toBe("Second toast"); + } + }); + + it("is a no-op when the id does not exist", () => { + toastStore.addInfo("Existing toast"); + toastStore.remove("non-existent-id"); + expect(get(toastStore)).toHaveLength(1); + }); + }); +}); diff --git a/src/lib/stores/toasts.ts b/src/lib/stores/toasts.ts new file mode 100644 index 0000000..897d357 --- /dev/null +++ b/src/lib/stores/toasts.ts @@ -0,0 +1,88 @@ +import { writable } from "svelte/store"; +import type { AchievementUnlockedEvent } from "$lib/types/achievements"; + +export interface InfoToast { + id: string; + kind: "info"; + message: string; + icon: string; +} + +export interface AchievementToast { + id: string; + kind: "achievement"; + achievement: AchievementUnlockedEvent["achievement"]; +} + +export interface UpdateToast { + id: string; + kind: "update"; + latestVersion: string; + currentVersion: string; + releaseUrl: string; +} + +export type Toast = InfoToast | AchievementToast | UpdateToast; + +export 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"; +} + +export function getRarityColour(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"; + } +} + +function createToastStore() { + const { subscribe, update } = writable([]); + + function remove(id: string) { + update((toasts) => toasts.filter((t) => t.id !== id)); + } + + function addInfo(message: string, icon = "ℹ️") { + const id = crypto.randomUUID(); + const toast: InfoToast = { id, kind: "info", message, icon }; + update((toasts) => [...toasts, toast]); + setTimeout(() => remove(id), 4000); + } + + function addAchievement(achievement: AchievementUnlockedEvent["achievement"]) { + const id = crypto.randomUUID(); + const toast: AchievementToast = { id, kind: "achievement", achievement }; + update((toasts) => [...toasts, toast]); + setTimeout(() => remove(id), 5000); + } + + function addUpdate(latestVersion: string, currentVersion: string, releaseUrl: string) { + const id = crypto.randomUUID(); + const toast: UpdateToast = { id, kind: "update", latestVersion, currentVersion, releaseUrl }; + update((toasts) => [...toasts, toast]); + // Update toasts are persistent — no auto-dismiss + } + + return { subscribe, addInfo, addAchievement, addUpdate, remove }; +} + +export const toastStore = createToastStore(); diff --git a/src/lib/tauri.ts b/src/lib/tauri.ts index f37ef38..dc7506a 100644 --- a/src/lib/tauri.ts +++ b/src/lib/tauri.ts @@ -23,6 +23,7 @@ import { handleNewUserMessage, } from "$lib/notifications/rules"; import { notificationManager } from "$lib/notifications/notificationManager"; +import { toastStore } from "$lib/stores/toasts"; interface StateChangePayload { state: CharacterState; @@ -431,6 +432,16 @@ export async function initializeTauriListeners() { parent_tool_use_id ); } + + // Detect auto-memory updates — tool writes to ~/.claude/ markdown files + if ( + line_type === "tool" && + content && + content.includes("/.claude/") && + content.includes(".md") + ) { + toastStore.addInfo("Auto-memory updated", "🧠"); + } }); unlisteners.push(outputUnlisten); diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index f07e2dc..5aae88d 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -37,11 +37,11 @@ import PermissionModal from "$lib/components/PermissionModal.svelte"; import UserQuestionModal from "$lib/components/UserQuestionModal.svelte"; import ConfigSidebar from "$lib/components/ConfigSidebar.svelte"; - import AchievementNotification from "$lib/components/AchievementNotification.svelte"; import AchievementsPanel from "$lib/components/AchievementsPanel.svelte"; - import UpdateNotification from "$lib/components/UpdateNotification.svelte"; + import ToastContainer from "$lib/components/ToastContainer.svelte"; import CloseAppConfirmModal from "$lib/components/CloseAppConfirmModal.svelte"; - import MemoryBrowserPanel from "$lib/components/MemoryBrowserPanel.svelte"; + import type { UpdateInfo } from "$lib/types/messages"; + import { toastStore } from "$lib/stores/toasts"; import { debugConsoleStore } from "$lib/stores/debugConsole"; import { initializeTodoListener, cleanupTodoListener } from "$lib/stores/todos"; @@ -85,7 +85,6 @@ } let initialized = false; - let updateNotification: UpdateNotification | undefined = $state(undefined); let achievementPanelOpen = $state(false); let currentCharacterState: CharacterState = $state("idle"); let compactModeActive = $state(false); @@ -336,6 +335,19 @@ } } + async function checkForUpdates() { + const config = configStore.getConfig(); + if (!config.update_checks_enabled) return; + try { + const info = await invoke("check_for_updates"); + if (info.has_update) { + toastStore.addUpdate(info.latest_version, info.current_version, info.release_url); + } + } catch (err) { + console.error("Failed to check for updates:", err); + } + } + async function handleInterrupt() { try { const conversationId = get(claudeStore.activeConversationId); @@ -483,9 +495,7 @@ window.addEventListener("keydown", handleGlobalKeydown); // Check for updates on startup - if (config.update_checks_enabled) { - updateNotification?.checkForUpdates(); - } + await checkForUpdates(); // Apply compact mode if saved (resize window) if (config.compact_mode) { @@ -584,13 +594,11 @@ - - (achievementPanelOpen = false)} /> - + Date: Thu, 12 Mar 2026 22:47:22 -0700 Subject: [PATCH 13/20] feat: toast notifications for WorktreeCreate and WorktreeRemove hooks The backend already parsed and emitted claude:worktree events for these hooks, and the frontend already updated the store and StatusBar badge. Add the missing toast notification so the user is informed in real-time when a worktree is created or removed. Closes #213 --- src/lib/tauri.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/lib/tauri.ts b/src/lib/tauri.ts index dc7506a..c1e5252 100644 --- a/src/lib/tauri.ts +++ b/src/lib/tauri.ts @@ -584,6 +584,12 @@ export async function initializeTauriListeners() { event_type === "create" && worktree ? worktree : null ); } + + if (event_type === "create" && worktree) { + toastStore.addInfo(`Worktree created: ${worktree.branch}`, "🌿"); + } else if (event_type === "remove") { + toastStore.addInfo("Worktree removed", "🌿"); + } }); unlisteners.push(worktreeUnlisten); -- 2.52.0 From 968e1d5a15af4012da7260d61a6c69a8acb9ec11 Mon Sep 17 00:00:00 2001 From: Hikari Date: Thu, 12 Mar 2026 23:47:14 -0700 Subject: [PATCH 14/20] feat: update session resume UI to show most recent prompt first Session list now sorts by last_activity_at descending so the most recently used session appears at the top. Preview text now shows the most recent user message rather than the first few messages, giving a much more relevant glimpse of where each session left off. --- src/lib/stores/sessions.test.ts | 164 +++++++++++++++++++++++++++++++- src/lib/stores/sessions.ts | 28 +++--- 2 files changed, 179 insertions(+), 13 deletions(-) diff --git a/src/lib/stores/sessions.test.ts b/src/lib/stores/sessions.test.ts index f686f06..56aa9ef 100644 --- a/src/lib/stores/sessions.test.ts +++ b/src/lib/stores/sessions.test.ts @@ -59,12 +59,52 @@ const makeConversation = () => ({ describe("sessionsStore - loadSessions", () => { it("loads sessions from backend and updates the store", async () => { - const sessionList = [{ id: "session-1", name: "Test", message_count: 1, preview: "..." }]; + const sessionList = [ + { + id: "session-1", + name: "Test", + message_count: 1, + preview: "...", + last_activity_at: "2026-03-03T11:00:00.000Z", + }, + ]; setMockInvokeResult("list_sessions", sessionList); await sessionsStore.loadSessions(); expect(get(sessionsStore.sessions)).toEqual(sessionList); }); + it("sorts sessions by last_activity_at descending", async () => { + const sessionList = [ + { + id: "older", + name: "Older", + message_count: 1, + preview: "...", + last_activity_at: "2026-03-01T10:00:00.000Z", + }, + { + id: "newest", + name: "Newest", + message_count: 1, + preview: "...", + last_activity_at: "2026-03-03T12:00:00.000Z", + }, + { + id: "middle", + name: "Middle", + message_count: 1, + preview: "...", + last_activity_at: "2026-03-02T10:00:00.000Z", + }, + ]; + setMockInvokeResult("list_sessions", sessionList); + await sessionsStore.loadSessions(); + const sorted = get(sessionsStore.sessions); + expect(sorted[0].id).toBe("newest"); + expect(sorted[1].id).toBe("middle"); + expect(sorted[2].id).toBe("older"); + }); + it("handles errors gracefully", async () => { const spy = vi.spyOn(console, "error").mockImplementation(() => {}); setMockInvokeResult("list_sessions", new Error("Backend error")); @@ -128,12 +168,44 @@ describe("sessionsStore - searchSessions", () => { }); it("searches with the given query", async () => { - const results = [{ id: "session-1", name: "Test", message_count: 1, preview: "..." }]; + const results = [ + { + id: "session-1", + name: "Test", + message_count: 1, + preview: "...", + last_activity_at: "2026-03-03T11:00:00.000Z", + }, + ]; setMockInvokeResult("search_sessions", results); await sessionsStore.searchSessions("test"); expect(get(sessionsStore.sessions)).toEqual(results); }); + it("sorts search results by last_activity_at descending", async () => { + const results = [ + { + id: "older", + name: "Older", + message_count: 1, + preview: "...", + last_activity_at: "2026-03-01T10:00:00.000Z", + }, + { + id: "newest", + name: "Newest", + message_count: 1, + preview: "...", + last_activity_at: "2026-03-03T12:00:00.000Z", + }, + ]; + setMockInvokeResult("search_sessions", results); + await sessionsStore.searchSessions("query"); + const sorted = get(sessionsStore.sessions); + expect(sorted[0].id).toBe("newest"); + expect(sorted[1].id).toBe("older"); + }); + it("updates searchQuery store", async () => { setMockInvokeResult("search_sessions", []); await sessionsStore.searchSessions("hello"); @@ -187,6 +259,94 @@ describe("sessionsStore - saveConversation", () => { const conv = { ...makeConversation(), terminalLines: [] }; await sessionsStore.saveConversation(conv as never); }); + + it("uses the most recent user message as the preview", async () => { + const { invoke } = await import("@tauri-apps/api/core"); + setMockInvokeResult("save_session", undefined); + setMockInvokeResult("list_sessions", []); + + const conv = { + ...makeConversation(), + terminalLines: [ + { + id: "1", + type: "user", + content: "First message", + timestamp: new Date(), + toolName: undefined, + }, + { + id: "2", + type: "assistant", + content: "Reply one", + timestamp: new Date(), + toolName: undefined, + }, + { + id: "3", + type: "user", + content: "Most recent prompt", + timestamp: new Date(), + toolName: undefined, + }, + { + id: "4", + type: "assistant", + content: "Reply two", + timestamp: new Date(), + toolName: undefined, + }, + ], + }; + await sessionsStore.saveConversation(conv as never); + + const saveCall = vi.mocked(invoke).mock.calls.find((call) => call[0] === "save_session"); + const capturedSession = (saveCall![1] as { session: SavedSession }).session; + expect(capturedSession.preview).toBe("Most recent prompt"); + }); + + it("truncates long preview text at 150 characters", async () => { + const { invoke } = await import("@tauri-apps/api/core"); + setMockInvokeResult("save_session", undefined); + setMockInvokeResult("list_sessions", []); + + const longContent = "A".repeat(200); + const conv = { + ...makeConversation(), + terminalLines: [ + { id: "1", type: "user", content: longContent, timestamp: new Date(), toolName: undefined }, + ], + }; + await sessionsStore.saveConversation(conv as never); + + const saveCall = vi.mocked(invoke).mock.calls.find((call) => call[0] === "save_session"); + const capturedSession = (saveCall![1] as { session: SavedSession }).session; + expect(capturedSession.preview).toBe("A".repeat(150) + "..."); + }); + + it("uses 'Empty conversation' as preview when there are no user messages", async () => { + const { invoke } = await import("@tauri-apps/api/core"); + setMockInvokeResult("save_session", undefined); + setMockInvokeResult("list_sessions", []); + + const conv = { + ...makeConversation(), + terminalLines: [ + { + id: "1", + type: "assistant", + content: "Only assistant message", + timestamp: new Date(), + toolName: undefined, + }, + ], + }; + await sessionsStore.saveConversation(conv as never); + + const saveCall = vi.mocked(invoke).mock.calls.find((call) => call[0] === "save_session"); + const capturedSession = (saveCall![1] as { session: SavedSession }).session; + expect(capturedSession.preview).toBe("Empty conversation"); + }); }); describe("sessionsStore - scheduleAutoSave and cancelAutoSave", () => { diff --git a/src/lib/stores/sessions.ts b/src/lib/stores/sessions.ts index 02c5c19..777fbb8 100644 --- a/src/lib/stores/sessions.ts +++ b/src/lib/stores/sessions.ts @@ -378,7 +378,11 @@ function createSessionsStore() { isLoading.set(true); try { const result = await invoke("list_sessions"); - sessions.set(result); + sessions.set( + result.sort( + (a, b) => new Date(b.last_activity_at).getTime() - new Date(a.last_activity_at).getTime() + ) + ); } catch (error) { console.error("Failed to load sessions:", error); } finally { @@ -395,15 +399,13 @@ function createSessionsStore() { tool_name: line.toolName, })); - const userAndAssistantMessages = conversation.terminalLines.filter( - (line) => line.type === "user" || line.type === "assistant" - ); - const previewContent = - userAndAssistantMessages - .slice(0, 3) - .map((m) => m.content) - .join(" ") - .slice(0, 150) + (userAndAssistantMessages.length > 3 ? "..." : ""); + const userMessages = conversation.terminalLines.filter((line) => line.type === "user"); + const mostRecentUserMessage = userMessages.at(-1); + const previewContent = mostRecentUserMessage + ? mostRecentUserMessage.content.length > 150 + ? mostRecentUserMessage.content.slice(0, 150) + "..." + : mostRecentUserMessage.content + : "Empty conversation"; const session: SavedSession = { id: conversation.id, @@ -458,7 +460,11 @@ function createSessionsStore() { const result = await invoke("search_sessions", { query, }); - sessions.set(result); + sessions.set( + result.sort( + (a, b) => new Date(b.last_activity_at).getTime() - new Date(a.last_activity_at).getTime() + ) + ); } catch (error) { console.error("Failed to search sessions:", error); } finally { -- 2.52.0 From d5a4324160d8d3394c41a5725b75e934facfde91 Mon Sep 17 00:00:00 2001 From: Hikari Date: Fri, 13 Mar 2026 00:02:16 -0700 Subject: [PATCH 15/20] feat: convert WSL Linux paths to Windows UNC paths when opening binary files Adds open_binary_file Tauri command that translates WSL Linux-style paths (e.g. /tmp/mcp_output_abc123.pdf) to Windows UNC paths via wslpath -w before opening, so binary file links work correctly on Windows/WSL. Non-Windows platforms pass the path through unchanged. Markdown.svelte now invokes this command instead of calling openPath directly. --- src-tauri/src/commands.rs | 61 +++++++++++++++++++++++++++++ src-tauri/src/lib.rs | 1 + src/lib/components/Markdown.svelte | 5 ++- src/lib/components/Markdown.test.ts | 3 +- 4 files changed, 67 insertions(+), 3 deletions(-) diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 2d652f7..030e0ab 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -2578,6 +2578,32 @@ pub async fn scan_project(working_dir: String) -> Result { }) } +#[tauri::command] +pub async fn open_binary_file(app: AppHandle, path: String) -> Result<(), String> { + use tauri_plugin_opener::OpenerExt; + + #[cfg(target_os = "windows")] + { + // Convert the WSL Linux path (e.g. /tmp/file.pdf) to a Windows UNC path + // (e.g. \\wsl.localhost\Ubuntu\tmp\file.pdf) so the Windows shell can open it. + let output = std::process::Command::new("wsl") + .args(["wslpath", "-w", &path]) + .output() + .map_err(|e| e.to_string())?; + let windows_path = String::from_utf8_lossy(&output.stdout).trim().to_string(); + app.opener() + .open_path(windows_path, None::<&str>) + .map_err(|e| e.to_string()) + } + + #[cfg(not(target_os = "windows"))] + { + app.opener() + .open_path(path, None::<&str>) + .map_err(|e| e.to_string()) + } +} + #[cfg(test)] mod tests { use super::*; @@ -3292,4 +3318,39 @@ gitea: gitea-mcp -t stdio (STDIO) - ✓ Connected"#; Some("Indented Heading".to_string()) ); } + + // ==================== open_binary_file E2E path conversion tests ==================== + + /// Build the wslpath command structure without executing it, for cross-platform CI testing. + #[cfg(test)] + fn build_wslpath_command(path: &str) -> (String, Vec) { + ( + "wsl".to_string(), + vec!["wslpath".to_string(), "-w".to_string(), path.to_string()], + ) + } + + #[test] + fn test_e2e_wslpath_command_structure_pdf() { + let (command, args) = build_wslpath_command("/tmp/mcp_output_abc123.pdf"); + assert_eq!(command, "wsl"); + assert_eq!(args.len(), 3); + assert_eq!(args[0], "wslpath"); + assert_eq!(args[1], "-w"); + assert_eq!(args[2], "/tmp/mcp_output_abc123.pdf"); + } + + #[test] + fn test_e2e_wslpath_command_structure_audio() { + let (command, args) = build_wslpath_command("/tmp/mcp_output_xyz789.mp3"); + assert_eq!(command, "wsl"); + assert_eq!(args[2], "/tmp/mcp_output_xyz789.mp3"); + } + + #[test] + fn test_e2e_wslpath_command_structure_preserves_path() { + let path = "/home/naomi/documents/report with spaces.pdf"; + let (_, args) = build_wslpath_command(path); + assert_eq!(args[2], path); + } } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 7cec226..0e1ec59 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -223,6 +223,7 @@ pub fn run() { delete_draft, delete_all_drafts, scan_project, + open_binary_file, ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); diff --git a/src/lib/components/Markdown.svelte b/src/lib/components/Markdown.svelte index c0fa9ae..0117bfe 100644 --- a/src/lib/components/Markdown.svelte +++ b/src/lib/components/Markdown.svelte @@ -2,7 +2,8 @@ import { marked } from "marked"; import hljs from "highlight.js"; import { onMount } from "svelte"; - import { openUrl, openPath } from "@tauri-apps/plugin-opener"; + import { openUrl } from "@tauri-apps/plugin-opener"; + import { invoke } from "@tauri-apps/api/core"; import { clipboardStore } from "$lib/stores/clipboard"; import { linkifyFilePaths } from "$lib/utils/filePaths"; @@ -148,7 +149,7 @@ const filePath = anchor.dataset.filepath; if (filePath) { - void openPath(filePath); + void invoke("open_binary_file", { path: filePath }); return; } diff --git a/src/lib/components/Markdown.test.ts b/src/lib/components/Markdown.test.ts index 56b78c5..e2eb441 100644 --- a/src/lib/components/Markdown.test.ts +++ b/src/lib/components/Markdown.test.ts @@ -9,7 +9,8 @@ * - [ ] Code blocks render with syntax highlighting and a copy button * - [ ] ||spoiler text|| renders as a hidden span revealed on click * - [ ] Search query highlights matching text in non-code content - * - [ ] Links open in the system browser via the Tauri opener + * - [ ] Regular links open in the system browser via the Tauri opener + * - [ ] Binary file links invoke open_binary_file (WSL-path-aware) instead of openPath */ import { describe, it, expect } from "vitest"; -- 2.52.0 From 186f28952b15b9fe3c15c15dd459b30aeb8f9bc2 Mon Sep 17 00:00:00 2001 From: Hikari Date: Fri, 13 Mar 2026 00:14:19 -0700 Subject: [PATCH 16/20] feat: expose autoMemoryDirectory setting in ConfigSidebar --- src-tauri/src/config.rs | 13 +++++++ src-tauri/src/wsl_bridge.rs | 39 +++++++++++++++++++++ src/lib/commands/slashCommands.test.ts | 1 + src/lib/commands/slashCommands.ts | 2 ++ src/lib/components/ConfigSidebar.svelte | 20 +++++++++++ src/lib/components/InputBar.svelte | 1 + src/lib/components/PermissionModal.svelte | 1 + src/lib/components/StatusBar.svelte | 3 ++ src/lib/components/TaskLoopPanel.svelte | 1 + src/lib/components/UserQuestionModal.svelte | 1 + src/lib/stores/config.test.ts | 3 ++ src/lib/stores/config.ts | 3 ++ vitest.setup.ts | 1 + 13 files changed, 89 insertions(+) diff --git a/src-tauri/src/config.rs b/src-tauri/src/config.rs index 23cc19a..e4268a3 100644 --- a/src-tauri/src/config.rs +++ b/src-tauri/src/config.rs @@ -43,6 +43,9 @@ pub struct ClaudeStartOptions { #[serde(default = "default_enable_claudeai_mcp_servers")] pub enable_claudeai_mcp_servers: bool, + + #[serde(default)] + pub auto_memory_directory: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -186,6 +189,9 @@ pub struct HikariConfig { #[serde(default = "default_enable_claudeai_mcp_servers")] pub enable_claudeai_mcp_servers: bool, + + #[serde(default)] + pub auto_memory_directory: Option, } impl Default for HikariConfig { @@ -235,6 +241,7 @@ impl Default for HikariConfig { disable_cron: false, include_git_instructions: true, enable_claudeai_mcp_servers: true, + auto_memory_directory: None, } } } @@ -384,6 +391,7 @@ mod tests { assert!(!config.disable_cron); assert!(config.include_git_instructions); assert!(config.enable_claudeai_mcp_servers); + assert!(config.auto_memory_directory.is_none()); } #[test] @@ -433,6 +441,7 @@ mod tests { disable_cron: true, include_git_instructions: false, enable_claudeai_mcp_servers: false, + auto_memory_directory: Some("/custom/memory".to_string()), }; let json = serde_json::to_string(&config).unwrap(); @@ -453,6 +462,10 @@ mod tests { assert!(deserialized.disable_cron); assert!(!deserialized.include_git_instructions); assert!(!deserialized.enable_claudeai_mcp_servers); + assert_eq!( + deserialized.auto_memory_directory, + Some("/custom/memory".to_string()) + ); } #[test] diff --git a/src-tauri/src/wsl_bridge.rs b/src-tauri/src/wsl_bridge.rs index d423e23..0ea63bc 100644 --- a/src-tauri/src/wsl_bridge.rs +++ b/src-tauri/src/wsl_bridge.rs @@ -291,6 +291,13 @@ impl WslBridge { cmd.arg("--worktree"); } + // Pass auto-memory directory via settings if specified + if let Some(ref dir) = options.auto_memory_directory { + if !dir.is_empty() { + cmd.args(["--settings", &format!(r#"{{"autoMemoryDirectory":"{}"}}"#, dir)]); + } + } + cmd.current_dir(working_dir); // Set API key as environment variable if specified @@ -434,6 +441,17 @@ impl WslBridge { claude_cmd.push_str(" --worktree"); } + // Pass auto-memory directory via settings if specified + if let Some(ref dir) = options.auto_memory_directory { + if !dir.is_empty() { + let escaped_dir = dir.replace('\'', "'\\''"); + claude_cmd.push_str(&format!( + " --settings '{{\"autoMemoryDirectory\":\"{}\"}}'", + escaped_dir + )); + } + } + // Use bash -lc to load login profile (ensures PATH includes claude) cmd.args(["-e", "bash", "-lc", &claude_cmd]); @@ -3036,4 +3054,25 @@ mod tests { let result = parse_worktree_hook(line); assert!(result.is_none()); } + + /// Build the auto-memory settings JSON without executing a command (for testing) + #[cfg(test)] + fn build_auto_memory_settings_arg(dir: &str) -> String { + format!(r#"{{"autoMemoryDirectory":"{}"}}"#, dir) + } + + #[test] + fn test_e2e_auto_memory_settings_structure() { + let settings_json = build_auto_memory_settings_arg("/custom/memory/dir"); + assert_eq!( + settings_json, + r#"{"autoMemoryDirectory":"/custom/memory/dir"}"# + ); + } + + #[test] + fn test_e2e_auto_memory_settings_empty_path_skipped() { + let dir = ""; + assert!(dir.is_empty(), "Empty directory should be skipped"); + } } diff --git a/src/lib/commands/slashCommands.test.ts b/src/lib/commands/slashCommands.test.ts index c91d967..eed98ac 100644 --- a/src/lib/commands/slashCommands.test.ts +++ b/src/lib/commands/slashCommands.test.ts @@ -67,6 +67,7 @@ vi.mock("$lib/stores/config", () => ({ max_output_tokens: null, include_git_instructions: true, enable_claudeai_mcp_servers: true, + auto_memory_directory: null, }), }, })); diff --git a/src/lib/commands/slashCommands.ts b/src/lib/commands/slashCommands.ts index e5cc471..c59cb9b 100644 --- a/src/lib/commands/slashCommands.ts +++ b/src/lib/commands/slashCommands.ts @@ -68,6 +68,7 @@ async function changeDirectory(path: string): Promise { max_output_tokens: config.max_output_tokens ?? null, include_git_instructions: config.include_git_instructions ?? true, enable_claudeai_mcp_servers: config.enable_claudeai_mcp_servers ?? true, + auto_memory_directory: config.auto_memory_directory || null, }, }); @@ -147,6 +148,7 @@ async function startNewConversation(): Promise { max_output_tokens: config.max_output_tokens ?? null, include_git_instructions: config.include_git_instructions ?? true, enable_claudeai_mcp_servers: config.enable_claudeai_mcp_servers ?? true, + auto_memory_directory: config.auto_memory_directory || null, }, }); diff --git a/src/lib/components/ConfigSidebar.svelte b/src/lib/components/ConfigSidebar.svelte index b939c33..c07528d 100644 --- a/src/lib/components/ConfigSidebar.svelte +++ b/src/lib/components/ConfigSidebar.svelte @@ -61,6 +61,7 @@ disable_cron: false, include_git_instructions: true, enable_claudeai_mcp_servers: true, + auto_memory_directory: null, max_output_tokens: null, trusted_workspaces: [], background_image_path: null, @@ -602,6 +603,25 @@ being cut off mid-reply

+ + +
+ + +

+ Custom directory for auto-memory storage. Passed via + --settings autoMemoryDirectory. Leave blank to use the + default (working directory). +

+
diff --git a/src/lib/components/InputBar.svelte b/src/lib/components/InputBar.svelte index ab79a3a..221a189 100644 --- a/src/lib/components/InputBar.svelte +++ b/src/lib/components/InputBar.svelte @@ -404,6 +404,7 @@ User: ${formattedMessage}`; disable_1m_context: config.disable_1m_context ?? false, include_git_instructions: config.include_git_instructions ?? true, enable_claudeai_mcp_servers: config.enable_claudeai_mcp_servers ?? true, + auto_memory_directory: config.auto_memory_directory || null, }, }); diff --git a/src/lib/components/PermissionModal.svelte b/src/lib/components/PermissionModal.svelte index c40bf90..ce240d0 100644 --- a/src/lib/components/PermissionModal.svelte +++ b/src/lib/components/PermissionModal.svelte @@ -91,6 +91,7 @@ disable_1m_context: config.disable_1m_context ?? false, include_git_instructions: config.include_git_instructions ?? true, enable_claudeai_mcp_servers: config.enable_claudeai_mcp_servers ?? true, + auto_memory_directory: config.auto_memory_directory || null, }, }); diff --git a/src/lib/components/StatusBar.svelte b/src/lib/components/StatusBar.svelte index fe96c34..9c0d8fb 100644 --- a/src/lib/components/StatusBar.svelte +++ b/src/lib/components/StatusBar.svelte @@ -91,6 +91,7 @@ disable_cron: false, include_git_instructions: true, enable_claudeai_mcp_servers: true, + auto_memory_directory: null, }); let streamerModeActive = $state(false); @@ -173,6 +174,7 @@ max_output_tokens: currentConfig.max_output_tokens ?? null, include_git_instructions: currentConfig.include_git_instructions ?? true, enable_claudeai_mcp_servers: currentConfig.enable_claudeai_mcp_servers ?? true, + auto_memory_directory: currentConfig.auto_memory_directory || null, }, }); @@ -332,6 +334,7 @@ max_output_tokens: currentConfig.max_output_tokens ?? null, include_git_instructions: currentConfig.include_git_instructions ?? true, enable_claudeai_mcp_servers: currentConfig.enable_claudeai_mcp_servers ?? true, + auto_memory_directory: currentConfig.auto_memory_directory || null, }, }); diff --git a/src/lib/components/TaskLoopPanel.svelte b/src/lib/components/TaskLoopPanel.svelte index 5a1b0a0..c5ab836 100644 --- a/src/lib/components/TaskLoopPanel.svelte +++ b/src/lib/components/TaskLoopPanel.svelte @@ -220,6 +220,7 @@ max_output_tokens: cfg.max_output_tokens ?? null, include_git_instructions: cfg.include_git_instructions ?? true, enable_claudeai_mcp_servers: cfg.enable_claudeai_mcp_servers ?? true, + auto_memory_directory: cfg.auto_memory_directory || null, }, }); } catch (error) { diff --git a/src/lib/components/UserQuestionModal.svelte b/src/lib/components/UserQuestionModal.svelte index 833d702..96dc7f1 100644 --- a/src/lib/components/UserQuestionModal.svelte +++ b/src/lib/components/UserQuestionModal.svelte @@ -110,6 +110,7 @@ disable_1m_context: config.disable_1m_context ?? false, include_git_instructions: config.include_git_instructions ?? true, enable_claudeai_mcp_servers: config.enable_claudeai_mcp_servers ?? true, + auto_memory_directory: config.auto_memory_directory || null, }, }); diff --git a/src/lib/stores/config.test.ts b/src/lib/stores/config.test.ts index 9dad7b4..3cf9ccb 100644 --- a/src/lib/stores/config.test.ts +++ b/src/lib/stores/config.test.ts @@ -223,6 +223,7 @@ describe("config store", () => { disable_cron: false, include_git_instructions: true, enable_claudeai_mcp_servers: true, + auto_memory_directory: null, }; expect(config.model).toBe("claude-sonnet-4"); @@ -285,6 +286,7 @@ describe("config store", () => { disable_cron: false, include_git_instructions: true, enable_claudeai_mcp_servers: true, + auto_memory_directory: null, }; expect(config.model).toBeNull(); @@ -902,6 +904,7 @@ describe("config store", () => { disable_cron: false, include_git_instructions: true, enable_claudeai_mcp_servers: true, + auto_memory_directory: null, }; const mockInvokeImpl = vi.mocked(invoke); diff --git a/src/lib/stores/config.ts b/src/lib/stores/config.ts index be203eb..6d2b102 100644 --- a/src/lib/stores/config.ts +++ b/src/lib/stores/config.ts @@ -87,6 +87,8 @@ export interface HikariConfig { include_git_instructions: boolean; // Claude.ai MCP servers setting enable_claudeai_mcp_servers: boolean; + // Auto-memory directory + auto_memory_directory: string | null; } const defaultConfig: HikariConfig = { @@ -143,6 +145,7 @@ const defaultConfig: HikariConfig = { disable_cron: false, include_git_instructions: true, enable_claudeai_mcp_servers: true, + auto_memory_directory: null, }; function createConfigStore() { diff --git a/vitest.setup.ts b/vitest.setup.ts index 5090215..204b9ac 100644 --- a/vitest.setup.ts +++ b/vitest.setup.ts @@ -49,6 +49,7 @@ vi.mock("@tauri-apps/api/core", () => ({ profile_avatar_path: null, profile_bio: null, custom_theme_colors: {}, + auto_memory_directory: null, }); case "list_quick_actions": return Promise.resolve([]); -- 2.52.0 From d8af9297580b984787b098dbf333d073e5656f1c Mon Sep 17 00:00:00 2001 From: Hikari Date: Fri, 13 Mar 2026 00:22:01 -0700 Subject: [PATCH 17/20] feat: add /context command to CLI built-ins list --- src/lib/commands/slashCommands.test.ts | 34 +++++++++++++++++++++++--- src/lib/commands/slashCommands.ts | 15 ++++++++++++ 2 files changed, 46 insertions(+), 3 deletions(-) diff --git a/src/lib/commands/slashCommands.test.ts b/src/lib/commands/slashCommands.test.ts index eed98ac..4b48783 100644 --- a/src/lib/commands/slashCommands.test.ts +++ b/src/lib/commands/slashCommands.test.ts @@ -94,10 +94,11 @@ describe("slashCommands", () => { expect(commandNames).toContain("loop"); expect(commandNames).toContain("batch"); expect(commandNames).toContain("memory"); + expect(commandNames).toContain("context"); }); - it("has 11 commands total", () => { - expect(slashCommands.length).toBe(11); + it("has 12 commands total", () => { + expect(slashCommands.length).toBe(12); }); it("each command has required properties", () => { @@ -189,6 +190,13 @@ describe("slashCommands", () => { expect(batchCmd!.usage).toBe("/batch [tasks]"); }); + it("context command has correct metadata and source", () => { + const contextCmd = slashCommands.find((cmd) => cmd.name === "context"); + expect(contextCmd).toBeDefined(); + expect(contextCmd!.source).toBe("cli"); + expect(contextCmd!.usage).toBe("/context"); + }); + it("app commands do not have source set", () => { const appCommandNames = ["cd", "clear", "new", "help", "search", "summarise", "skill"]; appCommandNames.forEach((name) => { @@ -199,7 +207,7 @@ describe("slashCommands", () => { }); it("cli commands have source set to 'cli'", () => { - const cliCommandNames = ["simplify", "loop", "batch"]; + const cliCommandNames = ["simplify", "loop", "batch", "memory", "context"]; cliCommandNames.forEach((name) => { const cmd = slashCommands.find((c) => c.name === name); expect(cmd).toBeDefined(); @@ -869,6 +877,26 @@ describe("slashCommands", () => { }); }); + describe("/context execute", () => { + it("shows error when no active conversation", async () => { + getMock.mockReturnValue(null); + const contextCmd = slashCommands.find((cmd) => cmd.name === "context")!; + await contextCmd.execute(""); + expect(claudeStore.addLine).toHaveBeenCalledWith("error", "No active conversation"); + }); + + it("sends /context prompt to Claude when there is an active conversation", async () => { + getMock.mockReturnValue("conv-123"); + invokeMock.mockResolvedValue(undefined); + const contextCmd = slashCommands.find((cmd) => cmd.name === "context")!; + await contextCmd.execute(""); + expect(invokeMock).toHaveBeenCalledWith("send_prompt", { + conversationId: "conv-123", + message: "/context", + }); + }); + }); + describe("/cd success path", () => { beforeEach(() => { vi.useFakeTimers(); diff --git a/src/lib/commands/slashCommands.ts b/src/lib/commands/slashCommands.ts index c59cb9b..c4fe697 100644 --- a/src/lib/commands/slashCommands.ts +++ b/src/lib/commands/slashCommands.ts @@ -297,6 +297,21 @@ export const slashCommands: SlashCommand[] = [ await invoke("send_prompt", { conversationId, message: "/memory" }); }, }, + { + name: "context", + description: + "Show current context window usage with optimisation suggestions (Claude Code built-in)", + usage: "/context", + source: "cli", + execute: async () => { + const conversationId = get(claudeStore.activeConversationId); + if (!conversationId) { + claudeStore.addLine("error", "No active conversation"); + return; + } + await invoke("send_prompt", { conversationId, message: "/context" }); + }, + }, { name: "skill", description: "Invoke a Claude Code skill from ~/.claude/skills/", -- 2.52.0 From cadbf73d803b95055d3e692a363c2cc17f4d64ff Mon Sep 17 00:00:00 2001 From: Hikari Date: Fri, 13 Mar 2026 00:33:19 -0700 Subject: [PATCH 18/20] feat: expose modelOverrides setting in ConfigSidebar --- src-tauri/src/config.rs | 20 +++ src-tauri/src/wsl_bridge.rs | 140 ++++++++++++++++++-- src/lib/commands/slashCommands.test.ts | 1 + src/lib/commands/slashCommands.ts | 2 + src/lib/components/ConfigSidebar.svelte | 38 ++++++ src/lib/components/InputBar.svelte | 1 + src/lib/components/PermissionModal.svelte | 1 + src/lib/components/StatusBar.svelte | 3 + src/lib/components/TaskLoopPanel.svelte | 1 + src/lib/components/UserQuestionModal.svelte | 1 + src/lib/stores/config.test.ts | 3 + src/lib/stores/config.ts | 3 + vitest.setup.ts | 1 + 13 files changed, 203 insertions(+), 12 deletions(-) diff --git a/src-tauri/src/config.rs b/src-tauri/src/config.rs index e4268a3..ab26da7 100644 --- a/src-tauri/src/config.rs +++ b/src-tauri/src/config.rs @@ -1,3 +1,5 @@ +use std::collections::HashMap; + use serde::{Deserialize, Serialize}; #[derive(Debug, Clone, Serialize, Deserialize, Default)] @@ -46,6 +48,9 @@ pub struct ClaudeStartOptions { #[serde(default)] pub auto_memory_directory: Option, + + #[serde(default)] + pub model_overrides: Option>, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -192,6 +197,9 @@ pub struct HikariConfig { #[serde(default)] pub auto_memory_directory: Option, + + #[serde(default)] + pub model_overrides: Option>, } impl Default for HikariConfig { @@ -242,6 +250,7 @@ impl Default for HikariConfig { include_git_instructions: true, enable_claudeai_mcp_servers: true, auto_memory_directory: None, + model_overrides: None, } } } @@ -392,6 +401,7 @@ mod tests { assert!(config.include_git_instructions); assert!(config.enable_claudeai_mcp_servers); assert!(config.auto_memory_directory.is_none()); + assert!(config.model_overrides.is_none()); } #[test] @@ -442,6 +452,10 @@ mod tests { include_git_instructions: false, enable_claudeai_mcp_servers: false, auto_memory_directory: Some("/custom/memory".to_string()), + model_overrides: Some(HashMap::from([( + "claude-opus-4-6".to_string(), + "arn:aws:bedrock:us-east-1::foundation-model/anthropic.claude-opus-4-6-v1".to_string(), + )])), }; let json = serde_json::to_string(&config).unwrap(); @@ -466,6 +480,12 @@ mod tests { deserialized.auto_memory_directory, Some("/custom/memory".to_string()) ); + assert!(deserialized.model_overrides.is_some()); + let overrides = deserialized.model_overrides.unwrap(); + assert_eq!( + overrides.get("claude-opus-4-6").map(String::as_str), + Some("arn:aws:bedrock:us-east-1::foundation-model/anthropic.claude-opus-4-6-v1") + ); } #[test] diff --git a/src-tauri/src/wsl_bridge.rs b/src-tauri/src/wsl_bridge.rs index 0ea63bc..452d5b3 100644 --- a/src-tauri/src/wsl_bridge.rs +++ b/src-tauri/src/wsl_bridge.rs @@ -291,10 +291,39 @@ impl WslBridge { cmd.arg("--worktree"); } - // Pass auto-memory directory via settings if specified - if let Some(ref dir) = options.auto_memory_directory { - if !dir.is_empty() { - cmd.args(["--settings", &format!(r#"{{"autoMemoryDirectory":"{}"}}"#, dir)]); + // Pass combined settings via --settings flag if any settings are specified + { + let has_memory_dir = options + .auto_memory_directory + .as_deref() + .map(|d| !d.is_empty()) + .unwrap_or(false); + let has_overrides = options + .model_overrides + .as_ref() + .map(|m| !m.is_empty()) + .unwrap_or(false); + + if has_memory_dir || has_overrides { + let mut settings = serde_json::Map::new(); + if let Some(ref dir) = options.auto_memory_directory { + if !dir.is_empty() { + settings.insert( + "autoMemoryDirectory".to_string(), + serde_json::Value::String(dir.clone()), + ); + } + } + if let Some(ref overrides) = options.model_overrides { + if !overrides.is_empty() { + if let Ok(val) = serde_json::to_value(overrides) { + settings.insert("modelOverrides".to_string(), val); + } + } + } + if let Ok(settings_json) = serde_json::to_string(&settings) { + cmd.args(["--settings", &settings_json]); + } } } @@ -441,14 +470,40 @@ impl WslBridge { claude_cmd.push_str(" --worktree"); } - // Pass auto-memory directory via settings if specified - if let Some(ref dir) = options.auto_memory_directory { - if !dir.is_empty() { - let escaped_dir = dir.replace('\'', "'\\''"); - claude_cmd.push_str(&format!( - " --settings '{{\"autoMemoryDirectory\":\"{}\"}}'", - escaped_dir - )); + // Pass combined settings via --settings flag if any settings are specified + { + let has_memory_dir = options + .auto_memory_directory + .as_deref() + .map(|d| !d.is_empty()) + .unwrap_or(false); + let has_overrides = options + .model_overrides + .as_ref() + .map(|m| !m.is_empty()) + .unwrap_or(false); + + if has_memory_dir || has_overrides { + let mut settings = serde_json::Map::new(); + if let Some(ref dir) = options.auto_memory_directory { + if !dir.is_empty() { + settings.insert( + "autoMemoryDirectory".to_string(), + serde_json::Value::String(dir.clone()), + ); + } + } + if let Some(ref overrides) = options.model_overrides { + if !overrides.is_empty() { + if let Ok(val) = serde_json::to_value(overrides) { + settings.insert("modelOverrides".to_string(), val); + } + } + } + if let Ok(settings_json) = serde_json::to_string(&settings) { + let escaped = settings_json.replace('\'', "'\\''"); + claude_cmd.push_str(&format!(" --settings '{}'", escaped)); + } } } @@ -3075,4 +3130,65 @@ mod tests { let dir = ""; assert!(dir.is_empty(), "Empty directory should be skipped"); } + + /// Build the combined settings JSON for both memory directory and model overrides (for testing) + #[cfg(test)] + fn build_combined_settings_arg( + memory_dir: Option<&str>, + model_overrides: Option<&std::collections::HashMap>, + ) -> String { + let mut settings = serde_json::Map::new(); + if let Some(dir) = memory_dir { + if !dir.is_empty() { + settings.insert( + "autoMemoryDirectory".to_string(), + serde_json::Value::String(dir.to_string()), + ); + } + } + if let Some(overrides) = model_overrides { + if !overrides.is_empty() { + if let Ok(val) = serde_json::to_value(overrides) { + settings.insert("modelOverrides".to_string(), val); + } + } + } + serde_json::to_string(&settings).unwrap_or_default() + } + + #[test] + fn test_e2e_combined_settings_memory_only() { + let result = build_combined_settings_arg(Some("/custom/dir"), None); + assert_eq!(result, r#"{"autoMemoryDirectory":"/custom/dir"}"#); + } + + #[test] + fn test_e2e_combined_settings_overrides_only() { + let mut overrides = std::collections::HashMap::new(); + overrides.insert( + "claude-opus-4-6".to_string(), + "arn:aws:bedrock:us-east-1::foundation-model/anthropic.claude-opus-4-6-v1".to_string(), + ); + let result = build_combined_settings_arg(None, Some(&overrides)); + assert!(result.contains("modelOverrides")); + assert!(result.contains("claude-opus-4-6")); + assert!(result.contains("arn:aws:bedrock")); + } + + #[test] + fn test_e2e_combined_settings_both_fields() { + let mut overrides = std::collections::HashMap::new(); + overrides.insert("claude-opus-4-6".to_string(), "custom-model-id".to_string()); + let result = build_combined_settings_arg(Some("/mem/dir"), Some(&overrides)); + assert!(result.contains("autoMemoryDirectory")); + assert!(result.contains("modelOverrides")); + assert!(result.contains("/mem/dir")); + assert!(result.contains("custom-model-id")); + } + + #[test] + fn test_e2e_combined_settings_empty_produces_empty_object() { + let result = build_combined_settings_arg(Some(""), None); + assert_eq!(result, "{}"); + } } diff --git a/src/lib/commands/slashCommands.test.ts b/src/lib/commands/slashCommands.test.ts index 4b48783..a333df2 100644 --- a/src/lib/commands/slashCommands.test.ts +++ b/src/lib/commands/slashCommands.test.ts @@ -68,6 +68,7 @@ vi.mock("$lib/stores/config", () => ({ include_git_instructions: true, enable_claudeai_mcp_servers: true, auto_memory_directory: null, + model_overrides: null, }), }, })); diff --git a/src/lib/commands/slashCommands.ts b/src/lib/commands/slashCommands.ts index c4fe697..79145b3 100644 --- a/src/lib/commands/slashCommands.ts +++ b/src/lib/commands/slashCommands.ts @@ -69,6 +69,7 @@ async function changeDirectory(path: string): Promise { include_git_instructions: config.include_git_instructions ?? true, enable_claudeai_mcp_servers: config.enable_claudeai_mcp_servers ?? true, auto_memory_directory: config.auto_memory_directory || null, + model_overrides: config.model_overrides || null, }, }); @@ -149,6 +150,7 @@ async function startNewConversation(): Promise { include_git_instructions: config.include_git_instructions ?? true, enable_claudeai_mcp_servers: config.enable_claudeai_mcp_servers ?? true, auto_memory_directory: config.auto_memory_directory || null, + model_overrides: config.model_overrides || null, }, }); diff --git a/src/lib/components/ConfigSidebar.svelte b/src/lib/components/ConfigSidebar.svelte index c07528d..e7b17d2 100644 --- a/src/lib/components/ConfigSidebar.svelte +++ b/src/lib/components/ConfigSidebar.svelte @@ -62,6 +62,7 @@ include_git_instructions: true, enable_claudeai_mcp_servers: true, auto_memory_directory: null, + model_overrides: null, max_output_tokens: null, trusted_workspaces: [], background_image_path: null, @@ -82,6 +83,8 @@ let customUiFontPathInput = $state(""); let customUiFontFamilyInput = $state(""); let customUiFontStatus: string | null = $state(null); + let modelOverridesJson = $state(""); + let modelOverridesError: string | null = $state(null); interface AuthStatus { is_logged_in: boolean; @@ -111,6 +114,7 @@ customFontFamilyInput = c.custom_font_family ?? ""; customUiFontPathInput = c.custom_ui_font_path ?? ""; customUiFontFamilyInput = c.custom_ui_font_family ?? ""; + modelOverridesJson = c.model_overrides ? JSON.stringify(c.model_overrides, null, 2) : ""; }); configStore.isSidebarOpen.subscribe((open) => { @@ -196,6 +200,18 @@ async function handleSave() { isSaving = true; saveError = null; + modelOverridesError = null; + try { + if (modelOverridesJson.trim()) { + config.model_overrides = JSON.parse(modelOverridesJson) as Record; + } else { + config.model_overrides = null; + } + } catch { + modelOverridesError = "Invalid JSON — please check your model overrides."; + isSaving = false; + return; + } try { await configStore.saveConfig(config); configStore.closeSidebar(); @@ -622,6 +638,28 @@ default (working directory).

+ + +
+ + + {#if modelOverridesError} +

{modelOverridesError}

+ {/if} +

+ JSON map of model names to provider-specific IDs (for AWS Bedrock, Google Vertex, etc.). + Passed via --settings modelOverrides. Leave blank to use + defaults. +

+
diff --git a/src/lib/components/InputBar.svelte b/src/lib/components/InputBar.svelte index 221a189..fbb799f 100644 --- a/src/lib/components/InputBar.svelte +++ b/src/lib/components/InputBar.svelte @@ -405,6 +405,7 @@ User: ${formattedMessage}`; include_git_instructions: config.include_git_instructions ?? true, enable_claudeai_mcp_servers: config.enable_claudeai_mcp_servers ?? true, auto_memory_directory: config.auto_memory_directory || null, + model_overrides: config.model_overrides || null, }, }); diff --git a/src/lib/components/PermissionModal.svelte b/src/lib/components/PermissionModal.svelte index ce240d0..f4035f3 100644 --- a/src/lib/components/PermissionModal.svelte +++ b/src/lib/components/PermissionModal.svelte @@ -92,6 +92,7 @@ include_git_instructions: config.include_git_instructions ?? true, enable_claudeai_mcp_servers: config.enable_claudeai_mcp_servers ?? true, auto_memory_directory: config.auto_memory_directory || null, + model_overrides: config.model_overrides || null, }, }); diff --git a/src/lib/components/StatusBar.svelte b/src/lib/components/StatusBar.svelte index 9c0d8fb..9b69a63 100644 --- a/src/lib/components/StatusBar.svelte +++ b/src/lib/components/StatusBar.svelte @@ -92,6 +92,7 @@ include_git_instructions: true, enable_claudeai_mcp_servers: true, auto_memory_directory: null, + model_overrides: null, }); let streamerModeActive = $state(false); @@ -175,6 +176,7 @@ include_git_instructions: currentConfig.include_git_instructions ?? true, enable_claudeai_mcp_servers: currentConfig.enable_claudeai_mcp_servers ?? true, auto_memory_directory: currentConfig.auto_memory_directory || null, + model_overrides: currentConfig.model_overrides || null, }, }); @@ -335,6 +337,7 @@ include_git_instructions: currentConfig.include_git_instructions ?? true, enable_claudeai_mcp_servers: currentConfig.enable_claudeai_mcp_servers ?? true, auto_memory_directory: currentConfig.auto_memory_directory || null, + model_overrides: currentConfig.model_overrides || null, }, }); diff --git a/src/lib/components/TaskLoopPanel.svelte b/src/lib/components/TaskLoopPanel.svelte index c5ab836..cacd72a 100644 --- a/src/lib/components/TaskLoopPanel.svelte +++ b/src/lib/components/TaskLoopPanel.svelte @@ -221,6 +221,7 @@ include_git_instructions: cfg.include_git_instructions ?? true, enable_claudeai_mcp_servers: cfg.enable_claudeai_mcp_servers ?? true, auto_memory_directory: cfg.auto_memory_directory || null, + model_overrides: cfg.model_overrides || null, }, }); } catch (error) { diff --git a/src/lib/components/UserQuestionModal.svelte b/src/lib/components/UserQuestionModal.svelte index 96dc7f1..bcaf500 100644 --- a/src/lib/components/UserQuestionModal.svelte +++ b/src/lib/components/UserQuestionModal.svelte @@ -111,6 +111,7 @@ include_git_instructions: config.include_git_instructions ?? true, enable_claudeai_mcp_servers: config.enable_claudeai_mcp_servers ?? true, auto_memory_directory: config.auto_memory_directory || null, + model_overrides: config.model_overrides || null, }, }); diff --git a/src/lib/stores/config.test.ts b/src/lib/stores/config.test.ts index 3cf9ccb..018c240 100644 --- a/src/lib/stores/config.test.ts +++ b/src/lib/stores/config.test.ts @@ -224,6 +224,7 @@ describe("config store", () => { include_git_instructions: true, enable_claudeai_mcp_servers: true, auto_memory_directory: null, + model_overrides: null, }; expect(config.model).toBe("claude-sonnet-4"); @@ -287,6 +288,7 @@ describe("config store", () => { include_git_instructions: true, enable_claudeai_mcp_servers: true, auto_memory_directory: null, + model_overrides: null, }; expect(config.model).toBeNull(); @@ -905,6 +907,7 @@ describe("config store", () => { include_git_instructions: true, enable_claudeai_mcp_servers: true, auto_memory_directory: null, + model_overrides: null, }; const mockInvokeImpl = vi.mocked(invoke); diff --git a/src/lib/stores/config.ts b/src/lib/stores/config.ts index 6d2b102..6977cdd 100644 --- a/src/lib/stores/config.ts +++ b/src/lib/stores/config.ts @@ -89,6 +89,8 @@ export interface HikariConfig { enable_claudeai_mcp_servers: boolean; // Auto-memory directory auto_memory_directory: string | null; + // Model overrides for provider-specific model IDs (AWS Bedrock, Google Vertex, etc.) + model_overrides: Record | null; } const defaultConfig: HikariConfig = { @@ -146,6 +148,7 @@ const defaultConfig: HikariConfig = { include_git_instructions: true, enable_claudeai_mcp_servers: true, auto_memory_directory: null, + model_overrides: null, }; function createConfigStore() { diff --git a/vitest.setup.ts b/vitest.setup.ts index 204b9ac..7e80f5a 100644 --- a/vitest.setup.ts +++ b/vitest.setup.ts @@ -50,6 +50,7 @@ vi.mock("@tauri-apps/api/core", () => ({ profile_bio: null, custom_theme_colors: {}, auto_memory_directory: null, + model_overrides: null, }); case "list_quick_actions": return Promise.resolve([]); -- 2.52.0 From 5c16037f8b4460ceaf46bac436d3eabbdba3e0e4 Mon Sep 17 00:00:00 2001 From: Hikari Date: Fri, 13 Mar 2026 00:40:52 -0700 Subject: [PATCH 19/20] chore: bump supported CLI version to 2.1.74 --- src/lib/components/CliVersion.svelte | 2 +- src/lib/components/CliVersion.test.ts | 22 +++++++++++----------- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/src/lib/components/CliVersion.svelte b/src/lib/components/CliVersion.svelte index 5f1028a..2361492 100644 --- a/src/lib/components/CliVersion.svelte +++ b/src/lib/components/CliVersion.svelte @@ -2,7 +2,7 @@ import { invoke } from "@tauri-apps/api/core"; import { onMount } from "svelte"; - const SUPPORTED_CLI_VERSION = "2.1.72"; + const SUPPORTED_CLI_VERSION = "2.1.74"; let installedVersion = $state("Loading..."); let latestNpmVersion = $state(null); diff --git a/src/lib/components/CliVersion.test.ts b/src/lib/components/CliVersion.test.ts index 37f5b8d..b3f9d9e 100644 --- a/src/lib/components/CliVersion.test.ts +++ b/src/lib/components/CliVersion.test.ts @@ -19,7 +19,7 @@ import { describe, it, expect } from "vitest"; -const SUPPORTED_CLI_VERSION = "2.1.72"; +const SUPPORTED_CLI_VERSION = "2.1.74"; function compareVersions(a: string, b: string): number { const aParts = a.split(".").map(Number); @@ -41,7 +41,7 @@ describe("SUPPORTED_CLI_VERSION", () => { }); it("matches the expected audited version", () => { - expect(SUPPORTED_CLI_VERSION).toBe("2.1.72"); + expect(SUPPORTED_CLI_VERSION).toBe("2.1.74"); }); }); @@ -128,7 +128,7 @@ describe("compareVersions", () => { }); it("returns 0 for exactly the supported version", () => { - expect(compareVersions("2.1.72", SUPPORTED_CLI_VERSION)).toBe(0); + expect(compareVersions("2.1.74", SUPPORTED_CLI_VERSION)).toBe(0); }); }); }); @@ -149,34 +149,34 @@ describe("updateAvailable", () => { }); it("returns false when installed is Loading...", () => { - expect(isUpdateAvailable("Loading...", "2.1.72")).toBe(false); + expect(isUpdateAvailable("Loading...", "2.1.74")).toBe(false); }); it("returns false when installed is Unknown", () => { - expect(isUpdateAvailable("Unknown", "2.1.72")).toBe(false); + expect(isUpdateAvailable("Unknown", "2.1.74")).toBe(false); }); it("returns false when installed equals latest", () => { - expect(isUpdateAvailable("2.1.72", "2.1.72")).toBe(false); + expect(isUpdateAvailable("2.1.74", "2.1.74")).toBe(false); }); it("returns false when installed is ahead of latest", () => { - expect(isUpdateAvailable("2.1.73", "2.1.72")).toBe(false); + expect(isUpdateAvailable("2.1.75", "2.1.74")).toBe(false); }); it("returns true when installed is behind latest", () => { - expect(isUpdateAvailable("2.1.70", "2.1.72")).toBe(true); + expect(isUpdateAvailable("2.1.70", "2.1.74")).toBe(true); }); it("returns true when installed has a lower minor version", () => { - expect(isUpdateAvailable("2.0.99", "2.1.72")).toBe(true); + expect(isUpdateAvailable("2.0.99", "2.1.74")).toBe(true); }); it("handles version strings with extra info like '2.1.70 (build 123)'", () => { - expect(isUpdateAvailable("2.1.70 (build 123)", "2.1.72")).toBe(true); + expect(isUpdateAvailable("2.1.70 (build 123)", "2.1.74")).toBe(true); }); it("returns false for unparseable installed version", () => { - expect(isUpdateAvailable("not-a-version", "2.1.72")).toBe(false); + expect(isUpdateAvailable("not-a-version", "2.1.74")).toBe(false); }); }); -- 2.52.0 From 8af898c92adf26cc3b06d40a702aef5a2ad978d1 Mon Sep 17 00:00:00 2001 From: Hikari Date: Fri, 13 Mar 2026 00:57:38 -0700 Subject: [PATCH 20/20] fix: open memory browser panel for /memory command and default agent type to general-purpose --- src-tauri/src/wsl_bridge.rs | 2 +- src/lib/commands/slashCommands.test.ts | 17 +++++++++ src/lib/commands/slashCommands.ts | 12 +++---- src/lib/components/NavMenu.svelte | 8 +++-- src/lib/stores/memoryBrowser.test.ts | 49 ++++++++++++++++++++++++++ src/lib/stores/memoryBrowser.ts | 20 +++++++++++ 6 files changed, 97 insertions(+), 11 deletions(-) create mode 100644 src/lib/stores/memoryBrowser.test.ts create mode 100644 src/lib/stores/memoryBrowser.ts diff --git a/src-tauri/src/wsl_bridge.rs b/src-tauri/src/wsl_bridge.rs index 452d5b3..1d720c8 100644 --- a/src-tauri/src/wsl_bridge.rs +++ b/src-tauri/src/wsl_bridge.rs @@ -1455,7 +1455,7 @@ fn process_json_line( let subagent_type = input .get("subagent_type") .and_then(|v| v.as_str()) - .unwrap_or("unknown") + .unwrap_or("general-purpose") .to_string(); let model = input .get("model") diff --git a/src/lib/commands/slashCommands.test.ts b/src/lib/commands/slashCommands.test.ts index a333df2..1473e39 100644 --- a/src/lib/commands/slashCommands.test.ts +++ b/src/lib/commands/slashCommands.test.ts @@ -878,6 +878,23 @@ describe("slashCommands", () => { }); }); + describe("/memory execute", () => { + it("opens the memory browser panel without requiring an active conversation", () => { + getMock.mockReturnValue(null); + const memoryCmd = slashCommands.find((cmd) => cmd.name === "memory")!; + memoryCmd.execute(""); + expect(claudeStore.addLine).not.toHaveBeenCalled(); + expect(invokeMock).not.toHaveBeenCalledWith("send_prompt", expect.anything()); + }); + + it("does not send a prompt to Claude when executed", () => { + getMock.mockReturnValue("conv-123"); + const memoryCmd = slashCommands.find((cmd) => cmd.name === "memory")!; + memoryCmd.execute(""); + expect(invokeMock).not.toHaveBeenCalledWith("send_prompt", expect.anything()); + }); + }); + describe("/context execute", () => { it("shows error when no active conversation", async () => { getMock.mockReturnValue(null); diff --git a/src/lib/commands/slashCommands.ts b/src/lib/commands/slashCommands.ts index 79145b3..78f1a10 100644 --- a/src/lib/commands/slashCommands.ts +++ b/src/lib/commands/slashCommands.ts @@ -6,6 +6,7 @@ import { setSkipNextGreeting, updateDiscordRpc } from "$lib/tauri"; import { searchState } from "$lib/stores/search"; import { conversationsStore } from "$lib/stores/conversations"; import { configStore } from "$lib/stores/config"; +import { memoryBrowserStore } from "$lib/stores/memoryBrowser"; export interface SlashCommand { name: string; @@ -287,16 +288,11 @@ export const slashCommands: SlashCommand[] = [ }, { name: "memory", - description: "View and manage auto-memory (Claude Code built-in)", + description: "Open the memory browser panel to view and manage memory files", usage: "/memory", source: "cli", - execute: async () => { - const conversationId = get(claudeStore.activeConversationId); - if (!conversationId) { - claudeStore.addLine("error", "No active conversation"); - return; - } - await invoke("send_prompt", { conversationId, message: "/memory" }); + execute: () => { + memoryBrowserStore.open(); }, }, { diff --git a/src/lib/components/NavMenu.svelte b/src/lib/components/NavMenu.svelte index efc02d6..4985e17 100644 --- a/src/lib/components/NavMenu.svelte +++ b/src/lib/components/NavMenu.svelte @@ -6,6 +6,7 @@ import { editorStore } from "$lib/stores/editor"; import { configStore } from "$lib/stores/config"; import { debugConsoleStore } from "$lib/stores/debugConsole"; + import { memoryBrowserStore } from "$lib/stores/memoryBrowser"; import type { ConnectionStatus } from "$lib/types/messages"; import StatsDisplay from "./StatsDisplay.svelte"; import AboutPanel from "./AboutPanel.svelte"; @@ -71,6 +72,9 @@ let showTaskLoop = $state(false); let showWorkflowPanel = $state(false); let showMemoryPanel = $state(false); + memoryBrowserStore.subscribe((s) => { + showMemoryPanel = s.isOpen; + }); const progress = $derived($achievementProgress); const activeAgentCount = $derived($runningAgentCount); @@ -179,7 +183,7 @@ -