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}
-
+ JSON map of model names to provider-specific IDs (for AWS Bedrock, Google Vertex, etc.).
+ Passed via --settings modelOverrides. Leave blank to use
+ defaults.
+