From 452fe185df8976dc6ffe9231c189549d865fac73 Mon Sep 17 00:00:00 2001 From: Hikari Date: Fri, 13 Mar 2026 01:34:44 -0700 Subject: [PATCH] =?UTF-8?q?feat:=20CLI=20v2.1.68=E2=80=93v2.1.74=20compati?= =?UTF-8?q?bility=20updates=20(#221)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary This PR brings Hikari Desktop up to full compatibility with Claude Code CLI versions v2.1.68 through v2.1.74, implementing all changelog items audited in issues #200–#218. ## Changes ### Bug Fixes - Remove deprecated Claude Opus 4.0 and 4.1 models from the model selector - Auto-migrate users pinned to deprecated models to Opus 4.6 ### New Features - Add cron tool support (`CronCreate`, `CronDelete`, `CronList`) with character state mapping and `CLAUDE_CODE_DISABLE_CRON` settings toggle - Handle `EnterWorktree` and `ExitWorktree` tools in character state mapping and tool display - Add CLI update check with npm registry indicator in the version bar - Add `agent_type` field and support the Agent tool rename from CLI v2.1.69 - Consume `worktree` field from status line hook events - Display per-agent model override in the agent monitor tree - Expose Claude Code CLI built-in slash commands (`/simplify`, `/loop`, `/batch`, `/memory`, `/context`) in the command menu with CLI badges - Add `includeGitInstructions` toggle in settings - Add `ENABLE_CLAUDEAI_MCP_SERVERS` opt-out setting - Linkify MCP binary file paths (PDFs, audio, Office docs) in markdown output - Add auto-memory panel, `/memory` slash command shortcut, and unified toast notification system - Toast notifications for `WorktreeCreate` and `WorktreeRemove` hook events - Sort session resume list by most recent activity, with most recent user message as preview - Convert WSL Linux paths to Windows UNC paths when opening binary files via `open_binary_file` command - Expose `autoMemoryDirectory` setting in ConfigSidebar (Agent Settings section) - Add `/context` as a CLI built-in in the slash command menu - Expose `modelOverrides` setting as a JSON textarea in ConfigSidebar (for AWS Bedrock, Google Vertex, etc.) > **Note:** The CLI update check commit does not have a corresponding issue — it was a bonus addition during the audit sprint. ## Closes Closes #200 Closes #201 Closes #202 Closes #205 Closes #206 Closes #207 Closes #208 Closes #209 Closes #210 Closes #211 Closes #212 Closes #213 Closes #214 Closes #215 Closes #216 Closes #217 Closes #218 Reviewed-on: https://git.nhcarrigan.com/nhcarrigan/hikari-desktop/pulls/221 Co-authored-by: Hikari Co-committed-by: Hikari --- src-tauri/src/commands.rs | 121 ++++ src-tauri/src/config.rs | 71 +++ src-tauri/src/lib.rs | 2 + src-tauri/src/types.rs | 20 + src-tauri/src/wsl_bridge.rs | 586 +++++++++++++++++- src/lib/commands/slashCommands.test.ts | 204 +++++- src/lib/commands/slashCommands.ts | 79 +++ .../components/AchievementNotification.svelte | 202 ------ .../AchievementNotification.test.ts | 153 ----- src/lib/components/AgentMonitorPanel.svelte | 10 +- src/lib/components/CliVersion.svelte | 66 +- src/lib/components/CliVersion.test.ts | 54 +- src/lib/components/ConfigSidebar.svelte | 114 +++- src/lib/components/InputBar.svelte | 4 + src/lib/components/Markdown.svelte | 43 +- src/lib/components/Markdown.test.ts | 3 +- src/lib/components/MemoryBrowserPanel.svelte | 166 ++--- src/lib/components/NavMenu.svelte | 23 + src/lib/components/PermissionModal.svelte | 4 + src/lib/components/SlashCommandMenu.svelte | 17 + src/lib/components/StatusBar.svelte | 34 + src/lib/components/TaskLoopPanel.svelte | 4 + src/lib/components/ToastContainer.svelte | 199 ++++++ src/lib/components/UpdateNotification.svelte | 89 --- src/lib/components/UserQuestionModal.svelte | 4 + src/lib/stores/agents.test.ts | 38 ++ src/lib/stores/agents.ts | 3 +- src/lib/stores/claude.ts | 4 + src/lib/stores/config.test.ts | 15 + src/lib/stores/config.ts | 15 + src/lib/stores/conversations.test.ts | 48 ++ src/lib/stores/conversations.ts | 15 + src/lib/stores/memoryBrowser.test.ts | 49 ++ src/lib/stores/memoryBrowser.ts | 20 + src/lib/stores/sessions.test.ts | 164 ++++- src/lib/stores/sessions.ts | 28 +- src/lib/stores/toasts.test.ts | 245 ++++++++ src/lib/stores/toasts.ts | 88 +++ src/lib/tauri.ts | 37 +- src/lib/types/agents.ts | 3 + src/lib/types/worktree.ts | 13 + src/lib/utils/filePaths.test.ts | 270 ++++++++ src/lib/utils/filePaths.ts | 133 ++++ src/routes/+page.svelte | 28 +- vitest.setup.ts | 2 + 45 files changed, 2905 insertions(+), 585 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/memoryBrowser.test.ts create mode 100644 src/lib/stores/memoryBrowser.ts create mode 100644 src/lib/stores/toasts.test.ts create mode 100644 src/lib/stores/toasts.ts create mode 100644 src/lib/types/worktree.ts create mode 100644 src/lib/utils/filePaths.test.ts create mode 100644 src/lib/utils/filePaths.ts diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 35838e8..030e0ab 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -662,6 +662,37 @@ pub async fn fetch_changelog() -> Result, String> { .collect()) } +fn parse_npm_cli_version(json: &str) -> Result { + let data: serde_json::Value = + serde_json::from_str(json).map_err(|e| format!("Failed to parse response: {}", e))?; + data.get("version") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()) + .ok_or_else(|| "No version field in response".to_string()) +} + +#[tauri::command] +pub async fn check_cli_latest_version() -> Result { + let client = reqwest::Client::new(); + let response = client + .get("https://registry.npmjs.org/@anthropic-ai/claude-code/latest") + .header("Accept", "application/json") + .send() + .await + .map_err(|e| format!("Failed to fetch CLI version: {}", e))?; + + if !response.status().is_success() { + return Err(format!("Registry returned status: {}", response.status())); + } + + let body = response + .text() + .await + .map_err(|e| format!("Failed to read response: {}", e))?; + + parse_npm_cli_version(&body) +} + #[derive(Debug, Clone, serde::Serialize)] pub struct SavedFileInfo { pub path: String, @@ -2547,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::*; @@ -2838,6 +2895,35 @@ mod tests { assert!(json.contains("null") || json.contains("release_notes")); } + // ==================== parse_npm_cli_version tests ==================== + + #[test] + fn test_parse_npm_cli_version_valid() { + let json = r#"{"name":"@anthropic-ai/claude-code","version":"2.1.72","description":"Claude Code"}"#; + let result = parse_npm_cli_version(json).unwrap(); + assert_eq!(result, "2.1.72"); + } + + #[test] + fn test_parse_npm_cli_version_missing_field() { + let json = r#"{"name":"@anthropic-ai/claude-code","description":"no version here"}"#; + let result = parse_npm_cli_version(json); + assert!(result.is_err()); + } + + #[test] + fn test_parse_npm_cli_version_invalid_json() { + let result = parse_npm_cli_version("not json at all"); + assert!(result.is_err()); + } + + #[test] + fn test_parse_npm_cli_version_non_string_version() { + let json = r#"{"version":123}"#; + let result = parse_npm_cli_version(json); + assert!(result.is_err()); + } + // ==================== SavedFileInfo struct tests ==================== #[test] @@ -3232,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/config.rs b/src-tauri/src/config.rs index dedae81..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)] @@ -34,6 +36,21 @@ pub struct ClaudeStartOptions { #[serde(default)] pub max_output_tokens: Option, + + #[serde(default)] + pub disable_cron: bool, + + #[serde(default = "default_include_git_instructions")] + pub include_git_instructions: bool, + + #[serde(default = "default_enable_claudeai_mcp_servers")] + pub enable_claudeai_mcp_servers: bool, + + #[serde(default)] + pub auto_memory_directory: Option, + + #[serde(default)] + pub model_overrides: Option>, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -168,6 +185,21 @@ pub struct HikariConfig { #[serde(default)] pub task_loop_include_summary: bool, + + #[serde(default)] + pub disable_cron: bool, + + #[serde(default = "default_include_git_instructions")] + pub include_git_instructions: bool, + + #[serde(default = "default_enable_claudeai_mcp_servers")] + pub enable_claudeai_mcp_servers: bool, + + #[serde(default)] + pub auto_memory_directory: Option, + + #[serde(default)] + pub model_overrides: Option>, } impl Default for HikariConfig { @@ -214,6 +246,11 @@ impl Default for HikariConfig { task_loop_auto_commit: false, task_loop_commit_prefix: "feat".to_string(), task_loop_include_summary: false, + disable_cron: false, + include_git_instructions: true, + enable_claudeai_mcp_servers: true, + auto_memory_directory: None, + model_overrides: None, } } } @@ -258,6 +295,14 @@ fn default_task_loop_commit_prefix() -> String { "feat".to_string() } +fn default_include_git_instructions() -> bool { + true +} + +fn default_enable_claudeai_mcp_servers() -> bool { + true +} + #[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)] #[serde(rename_all = "lowercase")] pub enum BudgetAction { @@ -352,6 +397,11 @@ 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); + 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] @@ -398,6 +448,14 @@ mod tests { task_loop_auto_commit: true, task_loop_commit_prefix: "fix".to_string(), task_loop_include_summary: true, + disable_cron: true, + 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(); @@ -415,6 +473,19 @@ 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); + assert!(!deserialized.include_git_instructions); + assert!(!deserialized.enable_claudeai_mcp_servers); + assert_eq!( + 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/lib.rs b/src-tauri/src/lib.rs index 9710b77..0e1ec59 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -134,6 +134,7 @@ pub fn run() { list_skills, check_for_updates, fetch_changelog, + check_cli_latest_version, save_temp_file, register_temp_file, get_temp_files, @@ -222,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-tauri/src/types.rs b/src-tauri/src/types.rs index 8fa1a9e..f73cb4b 100644 --- a/src-tauri/src/types.rs +++ b/src-tauri/src/types.rs @@ -292,6 +292,26 @@ pub struct AgentStartEvent { pub conversation_id: Option, #[serde(skip_serializing_if = "Option::is_none")] pub parent_tool_use_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub model: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct WorktreeInfo { + pub name: String, + pub path: String, + pub branch: String, + pub original_repo_directory: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct WorktreeEvent { + #[serde(skip_serializing_if = "Option::is_none")] + pub conversation_id: Option, + /// "create" or "remove" + pub event_type: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub worktree: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] diff --git a/src-tauri/src/wsl_bridge.rs b/src-tauri/src/wsl_bridge.rs index 945a11d..1d720c8 100644 --- a/src-tauri/src/wsl_bridge.rs +++ b/src-tauri/src/wsl_bridge.rs @@ -17,7 +17,7 @@ use crate::types::{ AgentEndEvent, AgentStartEvent, CharacterState, ClaudeMessage, ConnectionEvent, ConnectionStatus, ContentBlock, MessageCost, OutputEvent, PermissionPromptEvent, PermissionPromptEventItem, QuestionOption, SessionEvent, StateChangeEvent, TodoItem, - TodoUpdateEvent, UserQuestionEvent, WorkingDirectoryEvent, + TodoUpdateEvent, UserQuestionEvent, WorkingDirectoryEvent, WorktreeEvent, WorktreeInfo, }; use parking_lot::RwLock; use std::cell::RefCell; @@ -291,6 +291,42 @@ impl WslBridge { cmd.arg("--worktree"); } + // 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]); + } + } + } + cmd.current_dir(working_dir); // Set API key as environment variable if specified @@ -310,6 +346,21 @@ 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"); + } + + // Disable built-in git instructions if requested + if !options.include_git_instructions { + cmd.env("CLAUDE_CODE_DISABLE_GIT_INSTRUCTIONS", "1"); + } + + // Opt out of claude.ai MCP servers if requested + if !options.enable_claudeai_mcp_servers { + cmd.env("ENABLE_CLAUDEAI_MCP_SERVERS", "false"); + } + cmd } else { // Running on Windows - use wsl with bash login shell to ensure PATH is loaded @@ -362,6 +413,21 @@ 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 "); + } + + // Disable built-in git instructions if requested + if !options.include_git_instructions { + claude_cmd.push_str("CLAUDE_CODE_DISABLE_GIT_INSTRUCTIONS=1 "); + } + + // Opt out of claude.ai MCP servers if requested + if !options.enable_claudeai_mcp_servers { + claude_cmd.push_str("ENABLE_CLAUDEAI_MCP_SERVERS=false "); + } + claude_cmd.push_str( "claude --output-format stream-json --input-format stream-json --verbose", ); @@ -404,6 +470,43 @@ impl WslBridge { claude_cmd.push_str(" --worktree"); } + // 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)); + } + } + } + // Use bash -lc to load login profile (ensures PATH includes claude) cmd.args(["-e", "bash", "-lc", &claude_cmd]); @@ -903,13 +1006,14 @@ fn handle_stderr( tracing::debug!("Parsed SubagentStart hook: agent_id={}, parent={:?}", agent_data.agent_id, agent_data.parent_tool_use_id); - // Emit an agent-update event with the agent_id + // Emit an agent-update event with the agent_id and agent_type let _ = app.emit( "claude:agent-update", serde_json::json!({ "conversationId": conversation_id.clone(), "toolUseId": agent_data.parent_tool_use_id, "agentId": agent_data.agent_id, + "agentType": agent_data.agent_type, }), ); } @@ -945,9 +1049,10 @@ fn handle_stderr( } // Hook events are informational — emit with distinct types instead of error - let line_type = if line.contains("[WorktreeCreate Hook]") - || line.contains("[WorktreeRemove Hook]") - { + let is_worktree_create = line.contains("[WorktreeCreate Hook]"); + let is_worktree_remove = line.contains("[WorktreeRemove Hook]"); + + let line_type = if is_worktree_create || is_worktree_remove { "worktree" } else if line.contains("[ConfigChange Hook]") { "config-change" @@ -955,17 +1060,56 @@ fn handle_stderr( "error" }; - let _ = app.emit( - "claude:output", - OutputEvent { - line_type: line_type.to_string(), - content: line, - tool_name: None, - conversation_id: conversation_id.clone(), - cost: None, - parent_tool_use_id: None, - }, - ); + // For worktree hooks, parse structured data and emit a dedicated event + if is_worktree_create || is_worktree_remove { + let worktree_info = parse_worktree_hook(&line); + let event_type = if is_worktree_create { "create" } else { "remove" }; + let friendly_content = if let Some(ref info) = worktree_info { + if is_worktree_create { + format!( + "Worktree created: {} (branch: {}) at {}", + info.name, info.branch, info.path + ) + } else { + format!("Worktree removed: {} (branch: {})", info.name, info.branch) + } + } else { + line.clone() + }; + + let _ = app.emit( + "claude:worktree", + WorktreeEvent { + conversation_id: conversation_id.clone(), + event_type: event_type.to_string(), + worktree: worktree_info, + }, + ); + + let _ = app.emit( + "claude:output", + OutputEvent { + line_type: "worktree".to_string(), + content: friendly_content, + tool_name: None, + conversation_id: conversation_id.clone(), + cost: None, + parent_tool_use_id: None, + }, + ); + } else { + let _ = app.emit( + "claude:output", + OutputEvent { + line_type: line_type.to_string(), + content: line, + tool_name: None, + conversation_id: conversation_id.clone(), + cost: None, + parent_tool_use_id: None, + }, + ); + } } Err(_) => break, _ => {} @@ -976,11 +1120,35 @@ fn handle_stderr( #[derive(Debug)] struct SubagentStartData { agent_id: String, + agent_type: Option, parent_tool_use_id: Option, } +fn parse_worktree_hook(line: &str) -> Option { + // Parse: [WorktreeCreate/Remove Hook] name=worktree-abc, path=/tmp/worktrees/worktree-abc, + // branch=feat/my-feature, original_repo_directory=/home/naomi/code/project, session_id=xxx + + let extract = |key: &str| -> Option { + let after_key = line.split(&format!("{}=", key)).nth(1)?; + let value = after_key.split(',').next()?.trim().to_string(); + if value.is_empty() { None } else { Some(value) } + }; + + let name = extract("name")?; + let path = extract("path")?; + let branch = extract("branch")?; + let original_repo_directory = extract("original_repo_directory")?; + + Some(WorktreeInfo { + name, + path, + branch, + original_repo_directory, + }) +} + fn parse_subagent_start_hook(line: &str) -> Option { - // Parse: [SubagentStart Hook] agent_id=agent-xxx, parent_tool_use_id=Some("toolu_xxx"), ... + // Parse: [SubagentStart Hook] agent_id=agent-xxx, agent_type=general-purpose, parent_tool_use_id=Some("toolu_xxx"), ... // Extract agent_id let agent_id = line @@ -991,6 +1159,15 @@ fn parse_subagent_start_hook(line: &str) -> Option { .trim() .to_string(); + // Extract agent_type if present (added in CLI v2.1.69) + let agent_type = line + .split("agent_type=") + .nth(1) + .and_then(|s| { + let value = s.split(',').next()?.trim(); + if value.is_empty() { None } else { Some(value.to_string()) } + }); + // Extract parent_tool_use_id if present let parent_tool_use_id = if line.contains("parent_tool_use_id=Some") { line.split("parent_tool_use_id=Some(\"") @@ -1004,6 +1181,7 @@ fn parse_subagent_start_hook(line: &str) -> Option { Some(SubagentStartData { agent_id, + agent_type, parent_tool_use_id, }) } @@ -1128,6 +1306,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; @@ -1256,27 +1440,36 @@ fn process_json_line( } } - // Emit agent-start event for Task tool invocations - // Support both "Task" and "Task(agent_type)" syntax (CLI v2.1.33+) - if name == "Task" || name.starts_with("Task(") { + // Emit agent-start event for Task/Agent tool invocations + // Support "Task"/"Task(agent_type)" (CLI v2.1.33+) and + // "Agent"/"Agent(agent_type)" (CLI v2.1.69+ rename) + if name == "Task" || name.starts_with("Task(") + || name == "Agent" || name.starts_with("Agent(") + { let description = input .get("description") + .or_else(|| input.get("prompt")) .and_then(|v| v.as_str()) .unwrap_or("Subagent") .to_string(); 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") + .and_then(|v| v.as_str()) + .filter(|s| !s.is_empty()) + .map(|s| s.to_string()); let now = SystemTime::now() .duration_since(UNIX_EPOCH) .unwrap_or_default() .as_millis() as u64; tracing::debug!( - "Emitting agent-start: id={}, desc={}, type={}, parent={:?}", - id, description, subagent_type, parent_tool_use_id + "Emitting agent-start: id={}, desc={}, type={}, model={:?}, parent={:?}", + id, description, subagent_type, model, parent_tool_use_id ); let _ = app.emit( @@ -1286,6 +1479,7 @@ fn process_json_line( agent_id: None, // Will be updated when SubagentStart hook is received description, subagent_type, + model, started_at: now, conversation_id: conversation_id.clone(), parent_tool_use_id: parent_tool_use_id.clone(), @@ -1650,7 +1844,10 @@ fn process_json_line( // Helper function to check if a tool is a system tool that should never require permission let is_system_tool = |tool_name: &str| -> bool { - matches!(tool_name, "ExitPlanMode" | "EnterPlanMode") + matches!( + tool_name, + "ExitPlanMode" | "EnterPlanMode" | "EnterWorktree" | "ExitWorktree" + ) }; for denial in denials { @@ -1924,7 +2121,9 @@ fn get_tool_state(tool_name: &str) -> CharacterState { CharacterState::Coding } else if tool_name.starts_with("mcp__") { CharacterState::Mcp - } else if tool_name == "Task" || tool_name.starts_with("Task(") { + } else if tool_name == "Task" || tool_name.starts_with("Task(") + || tool_name == "Agent" || tool_name.starts_with("Agent(") + { CharacterState::Thinking } else { CharacterState::Typing @@ -2025,6 +2224,42 @@ fn format_tool_description(name: &str, input: &serde_json::Value) -> String { "Running command...".to_string() } } + "EnterWorktree" => { + if let Some(path) = input.get("path").and_then(|v| v.as_str()) { + format!("Entering worktree: {}", path) + } else { + "Entering worktree session...".to_string() + } + } + "ExitWorktree" => "Exiting worktree session...".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(), + name if name == "Agent" || name.starts_with("Agent(") => { + let task = input + .get("description") + .or_else(|| input.get("prompt")) + .and_then(|v| v.as_str()); + let agent_type = input.get("subagent_type").and_then(|v| v.as_str()); + match (task, agent_type) { + (Some(t), Some(a)) => format!("Launching {} agent: {}", a, t), + (Some(t), None) => format!("Launching agent: {}", t), + (None, Some(a)) => format!("Launching {} agent...", a), + (None, None) => "Launching agent...".to_string(), + } + } _ => format!("Using tool: {}", name), } } @@ -2118,6 +2353,24 @@ mod tests { )); } + #[test] + fn test_get_tool_state_agent() { + // CLI v2.1.69+ renamed Task to Agent + assert!(matches!(get_tool_state("Agent"), CharacterState::Thinking)); + assert!(matches!( + get_tool_state("Agent(Explore)"), + CharacterState::Thinking + )); + assert!(matches!( + get_tool_state("Agent(Plan)"), + CharacterState::Thinking + )); + assert!(matches!( + get_tool_state("Agent(general-purpose)"), + CharacterState::Thinking + )); + } + #[test] fn test_get_tool_state_unknown() { assert!(matches!( @@ -2191,6 +2444,97 @@ mod tests { assert_eq!(desc, "Using tool: CustomTool"); } + #[test] + fn test_format_tool_description_enter_worktree() { + let input = serde_json::json!({"path": "/home/naomi/worktrees/feature-branch"}); + let desc = format_tool_description("EnterWorktree", &input); + assert_eq!(desc, "Entering worktree: /home/naomi/worktrees/feature-branch"); + } + + #[test] + fn test_format_tool_description_enter_worktree_no_path() { + let input = serde_json::json!({}); + let desc = format_tool_description("EnterWorktree", &input); + assert_eq!(desc, "Entering worktree session..."); + } + + #[test] + fn test_format_tool_description_exit_worktree() { + let input = serde_json::json!({}); + let desc = format_tool_description("ExitWorktree", &input); + assert_eq!(desc, "Exiting worktree session..."); + } + + #[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_agent_with_type_and_description() { + let input = serde_json::json!({"subagent_type": "general-purpose", "description": "Fetch user info"}); + let desc = format_tool_description("Agent", &input); + assert_eq!(desc, "Launching general-purpose agent: Fetch user info"); + } + + #[test] + fn test_format_tool_description_agent_with_prompt() { + let input = serde_json::json!({"subagent_type": "Explore", "prompt": "Look at the repo"}); + let desc = format_tool_description("Agent", &input); + assert_eq!(desc, "Launching Explore agent: Look at the repo"); + } + + #[test] + fn test_format_tool_description_agent_no_description() { + let input = serde_json::json!({"subagent_type": "Plan"}); + let desc = format_tool_description("Agent", &input); + assert_eq!(desc, "Launching Plan agent..."); + } + + #[test] + fn test_format_tool_description_agent_no_fields() { + let input = serde_json::json!({}); + let desc = format_tool_description("Agent", &input); + assert_eq!(desc, "Launching agent..."); + } + + #[test] + fn test_format_tool_description_agent_with_parenthesized_type() { + let input = serde_json::json!({"description": "Check files"}); + let desc = format_tool_description("Agent(Explore)", &input); + assert_eq!(desc, "Launching agent: Check files"); + } + #[test] fn test_format_tool_description_memory_read() { let input = @@ -2317,6 +2661,7 @@ mod tests { assert!(result.is_some()); let data = result.unwrap(); assert_eq!(data.agent_id, "agent-abc123"); + assert_eq!(data.agent_type, None); assert_eq!(data.parent_tool_use_id, Some("toolu_01XYZ789".to_string())); } @@ -2328,6 +2673,7 @@ mod tests { assert!(result.is_some()); let data = result.unwrap(); assert_eq!(data.agent_id, "agent-xyz789"); + assert_eq!(data.agent_type, None); assert_eq!(data.parent_tool_use_id, None); } @@ -2347,9 +2693,34 @@ mod tests { assert!(result.is_some()); let data = result.unwrap(); assert_eq!(data.agent_id, "agent-test"); + assert_eq!(data.agent_type, None); assert_eq!(data.parent_tool_use_id, Some("toolu_test".to_string())); } + #[test] + fn test_parse_subagent_start_hook_with_agent_type() { + let line = r#"[SubagentStart Hook] agent_id=agent-abc123, agent_type=general-purpose, parent_tool_use_id=Some("toolu_01XYZ789"), session_id=123"#; + let result = parse_subagent_start_hook(line); + + assert!(result.is_some()); + let data = result.unwrap(); + assert_eq!(data.agent_id, "agent-abc123"); + assert_eq!(data.agent_type, Some("general-purpose".to_string())); + assert_eq!(data.parent_tool_use_id, Some("toolu_01XYZ789".to_string())); + } + + #[test] + fn test_parse_subagent_start_hook_with_explore_type() { + let line = r#"[SubagentStart Hook] agent_id=agent-xyz789, agent_type=Explore, parent_tool_use_id=None, session_id=456"#; + let result = parse_subagent_start_hook(line); + + assert!(result.is_some()); + let data = result.unwrap(); + assert_eq!(data.agent_id, "agent-xyz789"); + assert_eq!(data.agent_type, Some("Explore".to_string())); + assert_eq!(data.parent_tool_use_id, None); + } + // SubagentStop hook parsing tests #[test] fn test_parse_subagent_stop_hook_with_parent() { @@ -2655,4 +3026,169 @@ 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"); + } + + #[test] + fn test_parse_worktree_hook_create_with_all_fields() { + let line = r#"[WorktreeCreate Hook] name=worktree-abc, path=/tmp/worktrees/worktree-abc, branch=feat/my-feature, original_repo_directory=/home/naomi/code/project, session_id=123"#; + let result = parse_worktree_hook(line); + + assert!(result.is_some()); + let info = result.unwrap(); + assert_eq!(info.name, "worktree-abc"); + assert_eq!(info.path, "/tmp/worktrees/worktree-abc"); + assert_eq!(info.branch, "feat/my-feature"); + assert_eq!(info.original_repo_directory, "/home/naomi/code/project"); + } + + #[test] + fn test_parse_worktree_hook_remove_with_all_fields() { + let line = r#"[WorktreeRemove Hook] name=worktree-xyz, path=/tmp/worktrees/worktree-xyz, branch=fix/bug-123, original_repo_directory=/home/naomi/code/other, session_id=456"#; + let result = parse_worktree_hook(line); + + assert!(result.is_some()); + let info = result.unwrap(); + assert_eq!(info.name, "worktree-xyz"); + assert_eq!(info.branch, "fix/bug-123"); + assert_eq!(info.original_repo_directory, "/home/naomi/code/other"); + } + + #[test] + fn test_parse_worktree_hook_missing_field_returns_none() { + // Missing branch field — should return None + let line = r#"[WorktreeCreate Hook] name=worktree-abc, path=/tmp/worktrees/worktree-abc, original_repo_directory=/home/naomi/code/project, session_id=123"#; + let result = parse_worktree_hook(line); + assert!(result.is_none()); + } + + #[test] + fn test_parse_worktree_hook_invalid_returns_none() { + let line = "[WorktreeCreate Hook] no structured data here"; + 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"); + } + + /// 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 af08a7b..1473e39 100644 --- a/src/lib/commands/slashCommands.test.ts +++ b/src/lib/commands/slashCommands.test.ts @@ -65,6 +65,10 @@ vi.mock("$lib/stores/config", () => ({ use_worktree: false, disable_1m_context: false, max_output_tokens: null, + include_git_instructions: true, + enable_claudeai_mcp_servers: true, + auto_memory_directory: null, + model_overrides: null, }), }, })); @@ -87,10 +91,15 @@ describe("slashCommands", () => { expect(commandNames).toContain("search"); expect(commandNames).toContain("summarise"); expect(commandNames).toContain("skill"); + expect(commandNames).toContain("simplify"); + expect(commandNames).toContain("loop"); + expect(commandNames).toContain("batch"); + expect(commandNames).toContain("memory"); + expect(commandNames).toContain("context"); }); - it("has 7 commands total", () => { - expect(slashCommands.length).toBe(7); + it("has 12 commands total", () => { + expect(slashCommands.length).toBe(12); }); it("each command has required properties", () => { @@ -160,6 +169,52 @@ describe("slashCommands", () => { expect(skillCmd!.description).toBe("Invoke a Claude Code skill from ~/.claude/skills/"); expect(skillCmd!.usage).toBe("/skill [name] [data]"); }); + + it("simplify command has correct metadata and source", () => { + const simplifyCmd = slashCommands.find((cmd) => cmd.name === "simplify"); + expect(simplifyCmd).toBeDefined(); + expect(simplifyCmd!.source).toBe("cli"); + expect(simplifyCmd!.usage).toBe("/simplify"); + }); + + it("loop command has correct metadata and source", () => { + const loopCmd = slashCommands.find((cmd) => cmd.name === "loop"); + expect(loopCmd).toBeDefined(); + expect(loopCmd!.source).toBe("cli"); + expect(loopCmd!.usage).toBe("/loop [interval] [command]"); + }); + + it("batch command has correct metadata and source", () => { + const batchCmd = slashCommands.find((cmd) => cmd.name === "batch"); + expect(batchCmd).toBeDefined(); + expect(batchCmd!.source).toBe("cli"); + 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) => { + const cmd = slashCommands.find((c) => c.name === name); + expect(cmd).toBeDefined(); + expect(cmd!.source).toBeUndefined(); + }); + }); + + it("cli commands have source set to 'cli'", () => { + const cliCommandNames = ["simplify", "loop", "batch", "memory", "context"]; + cliCommandNames.forEach((name) => { + const cmd = slashCommands.find((c) => c.name === name); + expect(cmd).toBeDefined(); + expect(cmd!.source).toBe("cli"); + }); + }); }); describe("parseSlashCommand", () => { @@ -342,6 +397,19 @@ describe("slashCommands", () => { expect(names).toContain("search"); expect(names).toContain("summarise"); expect(names).toContain("skill"); + expect(names).toContain("simplify"); + }); + + it("returns /loop for /l prefix", () => { + const result = getMatchingCommands("/l"); + const names = result.map((cmd) => cmd.name); + expect(names).toContain("loop"); + }); + + it("returns /batch for /b prefix", () => { + const result = getMatchingCommands("/b"); + const names = result.map((cmd) => cmd.name); + expect(names).toContain("batch"); }); it("is case insensitive", () => { @@ -412,6 +480,19 @@ describe("slashCommands", () => { expect(testCommand.description).toBe("A test command"); expect(testCommand.usage).toBe("/test [arg]"); expect(typeof testCommand.execute).toBe("function"); + expect(testCommand.source).toBeUndefined(); + }); + + it("can create a cli-sourced slash command object", () => { + const cliCommand: SlashCommand = { + name: "cli-test", + description: "A CLI command", + usage: "/cli-test", + source: "cli", + execute: vi.fn(), + }; + + expect(cliCommand.source).toBe("cli"); }); it("execute can be async function", () => { @@ -715,6 +796,125 @@ describe("slashCommands", () => { }); }); + describe("/simplify execute", () => { + it("shows error when no active conversation", async () => { + getMock.mockReturnValue(null); + const simplifyCmd = slashCommands.find((cmd) => cmd.name === "simplify")!; + await simplifyCmd.execute(""); + expect(claudeStore.addLine).toHaveBeenCalledWith("error", "No active conversation"); + }); + + it("sends /simplify prompt to Claude when there is an active conversation", async () => { + getMock.mockReturnValue("conv-123"); + invokeMock.mockResolvedValue(undefined); + const simplifyCmd = slashCommands.find((cmd) => cmd.name === "simplify")!; + await simplifyCmd.execute(""); + expect(invokeMock).toHaveBeenCalledWith("send_prompt", { + conversationId: "conv-123", + message: "/simplify", + }); + }); + }); + + describe("/loop execute", () => { + it("shows error when no active conversation", async () => { + getMock.mockReturnValue(null); + const loopCmd = slashCommands.find((cmd) => cmd.name === "loop")!; + await loopCmd.execute("5m /help"); + expect(claudeStore.addLine).toHaveBeenCalledWith("error", "No active conversation"); + }); + + it("sends /loop with args when args are provided", async () => { + getMock.mockReturnValue("conv-123"); + invokeMock.mockResolvedValue(undefined); + const loopCmd = slashCommands.find((cmd) => cmd.name === "loop")!; + await loopCmd.execute("5m /help"); + expect(invokeMock).toHaveBeenCalledWith("send_prompt", { + conversationId: "conv-123", + message: "/loop 5m /help", + }); + }); + + it("sends /loop without args when no args provided", async () => { + getMock.mockReturnValue("conv-123"); + invokeMock.mockResolvedValue(undefined); + const loopCmd = slashCommands.find((cmd) => cmd.name === "loop")!; + await loopCmd.execute(""); + expect(invokeMock).toHaveBeenCalledWith("send_prompt", { + conversationId: "conv-123", + message: "/loop", + }); + }); + }); + + describe("/batch execute", () => { + it("shows error when no active conversation", async () => { + getMock.mockReturnValue(null); + const batchCmd = slashCommands.find((cmd) => cmd.name === "batch")!; + await batchCmd.execute("task1, task2"); + expect(claudeStore.addLine).toHaveBeenCalledWith("error", "No active conversation"); + }); + + it("sends /batch with args when args are provided", async () => { + getMock.mockReturnValue("conv-123"); + invokeMock.mockResolvedValue(undefined); + const batchCmd = slashCommands.find((cmd) => cmd.name === "batch")!; + await batchCmd.execute("task1, task2"); + expect(invokeMock).toHaveBeenCalledWith("send_prompt", { + conversationId: "conv-123", + message: "/batch task1, task2", + }); + }); + + it("sends /batch without args when no args provided", async () => { + getMock.mockReturnValue("conv-123"); + invokeMock.mockResolvedValue(undefined); + const batchCmd = slashCommands.find((cmd) => cmd.name === "batch")!; + await batchCmd.execute(""); + expect(invokeMock).toHaveBeenCalledWith("send_prompt", { + conversationId: "conv-123", + message: "/batch", + }); + }); + }); + + 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); + 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 3e97bb2..78f1a10 100644 --- a/src/lib/commands/slashCommands.ts +++ b/src/lib/commands/slashCommands.ts @@ -6,11 +6,14 @@ 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; description: string; usage: string; + /** "cli" = built into Claude Code CLI; omitted = Hikari app command */ + source?: "cli"; execute: (args: string) => Promise | void; } @@ -64,6 +67,10 @@ async function changeDirectory(path: string): Promise { use_worktree: config.use_worktree ?? false, disable_1m_context: config.disable_1m_context ?? false, 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, + model_overrides: config.model_overrides || null, }, }); @@ -141,6 +148,10 @@ async function startNewConversation(): Promise { use_worktree: config.use_worktree ?? false, disable_1m_context: config.disable_1m_context ?? false, 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, + model_overrides: config.model_overrides || null, }, }); @@ -231,6 +242,74 @@ export const slashCommands: SlashCommand[] = [ } }, }, + { + name: "simplify", + description: "Review changed code for reuse, quality, and efficiency (Claude Code built-in)", + usage: "/simplify", + source: "cli", + execute: async () => { + const conversationId = get(claudeStore.activeConversationId); + if (!conversationId) { + claudeStore.addLine("error", "No active conversation"); + return; + } + await invoke("send_prompt", { conversationId, message: "/simplify" }); + }, + }, + { + name: "loop", + description: "Run a prompt or slash command on a recurring interval (Claude Code built-in)", + usage: "/loop [interval] [command]", + source: "cli", + execute: async (args: string) => { + const conversationId = get(claudeStore.activeConversationId); + if (!conversationId) { + claudeStore.addLine("error", "No active conversation"); + return; + } + const message = args.trim() ? `/loop ${args.trim()}` : "/loop"; + await invoke("send_prompt", { conversationId, message }); + }, + }, + { + name: "batch", + description: "Process multiple tasks in a single Claude Code session (Claude Code built-in)", + usage: "/batch [tasks]", + source: "cli", + execute: async (args: string) => { + const conversationId = get(claudeStore.activeConversationId); + if (!conversationId) { + claudeStore.addLine("error", "No active conversation"); + return; + } + const message = args.trim() ? `/batch ${args.trim()}` : "/batch"; + await invoke("send_prompt", { conversationId, message }); + }, + }, + { + name: "memory", + description: "Open the memory browser panel to view and manage memory files", + usage: "/memory", + source: "cli", + execute: () => { + memoryBrowserStore.open(); + }, + }, + { + 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/", 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/AgentMonitorPanel.svelte b/src/lib/components/AgentMonitorPanel.svelte index 6efc412..db6eac6 100644 --- a/src/lib/components/AgentMonitorPanel.svelte +++ b/src/lib/components/AgentMonitorPanel.svelte @@ -282,8 +282,9 @@ class="px-1.5 py-0.5 text-[10px] rounded border {getStatusBadgeClass( agent.status )}" + title={agent.agentId ? `ID: ${agent.agentId}` : undefined} > - {getSubagentTypeLabel(agent.subagentType)} + {getSubagentTypeLabel(agent.agentType ?? agent.subagentType)} + + {#if agent.model} +

+ ✦ {agent.model} +

+ {/if} +
{#if agent.status === "running"} diff --git a/src/lib/components/CliVersion.svelte b/src/lib/components/CliVersion.svelte index 8b8fed5..2361492 100644 --- a/src/lib/components/CliVersion.svelte +++ b/src/lib/components/CliVersion.svelte @@ -2,9 +2,10 @@ import { invoke } from "@tauri-apps/api/core"; import { onMount } from "svelte"; - const SUPPORTED_CLI_VERSION = "2.1.53"; + const SUPPORTED_CLI_VERSION = "2.1.74"; let installedVersion = $state("Loading..."); + let latestNpmVersion = $state(null); function compareVersions(a: string, b: string): number { const aParts = a.split(".").map(Number); @@ -32,6 +33,15 @@ return "current"; }); + let updateAvailable = $derived.by(() => { + if (!latestNpmVersion || installedVersion === "Loading..." || installedVersion === "Unknown") { + return false; + } + const semverMatch = /(\d+\.\d+\.\d+)/.exec(installedVersion); + if (!semverMatch) return false; + return compareVersions(semverMatch[1], latestNpmVersion) < 0; + }); + async function fetchVersion() { try { const result = await invoke("get_claude_version"); @@ -42,13 +52,28 @@ } } + async function fetchLatestNpmVersion() { + try { + const result = await invoke("check_cli_latest_version"); + latestNpmVersion = result; + } catch (error) { + console.error("Failed to check latest CLI version:", error); + } + } + onMount(() => { fetchVersion(); + fetchLatestNpmVersion(); });
-
+
CLI {displayVersion} + {#if updateAvailable} + + + + + {/if}
@@ -135,6 +176,27 @@ color: var(--error-color, #f44336); } + .cli-version.update-available { + border-color: var(--warning-color, #ff9800); + color: var(--warning-color, #ff9800); + cursor: help; + } + + .update-icon { + flex-shrink: 0; + animation: pulse 2s ease-in-out infinite; + } + + @keyframes pulse { + 0%, + 100% { + opacity: 1; + } + 50% { + opacity: 0.5; + } + } + .terminal-icon { flex-shrink: 0; opacity: 0.7; diff --git a/src/lib/components/CliVersion.test.ts b/src/lib/components/CliVersion.test.ts index a16516f..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.53"; +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.53"); + expect(SUPPORTED_CLI_VERSION).toBe("2.1.74"); }); }); @@ -128,7 +128,55 @@ describe("compareVersions", () => { }); it("returns 0 for exactly the supported version", () => { - expect(compareVersions("2.1.53", SUPPORTED_CLI_VERSION)).toBe(0); + expect(compareVersions("2.1.74", SUPPORTED_CLI_VERSION)).toBe(0); }); }); }); + +// Mirrors the updateAvailable derived logic in CliVersion.svelte +function isUpdateAvailable(installedVersion: string, latestNpmVersion: string | null): boolean { + if (!latestNpmVersion || installedVersion === "Loading..." || installedVersion === "Unknown") { + return false; + } + const semverMatch = /(\d+\.\d+\.\d+)/.exec(installedVersion); + if (!semverMatch) return false; + return compareVersions(semverMatch[1], latestNpmVersion) < 0; +} + +describe("updateAvailable", () => { + it("returns false when latestNpmVersion is null", () => { + expect(isUpdateAvailable("2.1.70", null)).toBe(false); + }); + + it("returns false when installed is Loading...", () => { + expect(isUpdateAvailable("Loading...", "2.1.74")).toBe(false); + }); + + it("returns false when installed is Unknown", () => { + expect(isUpdateAvailable("Unknown", "2.1.74")).toBe(false); + }); + + it("returns false when installed equals latest", () => { + expect(isUpdateAvailable("2.1.74", "2.1.74")).toBe(false); + }); + + it("returns false when installed is ahead of latest", () => { + 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.74")).toBe(true); + }); + + it("returns true when installed has a lower minor version", () => { + 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.74")).toBe(true); + }); + + it("returns false for unparseable installed version", () => { + expect(isUpdateAvailable("not-a-version", "2.1.74")).toBe(false); + }); +}); diff --git a/src/lib/components/ConfigSidebar.svelte b/src/lib/components/ConfigSidebar.svelte index 8a9474b..e7b17d2 100644 --- a/src/lib/components/ConfigSidebar.svelte +++ b/src/lib/components/ConfigSidebar.svelte @@ -58,6 +58,11 @@ show_thinking_blocks: true, use_worktree: false, disable_1m_context: false, + disable_cron: false, + 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, @@ -78,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; @@ -107,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) => { @@ -137,11 +145,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 = [ @@ -197,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(); @@ -554,6 +569,38 @@

+ +
+ +

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

+
+ + +
+ +

+ When disabled, sets CLAUDE_CODE_DISABLE_GIT_INSTRUCTIONS=1 to + remove Claude's built-in commit and PR workflow guidance from its system prompt +

+
+
+ + +
+ + +

+ Custom directory for auto-memory storage. Passed via + --settings autoMemoryDirectory. Leave blank to use the + 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. +

+
@@ -629,6 +717,22 @@ class="w-full px-3 py-2 bg-[var(--bg-primary)] border border-[var(--border-color)] rounded-lg text-[var(--text-primary)] font-mono text-sm focus:outline-none focus:border-[var(--accent-primary)] resize-none" >
+ + +
+ +

+ When disabled, sets ENABLE_CLAUDEAI_MCP_SERVERS=false to prevent + Claude Code from connecting to MCP servers configured in Claude.ai. +

+
diff --git a/src/lib/components/InputBar.svelte b/src/lib/components/InputBar.svelte index b682753..fbb799f 100644 --- a/src/lib/components/InputBar.svelte +++ b/src/lib/components/InputBar.svelte @@ -402,6 +402,10 @@ User: ${formattedMessage}`; allowed_tools: allAllowedTools, 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, + auto_memory_directory: config.auto_memory_directory || null, + model_overrides: config.model_overrides || null, }, }); diff --git a/src/lib/components/Markdown.svelte b/src/lib/components/Markdown.svelte index 6786210..0117bfe 100644 --- a/src/lib/components/Markdown.svelte +++ b/src/lib/components/Markdown.svelte @@ -3,7 +3,9 @@ import hljs from "highlight.js"; import { onMount } from "svelte"; 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"; interface Props { content: string; @@ -113,7 +115,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 +143,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 invoke("open_binary_file", { path: filePath }); + return; + } + + if (anchor.href) { + void openUrl(anchor.href); } } @@ -453,4 +465,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/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"; 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/StatusBar.svelte b/src/lib/components/StatusBar.svelte index 77485ea..9b69a63 100644 --- a/src/lib/components/StatusBar.svelte +++ b/src/lib/components/StatusBar.svelte @@ -29,6 +29,7 @@ let connectionStatus: ConnectionStatus = $state("disconnected"); let workingDirectory = $state(""); + let worktreeInfo: import("$lib/types/worktree").WorktreeInfo | null = $state(null); let selectedDirectory = $state("/home/naomi"); let isConnecting = $state(false); let grantedToolsList: string[] = $state([]); @@ -87,6 +88,11 @@ 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, + auto_memory_directory: null, + model_overrides: null, }); let streamerModeActive = $state(false); @@ -115,6 +121,10 @@ workingDirectory = dir; }); + claudeStore.worktreeInfo.subscribe((info) => { + worktreeInfo = info; + }); + claudeStore.grantedTools.subscribe((tools) => { grantedToolsList = Array.from(tools); }); @@ -163,6 +173,10 @@ use_worktree: currentConfig.use_worktree ?? false, disable_1m_context: currentConfig.disable_1m_context ?? false, 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, + model_overrides: currentConfig.model_overrides || null, }, }); @@ -320,6 +334,10 @@ use_worktree: currentConfig.use_worktree ?? false, disable_1m_context: currentConfig.disable_1m_context ?? false, 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, + model_overrides: currentConfig.model_overrides || null, }, }); @@ -392,6 +410,22 @@ {workingDirectory}
{/if} + {#if worktreeInfo} +
+ + + + {worktreeInfo.branch} +
+ {/if} {:else}
cwd: diff --git a/src/lib/components/TaskLoopPanel.svelte b/src/lib/components/TaskLoopPanel.svelte index 29bb6e0..cacd72a 100644 --- a/src/lib/components/TaskLoopPanel.svelte +++ b/src/lib/components/TaskLoopPanel.svelte @@ -218,6 +218,10 @@ use_worktree: cfg.use_worktree ?? false, 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, + auto_memory_directory: cfg.auto_memory_directory || null, + model_overrides: cfg.model_overrides || null, }, }); } catch (error) { diff --git a/src/lib/components/ToastContainer.svelte b/src/lib/components/ToastContainer.svelte new file mode 100644 index 0000000..6af450e --- /dev/null +++ b/src/lib/components/ToastContainer.svelte @@ -0,0 +1,199 @@ + + +
+ {#each $toasts as toast (toast.id)} +
+ {#if toast.kind === "info"} + +
+ {toast.icon} + {toast.message} + +
+ {:else if toast.kind === "achievement"} + {@const rarity = getAchievementRarity(toast.achievement.id)} + {@const colour = getRarityColour(rarity)} + +
+ +
+ + +
+ + +
+ +
+
{toast.achievement.icon}
+
+
+ ✨ +
+
+ ✨ +
+
+ + +
+

+ Achievement Unlocked! +

+

+ {toast.achievement.name} +

+

+ {toast.achievement.description} +

+ + +
+ + {rarity} + +
+
+
+ + +
+ {#each Array.from({ length: 10 }, (_, i) => i) as confettiIndex (confettiIndex)} +
+ {/each} +
+
+
+ {:else if toast.kind === "update"} + +
+
+
🎉
+
+

Update Available!

+ +

+ Current version: {toast.currentVersion} +

+
+ +
+
+ {/if} +
+ {/each} +
+ + 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/components/UserQuestionModal.svelte b/src/lib/components/UserQuestionModal.svelte index f26b3da..bcaf500 100644 --- a/src/lib/components/UserQuestionModal.svelte +++ b/src/lib/components/UserQuestionModal.svelte @@ -108,6 +108,10 @@ allowed_tools: grantedToolsList, 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, + auto_memory_directory: config.auto_memory_directory || null, + model_overrides: config.model_overrides || null, }, }); diff --git a/src/lib/stores/agents.test.ts b/src/lib/stores/agents.test.ts index 5806c9e..e6afa9e 100644 --- a/src/lib/stores/agents.test.ts +++ b/src/lib/stores/agents.test.ts @@ -43,6 +43,22 @@ describe("agents store", () => { expect(agents[0]).toMatchObject(agent); }); + it("preserves model field when provided", () => { + const agent = createMockAgent({ model: "claude-opus-4-6" }); + agentStore.addAgent(conversationId, agent); + + const agents = get(getAgentsForConversation(conversationId)); + expect(agents[0].model).toBe("claude-opus-4-6"); + }); + + it("leaves model undefined when not provided", () => { + const agent = createMockAgent(); + agentStore.addAgent(conversationId, agent); + + const agents = get(getAgentsForConversation(conversationId)); + expect(agents[0].model).toBeUndefined(); + }); + it("assigns a character name and avatar to added agents", () => { const agent = createMockAgent(); agentStore.addAgent(conversationId, agent); @@ -121,6 +137,28 @@ describe("agents store", () => { const agents = get(getAgentsForConversation(conversationId)); expect(agents[0].agentId).toBeUndefined(); }); + + it("updates agentType when provided alongside agentId", () => { + const agent = createMockAgent({ agentId: undefined }); + agentStore.addAgent(conversationId, agent); + + agentStore.updateAgentId(conversationId, agent.toolUseId, "agent-abc123", "general-purpose"); + + const agents = get(getAgentsForConversation(conversationId)); + expect(agents[0].agentId).toBe("agent-abc123"); + expect(agents[0].agentType).toBe("general-purpose"); + }); + + it("does not set agentType when not provided", () => { + const agent = createMockAgent({ agentId: undefined }); + agentStore.addAgent(conversationId, agent); + + agentStore.updateAgentId(conversationId, agent.toolUseId, "agent-abc123"); + + const agents = get(getAgentsForConversation(conversationId)); + expect(agents[0].agentId).toBe("agent-abc123"); + expect(agents[0].agentType).toBeUndefined(); + }); }); describe("endAgent", () => { diff --git a/src/lib/stores/agents.ts b/src/lib/stores/agents.ts index 4ea5251..abdd57e 100644 --- a/src/lib/stores/agents.ts +++ b/src/lib/stores/agents.ts @@ -24,7 +24,7 @@ function createAgentStore() { }); }, - updateAgentId(conversationId: string, toolUseId: string, agentId: string) { + updateAgentId(conversationId: string, toolUseId: string, agentId: string, agentType?: string) { agentsByConversation.update((state) => { const agents = state[conversationId]; if (!agents) return state; @@ -36,6 +36,7 @@ function createAgentStore() { updated[agentIndex] = { ...updated[agentIndex], agentId, + ...(agentType !== undefined ? { agentType } : {}), }; return { diff --git a/src/lib/stores/claude.ts b/src/lib/stores/claude.ts index b163f73..3b3cdcb 100644 --- a/src/lib/stores/claude.ts +++ b/src/lib/stores/claude.ts @@ -26,6 +26,7 @@ export const claudeStore = { grantedTools: conversationsStore.grantedTools, pendingRetryMessage: conversationsStore.pendingRetryMessage, attachments: conversationsStore.attachments, + worktreeInfo: conversationsStore.worktreeInfo, // New conversation-aware subscriptions conversations: conversationsStore.conversations, @@ -70,6 +71,9 @@ export const claudeStore = { // Draft text (per-tab input persistence) setDraftText: conversationsStore.setDraftText, + // Worktree info (per-conversation) + setWorktreeInfo: conversationsStore.setWorktreeInfo, + // Conversation management createConversation: conversationsStore.createConversation, deleteConversation: conversationsStore.deleteConversation, diff --git a/src/lib/stores/config.test.ts b/src/lib/stores/config.test.ts index 86744ce..018c240 100644 --- a/src/lib/stores/config.test.ts +++ b/src/lib/stores/config.test.ts @@ -220,6 +220,11 @@ 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, + auto_memory_directory: null, + model_overrides: null, }; expect(config.model).toBe("claude-sonnet-4"); @@ -279,6 +284,11 @@ 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, + auto_memory_directory: null, + model_overrides: null, }; expect(config.model).toBeNull(); @@ -893,6 +903,11 @@ 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, + 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 0383d7c..6977cdd 100644 --- a/src/lib/stores/config.ts +++ b/src/lib/stores/config.ts @@ -81,6 +81,16 @@ 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; + // 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 = { @@ -134,6 +144,11 @@ 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, + auto_memory_directory: null, + model_overrides: null, }; function createConfigStore() { diff --git a/src/lib/stores/conversations.test.ts b/src/lib/stores/conversations.test.ts index 2a5d9e7..b4ead50 100644 --- a/src/lib/stores/conversations.test.ts +++ b/src/lib/stores/conversations.test.ts @@ -562,6 +562,54 @@ describe("draft text persistence", () => { }); }); +describe("worktreeInfo state management", () => { + it("initialises worktreeInfo as null", () => { + const conversation = { worktreeInfo: null }; + expect(conversation.worktreeInfo).toBeNull(); + }); + + it("stores worktreeInfo when a worktree is created", () => { + const info = { + name: "worktree-abc", + path: "/tmp/worktrees/worktree-abc", + branch: "feat/my-feature", + original_repo_directory: "/home/naomi/code/project", + }; + const conversation = { worktreeInfo: null as typeof info | null }; + conversation.worktreeInfo = info; + + expect(conversation.worktreeInfo?.branch).toBe("feat/my-feature"); + expect(conversation.worktreeInfo?.name).toBe("worktree-abc"); + expect(conversation.worktreeInfo?.original_repo_directory).toBe("/home/naomi/code/project"); + }); + + it("clears worktreeInfo when a worktree is removed", () => { + const info = { + name: "worktree-abc", + path: "/tmp/worktrees/worktree-abc", + branch: "feat/my-feature", + original_repo_directory: "/home/naomi/code/project", + }; + const conversation = { worktreeInfo: info as typeof info | null }; + conversation.worktreeInfo = null; + + expect(conversation.worktreeInfo).toBeNull(); + }); + + it("stores worktreeInfo independently per conversation", () => { + const conversations = new Map([ + ["conv-1", { worktreeInfo: null as { branch: string } | null }], + ["conv-2", { worktreeInfo: null as { branch: string } | null }], + ]); + + const conv1 = conversations.get("conv-1"); + if (conv1) conv1.worktreeInfo = { branch: "feat/one" }; + + expect(conversations.get("conv-1")?.worktreeInfo?.branch).toBe("feat/one"); + expect(conversations.get("conv-2")?.worktreeInfo).toBeNull(); + }); +}); + describe("isProcessing state management", () => { it("starts as false by default", () => { const conversation = { id: "conv-1", isProcessing: false }; diff --git a/src/lib/stores/conversations.ts b/src/lib/stores/conversations.ts index 666b4d1..cf8bf31 100644 --- a/src/lib/stores/conversations.ts +++ b/src/lib/stores/conversations.ts @@ -7,6 +7,7 @@ import type { Attachment, } from "$lib/types/messages"; import type { CharacterState } from "$lib/types/states"; +import type { WorktreeInfo } from "$lib/types/worktree"; import { cleanupConversationTracking } from "$lib/tauri"; import { characterState } from "$lib/stores/character"; import { sessionsStore } from "$lib/stores/sessions"; @@ -41,6 +42,7 @@ export interface Conversation { successSoundFired: boolean; taskStartSoundFired: boolean; draftText: string; + worktreeInfo: WorktreeInfo | null; } const TAB_NAMES = [ @@ -165,6 +167,7 @@ function createConversationsStore() { successSoundFired: false, taskStartSoundFired: false, draftText: "", + worktreeInfo: null, }; } @@ -220,6 +223,7 @@ function createConversationsStore() { const pendingQuestion = derived(activeConversation, ($conv) => $conv?.pendingQuestion || null); const scrollPosition = derived(activeConversation, ($conv) => $conv?.scrollPosition ?? -1); const attachments = derived(activeConversation, ($conv) => $conv?.attachments || []); + const worktreeInfo = derived(activeConversation, ($conv) => $conv?.worktreeInfo ?? null); return { // Expose derived stores for compatibility @@ -235,6 +239,7 @@ function createConversationsStore() { pendingRetryMessage: { subscribe: pendingRetryMessage.subscribe }, scrollPosition: { subscribe: scrollPosition.subscribe }, attachments: { subscribe: attachments.subscribe }, + worktreeInfo: { subscribe: worktreeInfo.subscribe }, // New conversation-specific stores conversations: { subscribe: conversations.subscribe }, @@ -976,6 +981,16 @@ function createConversationsStore() { }); }, + setWorktreeInfo: (conversationId: string, info: WorktreeInfo | null) => { + conversations.update((convs) => { + const conv = convs.get(conversationId); + if (conv) { + conv.worktreeInfo = info; + } + return convs; + }); + }, + // Add initialization helper initialize: () => { ensureInitialized(); diff --git a/src/lib/stores/memoryBrowser.test.ts b/src/lib/stores/memoryBrowser.test.ts new file mode 100644 index 0000000..d3e375c --- /dev/null +++ b/src/lib/stores/memoryBrowser.test.ts @@ -0,0 +1,49 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { get } from "svelte/store"; +import { memoryBrowserStore } from "./memoryBrowser"; + +beforeEach(() => { + memoryBrowserStore.close(); +}); + +describe("memoryBrowserStore", () => { + it("initialises with panel closed", () => { + const state = get(memoryBrowserStore); + expect(state.isOpen).toBe(false); + }); + + it("open() sets isOpen to true", () => { + memoryBrowserStore.open(); + expect(get(memoryBrowserStore).isOpen).toBe(true); + }); + + it("close() sets isOpen to false", () => { + memoryBrowserStore.open(); + memoryBrowserStore.close(); + expect(get(memoryBrowserStore).isOpen).toBe(false); + }); + + it("toggle() opens the panel when closed", () => { + memoryBrowserStore.close(); + memoryBrowserStore.toggle(); + expect(get(memoryBrowserStore).isOpen).toBe(true); + }); + + it("toggle() closes the panel when open", () => { + memoryBrowserStore.open(); + memoryBrowserStore.toggle(); + expect(get(memoryBrowserStore).isOpen).toBe(false); + }); + + it("calling open() when already open keeps it open", () => { + memoryBrowserStore.open(); + memoryBrowserStore.open(); + expect(get(memoryBrowserStore).isOpen).toBe(true); + }); + + it("calling close() when already closed keeps it closed", () => { + memoryBrowserStore.close(); + memoryBrowserStore.close(); + expect(get(memoryBrowserStore).isOpen).toBe(false); + }); +}); diff --git a/src/lib/stores/memoryBrowser.ts b/src/lib/stores/memoryBrowser.ts new file mode 100644 index 0000000..ebd6cc2 --- /dev/null +++ b/src/lib/stores/memoryBrowser.ts @@ -0,0 +1,20 @@ +import { writable } from "svelte/store"; + +interface MemoryBrowserState { + isOpen: boolean; +} + +function createMemoryBrowserStore() { + const { subscribe, update } = writable({ + isOpen: false, + }); + + return { + subscribe, + open: () => update((state) => ({ ...state, isOpen: true })), + close: () => update((state) => ({ ...state, isOpen: false })), + toggle: () => update((state) => ({ ...state, isOpen: !state.isOpen })), + }; +} + +export const memoryBrowserStore = createMemoryBrowserStore(); 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 { 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 274d7f1..c1e5252 100644 --- a/src/lib/tauri.ts +++ b/src/lib/tauri.ts @@ -13,6 +13,7 @@ import type { } from "$lib/types/messages"; import type { CharacterState } from "$lib/types/states"; import type { AgentStartPayload, AgentEndPayload } from "$lib/types/agents"; +import type { WorktreeEvent } from "$lib/types/worktree"; import { agentStore } from "$lib/stores/agents"; import { todos } from "$lib/stores/todos"; import { @@ -22,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; @@ -430,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); @@ -515,6 +527,7 @@ export async function initializeTauriListeners() { agent_id, description, subagent_type, + model, started_at, conversation_id, parent_tool_use_id, @@ -526,6 +539,7 @@ export async function initializeTauriListeners() { agentId: agent_id, description, subagentType: subagent_type, + model, startedAt: started_at, status: "running", parentToolUseId: parent_tool_use_id, @@ -538,9 +552,10 @@ export async function initializeTauriListeners() { conversationId: string; toolUseId: string; agentId: string; + agentType?: string; }>("claude:agent-update", (event) => { - const { conversationId, toolUseId, agentId } = event.payload; - agentStore.updateAgentId(conversationId, toolUseId, agentId); + const { conversationId, toolUseId, agentId, agentType } = event.payload; + agentStore.updateAgentId(conversationId, toolUseId, agentId, agentType); }); unlisteners.push(agentUpdateUnlisten); @@ -560,6 +575,24 @@ export async function initializeTauriListeners() { }); unlisteners.push(agentEndUnlisten); + const worktreeUnlisten = await listen("claude:worktree", (event) => { + const { conversation_id, event_type, worktree } = event.payload; + const targetConversationId = conversation_id || get(claudeStore.activeConversationId); + if (targetConversationId) { + claudeStore.setWorktreeInfo( + targetConversationId, + 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); + const questionUnlisten = await listen("claude:question", (event) => { const questionEvent = event.payload; diff --git a/src/lib/types/agents.ts b/src/lib/types/agents.ts index cc9e3b5..caf572c 100644 --- a/src/lib/types/agents.ts +++ b/src/lib/types/agents.ts @@ -3,8 +3,10 @@ export type AgentStatus = "running" | "completed" | "errored"; export interface AgentInfo { toolUseId: string; agentId?: string; + agentType?: string; description: string; subagentType: string; + model?: string; startedAt: number; endedAt?: number; status: AgentStatus; @@ -20,6 +22,7 @@ export interface AgentStartPayload { agent_id?: string; description: string; subagent_type: string; + model?: string; started_at: number; conversation_id?: string; parent_tool_use_id?: string; diff --git a/src/lib/types/worktree.ts b/src/lib/types/worktree.ts new file mode 100644 index 0000000..7dd2880 --- /dev/null +++ b/src/lib/types/worktree.ts @@ -0,0 +1,13 @@ +export interface WorktreeInfo { + name: string; + path: string; + branch: string; + original_repo_directory: string; +} + +export interface WorktreeEvent { + conversation_id?: string; + /** "create" or "remove" */ + event_type: string; + worktree?: WorktreeInfo; +} 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; +} 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)} /> - + ({ profile_avatar_path: null, profile_bio: null, custom_theme_colors: {}, + auto_memory_directory: null, + model_overrides: null, }); case "list_quick_actions": return Promise.resolve([]);