feat: CLI v2.1.68–v2.1.74 compatibility updates #221

Merged
naomi merged 20 commits from feat/cli into main 2026-03-13 01:34:45 -07:00
45 changed files with 2905 additions and 585 deletions
+121
View File
@@ -662,6 +662,37 @@ pub async fn fetch_changelog() -> Result<Vec<ChangelogEntry>, String> {
.collect())
}
fn parse_npm_cli_version(json: &str) -> Result<String, String> {
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<String, String> {
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<ProjectScan, String> {
})
}
#[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<String>) {
(
"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);
}
}
+71
View File
@@ -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<u64>,
#[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<String>,
#[serde(default)]
pub model_overrides: Option<HashMap<String, String>>,
}
#[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<String>,
#[serde(default)]
pub model_overrides: Option<HashMap<String, String>>,
}
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]
+2
View File
@@ -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");
+20
View File
@@ -292,6 +292,26 @@ pub struct AgentStartEvent {
pub conversation_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub parent_tool_use_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub model: Option<String>,
}
#[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<String>,
/// "create" or "remove"
pub event_type: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub worktree: Option<WorktreeInfo>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
+561 -25
View File
@@ -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<String>,
parent_tool_use_id: Option<String>,
}
fn parse_worktree_hook(line: &str) -> Option<WorktreeInfo> {
// 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<String> {
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<SubagentStartData> {
// 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<SubagentStartData> {
.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<SubagentStartData> {
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<Mutex<Option<Instant>>> = 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, String>>,
) -> 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, "{}");
}
}
+202 -2
View File
@@ -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();
+79
View File
@@ -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> | void;
}
@@ -64,6 +67,10 @@ async function changeDirectory(path: string): Promise<void> {
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<void> {
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/",
@@ -1,202 +0,0 @@
<script lang="ts">
import { onMount } from "svelte";
import { fade, fly } from "svelte/transition";
import { cubicOut } from "svelte/easing";
import { listen } from "@tauri-apps/api/event";
import type { AchievementUnlockedEvent } from "$lib/types/achievements";
let achievements = $state<AchievementUnlockedEvent[]>([]);
let currentAchievement = $state<AchievementUnlockedEvent | null>(null);
let showNotification = $state(false);
onMount(() => {
let unlisten: (() => void) | undefined;
const setupListener = async () => {
unlisten = await listen<AchievementUnlockedEvent>("achievement:unlocked", (event) => {
achievements.push(event.payload);
if (!showNotification) {
showNext();
}
});
};
setupListener();
return () => {
if (unlisten) {
unlisten();
}
};
});
function showNext() {
if (achievements.length > 0) {
currentAchievement = achievements.shift() || null;
showNotification = true;
// Auto-hide after 5 seconds
setTimeout(() => {
showNotification = false;
// Show next achievement after animation completes
setTimeout(() => showNext(), 300);
}, 5000);
}
}
function dismiss() {
showNotification = false;
// Show next achievement after animation completes
setTimeout(() => showNext(), 300);
}
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";
}
}
function getAchievementRarity(id: string): string {
// Determine rarity based on achievement ID
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";
}
</script>
{#if showNotification && currentAchievement}
<div
class="fixed top-20 right-4 z-50 max-w-sm"
in:fly={{ x: 300, duration: 500, easing: cubicOut }}
out:fade={{ duration: 300 }}
>
<!-- Backdrop with animated gradient border -->
<div class="relative p-[2px] rounded-lg overflow-hidden">
<!-- Animated gradient border -->
<div
class="absolute inset-0 bg-gradient-to-r {getRarityColor(
getAchievementRarity(currentAchievement.achievement.id)
)} animate-pulse"
></div>
<!-- Main notification content -->
<div class="relative bg-[var(--bg-primary)] rounded-lg p-4 shadow-2xl backdrop-blur-sm">
<button
onclick={dismiss}
onkeydown={(e) => e.key === "Enter" && dismiss()}
class="absolute top-2 right-2 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 transition-colors"
aria-label="Dismiss notification"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
></path>
</svg>
</button>
<div class="flex items-start gap-4">
<!-- Icon with animated sparkles -->
<div class="relative flex-shrink-0">
<div class="text-5xl animate-bounce">{currentAchievement.achievement.icon}</div>
<!-- Sparkle animations -->
<div class="absolute -top-1 -right-1 text-yellow-400 animate-ping"></div>
<div
class="absolute -bottom-1 -left-1 text-yellow-400 animate-ping animation-delay-200"
>
</div>
<div class="absolute top-1/2 -right-2 text-yellow-400 animate-ping animation-delay-400">
</div>
</div>
<!-- Text content -->
<div class="flex-1 min-w-0 pt-1">
<h3
class="text-sm font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wide"
>
Achievement Unlocked!
</h3>
<p class="text-lg font-bold text-[var(--text-primary)] mt-1">
{currentAchievement.achievement.name}
</p>
<p class="text-sm text-gray-600 dark:text-gray-400 mt-1">
{currentAchievement.achievement.description}
</p>
<!-- Rarity badge -->
<div class="mt-2 inline-flex items-center">
<span
class="px-2 py-1 text-xs font-medium rounded-full bg-gradient-to-r {getRarityColor(
getAchievementRarity(currentAchievement.achievement.id)
)} text-white capitalize"
>
{getAchievementRarity(currentAchievement.achievement.id)}
</span>
</div>
</div>
</div>
<!-- Celebration confetti effect (CSS only) -->
<div class="absolute inset-0 pointer-events-none overflow-hidden rounded-lg">
{#each Array.from({ length: 10 }, (_, i) => i) as confettiIndex (confettiIndex)}
<div
class="absolute w-2 h-2 bg-gradient-to-br {getRarityColor(
getAchievementRarity(currentAchievement.achievement.id)
)} rounded-full animate-fall"
style="left: {Math.random() * 100}%; animation-delay: {Math.random() *
2}s; animation-duration: {2 + Math.random() * 2}s;"
></div>
{/each}
</div>
</div>
</div>
</div>
{/if}
<style>
@keyframes fall {
0% {
transform: translateY(-20px) rotate(0deg);
opacity: 1;
}
100% {
transform: translateY(400px) rotate(720deg);
opacity: 0;
}
}
.animate-fall {
animation: fall linear infinite;
}
.animation-delay-200 {
animation-delay: 200ms;
}
.animation-delay-400 {
animation-delay: 400ms;
}
</style>
@@ -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");
});
});
});
+9 -1
View File
@@ -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)}
</span>
</div>
<span
@@ -308,6 +309,13 @@
{agent.description}
</p>
<!-- Model override badge -->
{#if agent.model}
<p class="mt-0.5 text-[10px] text-purple-400 truncate" title="Model: {agent.model}">
{agent.model}
</p>
{/if}
<!-- Status indicator -->
<div class="mt-1 flex items-center gap-1">
{#if agent.status === "running"}
+64 -2
View File
@@ -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<string | null>(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<string>("get_claude_version");
@@ -42,13 +52,28 @@
}
}
async function fetchLatestNpmVersion() {
try {
const result = await invoke<string>("check_cli_latest_version");
latestNpmVersion = result;
} catch (error) {
console.error("Failed to check latest CLI version:", error);
}
}
onMount(() => {
fetchVersion();
fetchLatestNpmVersion();
});
</script>
<div class="cli-versions">
<div class="cli-version">
<div
class="cli-version {updateAvailable ? 'update-available' : ''}"
title={updateAvailable
? `Update available: ${latestNpmVersion} run: npm install -g @anthropic-ai/claude-code`
: "Installed CLI version"}
>
<svg
class="terminal-icon"
width="14"
@@ -64,6 +89,22 @@
<line x1="12" y1="19" x2="20" y2="19" />
</svg>
<span class="version-text">CLI {displayVersion}</span>
{#if updateAvailable}
<svg
class="update-icon"
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2.5"
stroke-linecap="round"
stroke-linejoin="round"
>
<polyline points="17 11 12 6 7 11" />
<line x1="12" y1="6" x2="12" y2="18" />
</svg>
{/if}
</div>
<div class="cli-version supported {supportedBadgeState}" title="Highest audited CLI version">
@@ -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;
+51 -3
View File
@@ -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);
});
});
+109 -5
View File
@@ -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<string, string>;
} 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 @@
</p>
</div>
<!-- Disable Cron Scheduling -->
<div class="mb-4">
<label class="flex items-center gap-3 cursor-pointer">
<input
type="checkbox"
bind:checked={config.disable_cron}
class="w-4 h-4 rounded border-[var(--border-color)] bg-[var(--bg-primary)] text-[var(--accent-primary)] focus:ring-[var(--accent-primary)]"
/>
<span class="text-sm text-[var(--text-primary)]">Disable cron scheduling</span>
</label>
<p class="text-xs text-[var(--text-tertiary)] mt-1 ml-7">
Sets <code class="font-mono">CLAUDE_CODE_DISABLE_CRON=1</code> to prevent Claude from scheduling
recurring tasks
</p>
</div>
<!-- Include Git Instructions -->
<div class="mb-4">
<label class="flex items-center gap-3 cursor-pointer">
<input
type="checkbox"
bind:checked={config.include_git_instructions}
class="w-4 h-4 rounded border-[var(--border-color)] bg-[var(--bg-primary)] text-[var(--accent-primary)] focus:ring-[var(--accent-primary)]"
/>
<span class="text-sm text-[var(--text-primary)]">Include git instructions</span>
</label>
<p class="text-xs text-[var(--text-tertiary)] mt-1 ml-7">
When disabled, sets <code class="font-mono">CLAUDE_CODE_DISABLE_GIT_INSTRUCTIONS=1</code> to
remove Claude's built-in commit and PR workflow guidance from its system prompt
</p>
</div>
<!-- Max Output Tokens -->
<div class="mb-4">
<label class="block text-sm text-[var(--text-primary)] mb-1" for="max-output-tokens">
@@ -572,6 +619,47 @@
being cut off mid-reply
</p>
</div>
<!-- Auto-memory Directory -->
<div class="mb-4">
<label for="auto-memory-dir" class="block text-sm text-[var(--text-primary)] mb-1">
Auto-memory directory <span class="text-[var(--text-tertiary)]">(optional)</span>
</label>
<input
id="auto-memory-dir"
type="text"
placeholder="Leave blank to use default"
bind:value={config.auto_memory_directory}
class="w-full px-3 py-2 text-sm bg-[var(--bg-primary)] border border-[var(--border-color)] rounded text-[var(--text-primary)] placeholder-[var(--text-tertiary)] focus:outline-none focus:border-[var(--accent-primary)]"
/>
<p class="text-xs text-[var(--text-tertiary)] mt-1">
Custom directory for auto-memory storage. Passed via
<code class="font-mono">--settings autoMemoryDirectory</code>. Leave blank to use the
default (working directory).
</p>
</div>
<!-- Model Overrides -->
<div class="mb-4">
<label for="model-overrides" class="block text-sm text-[var(--text-primary)] mb-1">
Model overrides <span class="text-[var(--text-tertiary)]">(optional)</span>
</label>
<textarea
id="model-overrides"
rows={4}
placeholder={'{\n "claude-opus-4-6": "arn:aws:bedrock:..."\n}'}
bind:value={modelOverridesJson}
class="w-full px-3 py-2 text-sm bg-[var(--bg-primary)] border border-[var(--border-color)] rounded text-[var(--text-primary)] placeholder-[var(--text-tertiary)] focus:outline-none focus:border-[var(--accent-primary)] font-mono resize-y"
></textarea>
{#if modelOverridesError}
<p class="text-xs text-red-500 mt-1">{modelOverridesError}</p>
{/if}
<p class="text-xs text-[var(--text-tertiary)] mt-1">
JSON map of model names to provider-specific IDs (for AWS Bedrock, Google Vertex, etc.).
Passed via <code class="font-mono">--settings modelOverrides</code>. Leave blank to use
defaults.
</p>
</div>
</section>
<!-- Greeting Section -->
@@ -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"
></textarea>
</div>
<!-- Enable Claude.ai MCP Servers -->
<div class="mb-4">
<label class="flex items-center gap-3 cursor-pointer">
<input
type="checkbox"
bind:checked={config.enable_claudeai_mcp_servers}
class="w-4 h-4 rounded border-[var(--border-color)] bg-[var(--bg-primary)] text-[var(--accent-primary)] focus:ring-[var(--accent-primary)]"
/>
<span class="text-sm text-[var(--text-primary)]">Enable Claude.ai MCP servers</span>
</label>
<p class="text-xs text-[var(--text-tertiary)] mt-1 ml-7">
When disabled, sets <code class="font-mono">ENABLE_CLAUDEAI_MCP_SERVERS=false</code> to prevent
Claude Code from connecting to MCP servers configured in Claude.ai.
</p>
</div>
</section>
<!-- Auto-Granted Tools Section -->
+4
View File
@@ -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,
},
});
+39 -4
View File
@@ -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);
}
</style>
+2 -1
View File
@@ -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";
+94 -72
View File
@@ -1,8 +1,16 @@
<script lang="ts">
import { onMount } from "svelte";
import { invoke } from "@tauri-apps/api/core";
import { get } from "svelte/store";
import { claudeStore } from "$lib/stores/claude";
import Markdown from "./Markdown.svelte";
interface Props {
isOpen: boolean;
onClose: () => void;
}
const { isOpen, onClose }: Props = $props();
interface MemoryFileInfo {
path: string;
heading: string | null;
@@ -17,7 +25,6 @@
let fileContent: string = $state("");
let isLoading = $state(false);
let error: string | null = $state(null);
let isPanelOpen = $state(false);
async function loadMemoryFiles() {
isLoading = true;
@@ -58,37 +65,20 @@
return file.heading ?? getFileName(file.path);
}
function togglePanel() {
isPanelOpen = !isPanelOpen;
if (isPanelOpen && memoryFiles.length === 0) {
loadMemoryFiles();
}
async function sendMemoryCommand() {
const conversationId = get(claudeStore.activeConversationId);
if (!conversationId) return;
await invoke("send_prompt", { conversationId, message: "/memory" });
}
onMount(() => {
// Don't load on mount - only when panel is opened
$effect(() => {
if (isOpen && memoryFiles.length === 0) {
loadMemoryFiles();
}
});
</script>
<button class="memory-toggle" onclick={togglePanel} title="Memory Browser">
<svg
class="icon"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253"
/>
</svg>
<span class="label">Memory</span>
</button>
{#if isPanelOpen}
{#if isOpen}
<div class="memory-panel">
<div class="panel-header">
<div class="header-title">
@@ -108,22 +98,56 @@
</svg>
<h3>Memory Files</h3>
</div>
<button class="close-btn" onclick={togglePanel} title="Close">
<svg
class="close-icon"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
<div class="header-actions">
<button onclick={sendMemoryCommand} class="action-btn" title="Send /memory to Claude">
<svg
class="action-icon"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M8 9l3 3-3 3m5 0h3M5 20h14a2 2 0 002-2V6a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"
/>
</svg>
</button>
<button onclick={loadMemoryFiles} class="action-btn" title="Refresh">
<svg
class="action-icon"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
/>
</svg>
</button>
<button class="close-btn" onclick={onClose} title="Close">
<svg
class="close-icon"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
</div>
<div class="panel-content">
@@ -230,34 +254,6 @@
{/if}
<style>
.memory-toggle {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 1rem;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 8px;
color: var(--text-primary);
cursor: pointer;
transition: all 0.2s ease;
}
.memory-toggle:hover {
background: var(--bg-hover);
border-color: var(--accent-primary);
}
.icon {
width: 1.25rem;
height: 1.25rem;
}
.label {
font-size: 0.875rem;
font-weight: 500;
}
.memory-panel {
position: fixed;
top: 0;
@@ -300,6 +296,32 @@
color: var(--text-primary);
}
.header-actions {
display: flex;
align-items: center;
gap: 0.25rem;
}
.action-btn {
padding: 0.5rem;
background: transparent;
border: none;
color: var(--text-secondary);
cursor: pointer;
border-radius: 4px;
transition: all 0.2s ease;
}
.action-btn:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
.action-icon {
width: 1.25rem;
height: 1.25rem;
}
.close-btn {
padding: 0.5rem;
background: transparent;
+23
View File
@@ -6,6 +6,7 @@
import { editorStore } from "$lib/stores/editor";
import { configStore } from "$lib/stores/config";
import { debugConsoleStore } from "$lib/stores/debugConsole";
import { memoryBrowserStore } from "$lib/stores/memoryBrowser";
import type { ConnectionStatus } from "$lib/types/messages";
import StatsDisplay from "./StatsDisplay.svelte";
import AboutPanel from "./AboutPanel.svelte";
@@ -24,6 +25,7 @@
import ChangelogPanel from "./ChangelogPanel.svelte";
import TaskLoopPanel from "./TaskLoopPanel.svelte";
import WorkflowPanel from "./WorkflowPanel.svelte";
import MemoryBrowserPanel from "./MemoryBrowserPanel.svelte";
import { injectTextStore } from "$lib/stores/projectContext";
const DISCORD_URL = "https://chat.nhcarrigan.com";
@@ -69,6 +71,10 @@
let showChangelog = $state(false);
let showTaskLoop = $state(false);
let showWorkflowPanel = $state(false);
let showMemoryPanel = $state(false);
memoryBrowserStore.subscribe((s) => {
showMemoryPanel = s.isOpen;
});
const progress = $derived($achievementProgress);
const activeAgentCount = $derived($runningAgentCount);
@@ -176,6 +182,19 @@
<span>Session History</span>
</button>
<!-- Memory Manager -->
<button onclick={menuAction(() => memoryBrowserStore.open())} class="nav-item">
<svg class="w-5 h-5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z"
/>
</svg>
<span>Memory Manager</span>
</button>
<!-- To-Do List -->
<button onclick={menuAction(() => (showTodoPanel = true))} class="nav-item">
<svg class="w-5 h-5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -547,6 +566,10 @@
/>
{/if}
{#if showMemoryPanel}
<MemoryBrowserPanel isOpen={showMemoryPanel} onClose={() => memoryBrowserStore.close()} />
{/if}
{#if showWorkflowPanel}
<WorkflowPanel
onClose={() => (showWorkflowPanel = false)}
@@ -89,6 +89,10 @@
allowed_tools: [...new Set([...newGrantedTools, ...config.auto_granted_tools])],
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,
},
});
@@ -23,6 +23,9 @@
>
<span class="command-name">/{command.name}</span>
<span class="command-description">{command.description}</span>
{#if command.source === "cli"}
<span class="cli-badge">CLI</span>
{/if}
</button>
{/each}
</div>
@@ -82,5 +85,19 @@
.command-description {
color: var(--text-secondary);
font-size: 13px;
flex: 1;
}
.cli-badge {
font-size: 10px;
font-weight: 600;
padding: 1px 5px;
border-radius: 4px;
background: color-mix(in srgb, var(--accent-primary) 15%, transparent);
color: var(--accent-primary);
border: 1px solid color-mix(in srgb, var(--accent-primary) 30%, transparent);
letter-spacing: 0.5px;
text-transform: uppercase;
flex-shrink: 0;
}
</style>
+34
View File
@@ -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}
</div>
{/if}
{#if worktreeInfo}
<div
class="flex items-center gap-1 px-2 py-0.5 rounded-full bg-emerald-500/15 border border-emerald-500/30 text-emerald-400 text-xs"
title="Worktree: {worktreeInfo.name} | Base: {worktreeInfo.original_repo_directory}"
>
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M13 10V3L4 14h7v7l9-11h-7z"
/>
</svg>
{worktreeInfo.branch}
</div>
{/if}
{:else}
<div class="flex items-center gap-2">
<span class="text-sm text-gray-600">cwd:</span>
+4
View File
@@ -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) {
+199
View File
@@ -0,0 +1,199 @@
<script lang="ts">
import { onMount } from "svelte";
import { fade, fly } from "svelte/transition";
import { cubicOut } from "svelte/easing";
import { listen } from "@tauri-apps/api/event";
import { openUrl } from "@tauri-apps/plugin-opener";
import { toastStore, getAchievementRarity, getRarityColour } from "$lib/stores/toasts";
import type { AchievementUnlockedEvent } from "$lib/types/achievements";
const toasts = toastStore;
onMount(() => {
let unlisten: (() => void) | undefined;
const setupListener = async () => {
unlisten = await listen<AchievementUnlockedEvent>("achievement:unlocked", (event) => {
toastStore.addAchievement(event.payload.achievement);
});
};
setupListener();
return () => {
if (unlisten) {
unlisten();
}
};
});
</script>
<div class="fixed top-20 right-4 z-50 flex flex-col gap-3 items-end">
{#each $toasts as toast (toast.id)}
<div in:fly={{ x: 300, duration: 500, easing: cubicOut }} out:fade={{ duration: 300 }}>
{#if toast.kind === "info"}
<!-- Info toast -->
<div
class="bg-[var(--bg-primary)] border border-[var(--border-color)] rounded-lg p-3 shadow-lg flex items-center gap-2 max-w-sm"
>
<span class="text-xl shrink-0">{toast.icon}</span>
<span class="text-sm text-[var(--text-primary)] flex-1">{toast.message}</span>
<button
onclick={() => toastStore.remove(toast.id)}
class="text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 transition-colors shrink-0"
aria-label="Dismiss"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
{:else if toast.kind === "achievement"}
{@const rarity = getAchievementRarity(toast.achievement.id)}
{@const colour = getRarityColour(rarity)}
<!-- Achievement toast -->
<div class="relative p-[2px] rounded-lg overflow-hidden max-w-sm">
<!-- Animated gradient border -->
<div class="absolute inset-0 bg-gradient-to-r {colour} animate-pulse"></div>
<!-- Main content -->
<div class="relative bg-[var(--bg-primary)] rounded-lg p-4 shadow-2xl backdrop-blur-sm">
<button
onclick={() => toastStore.remove(toast.id)}
class="absolute top-2 right-2 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 transition-colors"
aria-label="Dismiss notification"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
<div class="flex items-start gap-4">
<!-- Icon with animated sparkles -->
<div class="relative flex-shrink-0">
<div class="text-5xl animate-bounce">{toast.achievement.icon}</div>
<div class="absolute -top-1 -right-1 text-yellow-400 animate-ping"></div>
<div
class="absolute -bottom-1 -left-1 text-yellow-400 animate-ping animation-delay-200"
>
</div>
<div
class="absolute top-1/2 -right-2 text-yellow-400 animate-ping animation-delay-400"
>
</div>
</div>
<!-- Text content -->
<div class="flex-1 min-w-0 pt-1">
<h3
class="text-sm font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wide"
>
Achievement Unlocked!
</h3>
<p class="text-lg font-bold text-[var(--text-primary)] mt-1">
{toast.achievement.name}
</p>
<p class="text-sm text-gray-600 dark:text-gray-400 mt-1">
{toast.achievement.description}
</p>
<!-- Rarity badge -->
<div class="mt-2 inline-flex items-center">
<span
class="px-2 py-1 text-xs font-medium rounded-full bg-gradient-to-r {colour} text-white capitalize"
>
{rarity}
</span>
</div>
</div>
</div>
<!-- Confetti particles -->
<div class="absolute inset-0 pointer-events-none overflow-hidden rounded-lg">
{#each Array.from({ length: 10 }, (_, i) => i) as confettiIndex (confettiIndex)}
<div
class="absolute w-2 h-2 bg-gradient-to-br {colour} rounded-full animate-fall"
style="left: {(confettiIndex * 11) % 100}%; animation-delay: {(confettiIndex *
0.3) %
2}s; animation-duration: {2 + ((confettiIndex * 0.25) % 2)}s;"
></div>
{/each}
</div>
</div>
</div>
{:else if toast.kind === "update"}
<!-- Update toast -->
<div
class="bg-[var(--bg-tertiary)] border border-[var(--accent-primary)] rounded-lg p-4 shadow-lg max-w-sm"
>
<div class="flex items-start gap-3">
<div class="text-2xl">🎉</div>
<div class="flex-1">
<h3 class="text-[var(--text-primary)] font-semibold mb-1">Update Available!</h3>
<button
onclick={() => openUrl(toast.releaseUrl)}
class="text-[var(--accent-primary)] font-mono hover:underline text-sm"
>
{toast.latestVersion}
</button>
<p class="text-[var(--text-muted)] text-xs mt-1">
Current version: {toast.currentVersion}
</p>
</div>
<button
onclick={() => toastStore.remove(toast.id)}
class="text-[var(--text-muted)] hover:text-[var(--text-primary)] transition-colors shrink-0"
aria-label="Dismiss"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
</div>
{/if}
</div>
{/each}
</div>
<style>
@keyframes fall {
0% {
transform: translateY(-20px) rotate(0deg);
opacity: 1;
}
100% {
transform: translateY(400px) rotate(720deg);
opacity: 0;
}
}
.animate-fall {
animation: fall linear infinite;
}
.animation-delay-200 {
animation-delay: 200ms;
}
.animation-delay-400 {
animation-delay: 400ms;
}
</style>
@@ -1,89 +0,0 @@
<script lang="ts">
import { invoke } from "@tauri-apps/api/core";
import { openUrl } from "@tauri-apps/plugin-opener";
import type { UpdateInfo } from "$lib/types/messages";
import { configStore } from "$lib/stores/config";
let updateInfo = $state<UpdateInfo | null>(null);
let dismissed = $state(false);
export async function checkForUpdates() {
// Check if update checks are enabled
const config = configStore.getConfig();
if (!config.update_checks_enabled) {
return;
}
try {
const info = await invoke<UpdateInfo>("check_for_updates");
if (info.has_update) {
updateInfo = info;
dismissed = false;
}
} catch (err) {
const errorMessage = err instanceof Error ? err.message : String(err);
console.error("Failed to check for updates:", errorMessage);
}
}
function dismiss() {
dismissed = true;
}
async function openRelease() {
if (updateInfo?.release_url) {
await openUrl(updateInfo.release_url);
}
}
</script>
{#if updateInfo && !dismissed}
<div
class="fixed bottom-4 right-4 max-w-sm bg-[var(--bg-tertiary)] border border-[var(--accent-primary)] rounded-lg shadow-lg p-4 z-50"
>
<div class="flex items-start gap-3">
<div class="text-2xl">🎉</div>
<div class="flex-1">
<h3 class="text-[var(--text-primary)] font-semibold mb-1">Update Available!</h3>
<p class="text-[var(--text-secondary)] text-sm mb-2">
A new version of Hikari Desktop is available:
<span class="text-[var(--accent-primary)] font-mono">{updateInfo.latest_version}</span>
</p>
<p class="text-[var(--text-muted)] text-xs mb-3">
Current version: {updateInfo.current_version}
</p>
<div class="flex gap-2">
<button onclick={openRelease} class="btn-trans-gradient px-3 py-1.5 rounded text-sm">
View Release
</button>
<button
onclick={dismiss}
class="px-3 py-1.5 bg-[var(--bg-secondary)] text-[var(--text-secondary)] rounded text-sm hover:bg-[var(--bg-primary)] transition-all"
>
Later
</button>
</div>
</div>
<button
onclick={dismiss}
class="text-[var(--text-muted)] hover:text-[var(--text-primary)] transition-colors"
aria-label="Dismiss"
>
<svg
class="w-4 h-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
></path>
</svg>
</button>
</div>
</div>
{/if}
@@ -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,
},
});
+38
View File
@@ -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", () => {
+2 -1
View File
@@ -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 {
+4
View File
@@ -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,
+15
View File
@@ -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);
+15
View File
@@ -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<string, string> | 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() {
+48
View File
@@ -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 };
+15
View File
@@ -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();
+49
View File
@@ -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);
});
});
+20
View File
@@ -0,0 +1,20 @@
import { writable } from "svelte/store";
interface MemoryBrowserState {
isOpen: boolean;
}
function createMemoryBrowserStore() {
const { subscribe, update } = writable<MemoryBrowserState>({
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();
+162 -2
View File
@@ -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", () => {
+17 -11
View File
@@ -378,7 +378,11 @@ function createSessionsStore() {
isLoading.set(true);
try {
const result = await invoke<SessionListItem[]>("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<SessionListItem[]>("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 {
+245
View File
@@ -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);
});
});
});
+88
View File
@@ -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<Toast[]>([]);
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();
+35 -2
View File
@@ -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<WorktreeEvent>("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<UserQuestionEvent>("claude:question", (event) => {
const questionEvent = event.payload;
+3
View File
@@ -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;
+13
View File
@@ -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;
}
+270
View File
@@ -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 = "<p>Saved to /tmp/report.pdf successfully.</p>";
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 = "<p>Audio saved to /tmp/output.mp3</p>";
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 = "<p>Example:</p><pre><code>/tmp/file.pdf</code></pre>";
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 = "<p>Use <code>/tmp/file.pdf</code> to open it.</p>";
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 = "<p>Hello, this is regular text with /tmp/script.sh</p>";
const result = linkifyFilePaths(html);
expect(result).toBe(html);
});
it("does not linkify text file paths", () => {
const html = "<p>Saved to /tmp/output.txt</p>";
const result = linkifyFilePaths(html);
expect(result).not.toContain("data-filepath");
});
it("handles a home-relative path", () => {
const html = "<p>Saved to ~/downloads/audio.flac</p>";
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 = "<p>Files: /tmp/a.pdf and /tmp/b.mp3</p>";
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 = `<p>Saved to /tmp/my"file.pdf</p>`;
const result = linkifyFilePaths(html);
expect(result).not.toContain("data-filepath");
});
it("preserves existing HTML tags and attributes", () => {
const html = '<p class="foo">Saved to /tmp/report.pdf</p>';
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 = '<a href="/tmp/file.pdf">/tmp/file.pdf</a>';
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("");
});
});
+133
View File
@@ -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, "&quot;");
return `<a class="file-link" href="#" data-filepath="${escaped}">${icon} ${filePath}</a>`;
});
// 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;
}
+18 -10
View File
@@ -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<UpdateInfo>("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 @@
<PermissionModal />
<UserQuestionModal />
<ConfigSidebar />
<MemoryBrowserPanel />
<AchievementNotification />
<AchievementsPanel
bind:isOpen={achievementPanelOpen}
onClose={() => (achievementPanelOpen = false)}
/>
<UpdateNotification bind:this={updateNotification} />
<ToastContainer />
<CloseAppConfirmModal
isOpen={closeConfirmModalOpen}
{hasActiveConversation}
+2
View File
@@ -49,6 +49,8 @@ vi.mock("@tauri-apps/api/core", () => ({
profile_avatar_path: null,
profile_bio: null,
custom_theme_colors: {},
auto_memory_directory: null,
model_overrides: null,
});
case "list_quick_actions":
return Promise.resolve([]);