diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 030e0ab..32c96ea 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -1464,6 +1464,7 @@ pub async fn close_application(app_handle: AppHandle) -> Result<(), String> { pub struct MemoryFileInfo { pub path: String, pub heading: Option, + pub last_modified: Option, // Unix timestamp in seconds as a string } #[derive(serde::Serialize)] @@ -1535,7 +1536,11 @@ async fn list_memory_files_via_wsl() -> Result { let mut files = Vec::new(); for path in paths { let heading = read_wsl_file_first_heading(&path); - files.push(MemoryFileInfo { path, heading }); + files.push(MemoryFileInfo { + path, + heading, + last_modified: None, + }); } Ok(MemoryFilesResponse { files }) @@ -1605,14 +1610,23 @@ async fn list_memory_files_native() -> Result { // Sort files alphabetically memory_paths.sort(); - // Read first heading from each file + // Read first heading and modification time from each file let files = memory_paths .into_iter() .map(|path| { let heading = fs::read_to_string(&path) .ok() .and_then(|content| extract_first_heading(&content)); - MemoryFileInfo { path, heading } + let last_modified = fs::metadata(&path) + .ok() + .and_then(|m| m.modified().ok()) + .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()) + .map(|d| d.as_secs().to_string()); + MemoryFileInfo { + path, + heading, + last_modified, + } }) .collect(); diff --git a/src-tauri/src/config.rs b/src-tauri/src/config.rs index ab26da7..2fb18f4 100644 --- a/src-tauri/src/config.rs +++ b/src-tauri/src/config.rs @@ -51,6 +51,9 @@ pub struct ClaudeStartOptions { #[serde(default)] pub model_overrides: Option>, + + #[serde(default)] + pub session_name: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] diff --git a/src-tauri/src/types.rs b/src-tauri/src/types.rs index f73cb4b..73a1f57 100644 --- a/src-tauri/src/types.rs +++ b/src-tauri/src/types.rs @@ -280,6 +280,44 @@ pub struct UserQuestionEvent { pub conversation_id: Option, } +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ElicitationEvent { + pub message: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub server_name: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub request_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub conversation_id: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ElicitationResultEvent { + pub action: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub request_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub conversation_id: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct StopFailureEvent { + #[serde(skip_serializing_if = "Option::is_none")] + pub stop_reason: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub error_type: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub conversation_id: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PostCompactEvent { + #[serde(skip_serializing_if = "Option::is_none")] + pub session_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub conversation_id: Option, +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct AgentStartEvent { pub tool_use_id: String, @@ -566,4 +604,141 @@ mod tests { panic!("Expected RateLimitEvent variant"); } } + + #[test] + fn test_elicitation_event_serialization() { + let event = ElicitationEvent { + message: "Please provide the API endpoint".to_string(), + server_name: Some("my-server".to_string()), + request_id: Some("req-123".to_string()), + conversation_id: Some("conv-abc".to_string()), + }; + + let serialized = serde_json::to_string(&event).unwrap(); + assert!(serialized.contains("\"message\":\"Please provide the API endpoint\"")); + assert!(serialized.contains("\"server_name\":\"my-server\"")); + assert!(serialized.contains("\"request_id\":\"req-123\"")); + assert!(serialized.contains("\"conversation_id\":\"conv-abc\"")); + } + + #[test] + fn test_elicitation_event_omits_none_fields() { + let event = ElicitationEvent { + message: "Enter your token".to_string(), + server_name: None, + request_id: None, + conversation_id: None, + }; + + let serialized = serde_json::to_string(&event).unwrap(); + assert!(serialized.contains("\"message\":\"Enter your token\"")); + assert!(!serialized.contains("server_name")); + assert!(!serialized.contains("request_id")); + assert!(!serialized.contains("conversation_id")); + } + + #[test] + fn test_elicitation_result_event_serialization() { + let event = ElicitationResultEvent { + action: "accept".to_string(), + request_id: Some("req-123".to_string()), + conversation_id: Some("conv-abc".to_string()), + }; + + let serialized = serde_json::to_string(&event).unwrap(); + assert!(serialized.contains("\"action\":\"accept\"")); + assert!(serialized.contains("\"request_id\":\"req-123\"")); + } + + #[test] + fn test_elicitation_result_event_cancel_omits_none_fields() { + let event = ElicitationResultEvent { + action: "cancel".to_string(), + request_id: None, + conversation_id: None, + }; + + let serialized = serde_json::to_string(&event).unwrap(); + assert!(serialized.contains("\"action\":\"cancel\"")); + assert!(!serialized.contains("request_id")); + assert!(!serialized.contains("conversation_id")); + } + + #[test] + fn test_stop_failure_event_serialization() { + let event = StopFailureEvent { + stop_reason: Some("api_error".to_string()), + error_type: Some("rate_limit".to_string()), + conversation_id: Some("conv-abc".to_string()), + }; + + let serialized = serde_json::to_string(&event).unwrap(); + assert!(serialized.contains("\"stop_reason\":\"api_error\"")); + assert!(serialized.contains("\"error_type\":\"rate_limit\"")); + assert!(serialized.contains("\"conversation_id\":\"conv-abc\"")); + } + + #[test] + fn test_stop_failure_event_omits_none_fields() { + let event = StopFailureEvent { + stop_reason: None, + error_type: None, + conversation_id: None, + }; + + let serialized = serde_json::to_string(&event).unwrap(); + assert!(!serialized.contains("stop_reason")); + assert!(!serialized.contains("error_type")); + assert!(!serialized.contains("conversation_id")); + } + + #[test] + fn test_stop_failure_event_partial_fields() { + let event = StopFailureEvent { + stop_reason: Some("api_error".to_string()), + error_type: None, + conversation_id: None, + }; + + let serialized = serde_json::to_string(&event).unwrap(); + assert!(serialized.contains("\"stop_reason\":\"api_error\"")); + assert!(!serialized.contains("error_type")); + assert!(!serialized.contains("conversation_id")); + } + + #[test] + fn test_post_compact_event_serialization() { + let event = PostCompactEvent { + session_id: Some("sess-abc".to_string()), + conversation_id: Some("conv-123".to_string()), + }; + + let serialized = serde_json::to_string(&event).unwrap(); + assert!(serialized.contains("\"session_id\":\"sess-abc\"")); + assert!(serialized.contains("\"conversation_id\":\"conv-123\"")); + } + + #[test] + fn test_post_compact_event_omits_none_fields() { + let event = PostCompactEvent { + session_id: None, + conversation_id: None, + }; + + let serialized = serde_json::to_string(&event).unwrap(); + assert!(!serialized.contains("session_id")); + assert!(!serialized.contains("conversation_id")); + } + + #[test] + fn test_post_compact_event_partial_fields() { + let event = PostCompactEvent { + session_id: Some("sess-xyz".to_string()), + conversation_id: None, + }; + + let serialized = serde_json::to_string(&event).unwrap(); + assert!(serialized.contains("\"session_id\":\"sess-xyz\"")); + assert!(!serialized.contains("conversation_id")); + } } diff --git a/src-tauri/src/wsl_bridge.rs b/src-tauri/src/wsl_bridge.rs index 1d720c8..1eef472 100644 --- a/src-tauri/src/wsl_bridge.rs +++ b/src-tauri/src/wsl_bridge.rs @@ -15,9 +15,10 @@ use crate::process_ext::HideWindow; use crate::stats::{calculate_cost, StatsUpdateEvent, UsageStats}; use crate::types::{ AgentEndEvent, AgentStartEvent, CharacterState, ClaudeMessage, ConnectionEvent, - ConnectionStatus, ContentBlock, MessageCost, OutputEvent, PermissionPromptEvent, - PermissionPromptEventItem, QuestionOption, SessionEvent, StateChangeEvent, TodoItem, - TodoUpdateEvent, UserQuestionEvent, WorkingDirectoryEvent, WorktreeEvent, WorktreeInfo, + ConnectionStatus, ContentBlock, ElicitationEvent, ElicitationResultEvent, MessageCost, + OutputEvent, PermissionPromptEvent, PermissionPromptEventItem, QuestionOption, SessionEvent, + PostCompactEvent, StateChangeEvent, StopFailureEvent, TodoItem, TodoUpdateEvent, + UserQuestionEvent, WorkingDirectoryEvent, WorktreeEvent, WorktreeInfo, }; use parking_lot::RwLock; use std::cell::RefCell; @@ -286,6 +287,13 @@ impl WslBridge { } } + // Pass session name to Claude Code if specified + if let Some(ref name) = options.session_name { + if !name.is_empty() { + cmd.args(["--name", name]); + } + } + // Add worktree flag if requested if options.use_worktree { cmd.arg("--worktree"); @@ -465,6 +473,14 @@ impl WslBridge { } } + // Pass session name to Claude Code if specified + if let Some(ref name) = options.session_name { + if !name.is_empty() { + let escaped = name.replace('\'', "'\\''"); + claude_cmd.push_str(&format!(" --name '{}'", escaped)); + } + } + // Add worktree flag if requested if options.use_worktree { claude_cmd.push_str(" --worktree"); @@ -587,12 +603,13 @@ impl WslBridge { ); // Watchdog: if system:init never arrives the process is truly hung (e.g. a silent crash - // after spawning). After 5 minutes we kill it so the user isn't stuck forever. - // handle_stdout will surface the error when stdout closes after the kill. + // after spawning). After 30 seconds we kill it so the user is not stuck forever. + // The CLI v2.1.79 stdin-hang fix means startup is reliably fast; 30 s is a generous + // safety net rather than a workaround for a known hang. let process_watchdog = self.process.clone(); let received_init_watchdog = self.received_init.clone(); thread::spawn(move || { - thread::sleep(Duration::from_secs(60)); + thread::sleep(Duration::from_secs(30)); if !received_init_watchdog.load(Ordering::SeqCst) { if let Some(mut proc) = process_watchdog.lock().take() { let _ = proc.kill(); @@ -613,6 +630,9 @@ impl WslBridge { let process_mid_watchdog = self.process.clone(); let pending_since_watchdog = self.pending_since.clone(); let generation_watchdog = self.watchdog_generation.clone(); + // 5-minute stuck timeout is intentionally larger than the CLI's 2-minute per-attempt + // non-streaming fallback (added in v2.1.79). A non-streaming response will either + // succeed or fail with a Result message well within this window. const STUCK_TIMEOUT: Duration = Duration::from_secs(5 * 60); const POLL_INTERVAL: Duration = Duration::from_secs(30); thread::spawn(move || { @@ -783,14 +803,17 @@ impl WslBridge { } let input_chars = stats.current_request_input.as_ref().map(|s| s.len() as u64).unwrap_or(0); - let model = stats.model.clone().unwrap_or_else(|| "claude-sonnet-4-5-20250929".to_string()); + let model = stats.model.clone().unwrap_or_else(|| "claude-sonnet-4-6".to_string()); (input_chars, stats.current_request_output_chars, stats.current_request_thinking_chars, stats.current_request_tools.clone(), model) }; tracing::info!("[COST ESTIMATION] Estimating cost for interrupted request"); - // Use conservative 3.5 chars/token for estimation (vs standard 4) + // Char-based estimation: 3.5 chars/token is intentionally conservative (standard English + // averages ~4 chars/token). CLI v2.1.75 fixed over-counting in Claude Code's compaction + // logic but does not affect this heuristic — we count characters ourselves, independently + // of Claude Code's internal token tracking. The 1.2 safety margin avoids undercharging. let estimated_input_tokens = (input_chars as f64 / 3.5).ceil() as u64; let estimated_output_tokens = ((output_chars as f64 / 3.5).ceil() as u64) + ((thinking_chars as f64 / 3.5).ceil() as u64); @@ -1051,11 +1074,21 @@ fn handle_stderr( // Hook events are informational — emit with distinct types instead of error let is_worktree_create = line.contains("[WorktreeCreate Hook]"); let is_worktree_remove = line.contains("[WorktreeRemove Hook]"); + let is_elicitation = line.contains("[Elicitation Hook]"); + let is_elicitation_result = line.contains("[ElicitationResult Hook]"); + let is_stop_failure = line.contains("[StopFailure Hook]"); + let is_post_compact = line.contains("[PostCompact Hook]"); let line_type = if is_worktree_create || is_worktree_remove { "worktree" } else if line.contains("[ConfigChange Hook]") { "config-change" + } else if is_elicitation || is_elicitation_result { + "elicitation" + } else if is_stop_failure { + "error" + } else if is_post_compact { + "compact-prompt" } else { "error" }; @@ -1097,6 +1130,103 @@ fn handle_stderr( parent_tool_use_id: None, }, ); + } else if is_elicitation { + let data = parse_elicitation_hook(&line); + let friendly_content = + format!("MCP server requesting input: {}", data.message); + + let _ = app.emit( + "claude:elicitation", + ElicitationEvent { + message: data.message, + server_name: data.server_name, + request_id: data.request_id, + conversation_id: conversation_id.clone(), + }, + ); + + let _ = app.emit( + "claude:output", + OutputEvent { + line_type: "elicitation".to_string(), + content: friendly_content, + tool_name: None, + conversation_id: conversation_id.clone(), + cost: None, + parent_tool_use_id: None, + }, + ); + } else if is_elicitation_result { + let data = parse_elicitation_result_hook(&line); + let friendly_content = + format!("MCP elicitation completed: {}", data.action); + + let _ = app.emit( + "claude:elicitation-result", + ElicitationResultEvent { + action: data.action, + request_id: data.request_id, + conversation_id: conversation_id.clone(), + }, + ); + + let _ = app.emit( + "claude:output", + OutputEvent { + line_type: "elicitation".to_string(), + content: friendly_content, + tool_name: None, + conversation_id: conversation_id.clone(), + cost: None, + parent_tool_use_id: None, + }, + ); + } else if is_stop_failure { + let data = parse_stop_failure_hook(&line); + let friendly_content = build_stop_failure_message(&data); + + let _ = app.emit( + "claude:stop-failure", + StopFailureEvent { + stop_reason: data.stop_reason, + error_type: data.error_type, + conversation_id: conversation_id.clone(), + }, + ); + + let _ = app.emit( + "claude:output", + OutputEvent { + line_type: "error".to_string(), + content: friendly_content, + tool_name: None, + conversation_id: conversation_id.clone(), + cost: None, + parent_tool_use_id: None, + }, + ); + } else if is_post_compact { + let data = parse_post_compact_hook(&line); + + let _ = app.emit( + "claude:post-compact", + PostCompactEvent { + session_id: data.session_id, + conversation_id: conversation_id.clone(), + }, + ); + + let _ = app.emit( + "claude:output", + OutputEvent { + line_type: "compact-prompt".to_string(), + content: "Context compacted — conversation history has been summarised to free up space.".to_string(), + tool_name: None, + conversation_id: conversation_id.clone(), + cost: None, + parent_tool_use_id: None, + }, + ); } else { let _ = app.emit( "claude:output", @@ -1235,6 +1365,111 @@ fn parse_subagent_stop_hook(line: &str) -> Option { }) } +#[derive(Debug)] +struct ElicitationData { + message: String, + server_name: Option, + request_id: Option, +} + +fn parse_elicitation_hook(line: &str) -> ElicitationData { + let message = extract_quoted_value(line, "message").unwrap_or_else(|| { + line.split("[Elicitation Hook]") + .nth(1) + .unwrap_or("") + .trim() + .to_string() + }); + + let server_name = extract_debug_string_value(line, "server_name"); + let request_id = extract_debug_string_value(line, "request_id"); + + ElicitationData { message, server_name, request_id } +} + +#[derive(Debug)] +struct ElicitationResultData { + action: String, + request_id: Option, +} + +fn parse_elicitation_result_hook(line: &str) -> ElicitationResultData { + let action = + extract_quoted_value(line, "action").unwrap_or_else(|| "unknown".to_string()); + + let request_id = extract_debug_string_value(line, "request_id"); + + ElicitationResultData { action, request_id } +} + +#[derive(Debug)] +struct StopFailureData { + stop_reason: Option, + error_type: Option, +} + +fn parse_stop_failure_hook(line: &str) -> StopFailureData { + let stop_reason = extract_quoted_value(line, "stop_reason"); + let error_type = extract_debug_string_value(line, "error_type"); + + StopFailureData { stop_reason, error_type } +} + +/// Builds a user-friendly message from a `StopFailureData` instance. +fn build_stop_failure_message(data: &StopFailureData) -> String { + match data.stop_reason.as_deref() { + Some("rate_limit") => "Session stopped: rate limit reached".to_string(), + Some("auth_failure") | Some("authentication") => { + "Session stopped: authentication failed".to_string() + } + Some(reason) => format!("Session stopped due to API error: {}", reason), + None => match data.error_type.as_deref() { + Some(et) => format!("Session stopped due to API error: {}", et), + None => "Session stopped due to an unknown API error".to_string(), + }, + } +} + +#[derive(Debug)] +struct PostCompactData { + session_id: Option, +} + +fn parse_post_compact_hook(line: &str) -> PostCompactData { + let session_id = extract_debug_string_value(line, "session_id"); + PostCompactData { session_id } +} + +/// Extracts a double-quoted string value from a `key="value"` pair in a hook line. +/// Handles escape sequences within the quoted value. +fn extract_quoted_value(line: &str, key: &str) -> Option { + let prefix = format!("{}=\"", key); + let start_idx = line.find(&prefix)? + prefix.len(); + let rest = &line[start_idx..]; + + let mut result = String::new(); + let mut chars = rest.chars(); + loop { + match chars.next() { + Some('"') => return Some(result), + Some('\\') => match chars.next() { + Some('n') => result.push('\n'), + Some('t') => result.push('\t'), + Some('"') => result.push('"'), + Some('\\') => result.push('\\'), + Some(c) => { + result.push('\\'); + result.push(c); + } + None => break, + }, + Some(c) => result.push(c), + None => break, + } + } + None +} + /// Extract text content from a ToolResult's `content` field. /// The content may be a JSON string or an array of typed content blocks. fn extract_tool_result_text(content: &serde_json::Value) -> Option { @@ -1356,7 +1591,7 @@ fn process_json_line( stats_guard.model.clone() }).unwrap_or_else(|| { tracing::warn!("No model info available for cost calculation, using default"); - "claude-sonnet-4-5-20250929".to_string() + "claude-sonnet-4-6".to_string() }); // Calculate cost for historical tracking (including cache tokens) @@ -3191,4 +3426,218 @@ mod tests { let result = build_combined_settings_arg(Some(""), None); assert_eq!(result, "{}"); } + + #[test] + fn test_extract_quoted_value_basic() { + let line = r#"[Elicitation Hook] message="Hello world", server_name=Some("srv")"#; + let result = extract_quoted_value(line, "message"); + assert_eq!(result, Some("Hello world".to_string())); + } + + #[test] + fn test_extract_quoted_value_with_escapes() { + let line = r#"[Elicitation Hook] message="Line one\nLine two", request_id=Some("r1")"#; + let result = extract_quoted_value(line, "message"); + assert_eq!(result, Some("Line one\nLine two".to_string())); + } + + #[test] + fn test_extract_quoted_value_missing() { + let line = r#"[Elicitation Hook] server_name=Some("srv")"#; + let result = extract_quoted_value(line, "message"); + assert_eq!(result, None); + } + + #[test] + fn test_parse_elicitation_hook_with_all_fields() { + let line = r#"[Elicitation Hook] message="Please enter your API key", server_name=Some("my-mcp"), request_id=Some("req-456")"#; + let data = parse_elicitation_hook(line); + assert_eq!(data.message, "Please enter your API key"); + assert_eq!(data.server_name, Some("my-mcp".to_string())); + assert_eq!(data.request_id, Some("req-456".to_string())); + } + + #[test] + fn test_parse_elicitation_hook_missing_optional_fields() { + let line = r#"[Elicitation Hook] message="What is the endpoint?", server_name=None, request_id=None"#; + let data = parse_elicitation_hook(line); + assert_eq!(data.message, "What is the endpoint?"); + assert_eq!(data.server_name, None); + assert_eq!(data.request_id, None); + } + + #[test] + fn test_parse_elicitation_hook_invalid_line() { + let line = "[Elicitation Hook] some unstructured data"; + let data = parse_elicitation_hook(line); + assert_eq!(data.message, "some unstructured data"); + assert_eq!(data.server_name, None); + assert_eq!(data.request_id, None); + } + + #[test] + fn test_parse_elicitation_result_hook_accept() { + let line = r#"[ElicitationResult Hook] action="accept", request_id=Some("req-789")"#; + let data = parse_elicitation_result_hook(line); + assert_eq!(data.action, "accept"); + assert_eq!(data.request_id, Some("req-789".to_string())); + } + + #[test] + fn test_parse_elicitation_result_hook_cancel() { + let line = r#"[ElicitationResult Hook] action="cancel", request_id=None"#; + let data = parse_elicitation_result_hook(line); + assert_eq!(data.action, "cancel"); + assert_eq!(data.request_id, None); + } + + #[test] + fn test_parse_stop_failure_hook_with_all_fields() { + let line = r#"[StopFailure Hook] stop_reason="api_error", error_type=Some("rate_limit"), conversation_id=Some("conv-123")"#; + let data = parse_stop_failure_hook(line); + assert_eq!(data.stop_reason, Some("api_error".to_string())); + assert_eq!(data.error_type, Some("rate_limit".to_string())); + } + + #[test] + fn test_parse_stop_failure_hook_missing_optional_error_type() { + let line = r#"[StopFailure Hook] stop_reason="api_error", error_type=None"#; + let data = parse_stop_failure_hook(line); + assert_eq!(data.stop_reason, Some("api_error".to_string())); + assert_eq!(data.error_type, None); + } + + #[test] + fn test_parse_stop_failure_hook_invalid_line() { + let line = "[StopFailure Hook] some unstructured data"; + let data = parse_stop_failure_hook(line); + assert_eq!(data.stop_reason, None); + assert_eq!(data.error_type, None); + } + + #[test] + fn test_build_stop_failure_message_rate_limit() { + let data = StopFailureData { + stop_reason: Some("rate_limit".to_string()), + error_type: None, + }; + assert_eq!(build_stop_failure_message(&data), "Session stopped: rate limit reached"); + } + + #[test] + fn test_build_stop_failure_message_auth_failure() { + let data = StopFailureData { + stop_reason: Some("auth_failure".to_string()), + error_type: None, + }; + assert_eq!(build_stop_failure_message(&data), "Session stopped: authentication failed"); + } + + #[test] + fn test_build_stop_failure_message_authentication() { + let data = StopFailureData { + stop_reason: Some("authentication".to_string()), + error_type: None, + }; + assert_eq!(build_stop_failure_message(&data), "Session stopped: authentication failed"); + } + + #[test] + fn test_build_stop_failure_message_unknown_reason() { + let data = StopFailureData { + stop_reason: Some("server_error".to_string()), + error_type: None, + }; + assert_eq!( + build_stop_failure_message(&data), + "Session stopped due to API error: server_error" + ); + } + + #[test] + fn test_build_stop_failure_message_no_reason_with_error_type() { + let data = StopFailureData { + stop_reason: None, + error_type: Some("timeout".to_string()), + }; + assert_eq!( + build_stop_failure_message(&data), + "Session stopped due to API error: timeout" + ); + } + + #[test] + fn test_parse_post_compact_hook_with_session_id() { + let line = + r#"[PostCompact Hook] session_id=Some("sess-abc123"), conversation_id=Some("conv-xyz")"#; + let data = parse_post_compact_hook(line); + assert_eq!(data.session_id, Some("sess-abc123".to_string())); + } + + #[test] + fn test_parse_post_compact_hook_without_session_id() { + let line = "[PostCompact Hook] session_id=None"; + let data = parse_post_compact_hook(line); + assert_eq!(data.session_id, None); + } + + #[test] + fn test_parse_post_compact_hook_empty_line() { + let line = "[PostCompact Hook]"; + let data = parse_post_compact_hook(line); + assert_eq!(data.session_id, None); + } + + #[test] + fn test_build_stop_failure_message_no_fields() { + let data = StopFailureData { + stop_reason: None, + error_type: None, + }; + assert_eq!( + build_stop_failure_message(&data), + "Session stopped due to an unknown API error" + ); + } + + /// Build the --name argument string without executing a command (for testing) + #[cfg(test)] + fn build_session_name_arg(name: &str) -> Option<(String, String)> { + if name.is_empty() { + return None; + } + Some(("--name".to_string(), name.to_string())) + } + + #[test] + fn test_e2e_session_name_passed_when_set() { + let name = "Sakura"; + let result = build_session_name_arg(name); + assert!(result.is_some()); + let (flag, value) = result.unwrap(); + assert_eq!(flag, "--name"); + assert_eq!(value, "Sakura"); + } + + #[test] + fn test_e2e_session_name_not_passed_when_none() { + let options = ClaudeStartOptions { + session_name: None, + ..Default::default() + }; + // When session_name is None, no --name arg should be produced + let has_name = options.session_name.as_deref().map(|n| !n.is_empty()).unwrap_or(false); + assert!(!has_name); + } + + #[test] + fn test_e2e_session_name_not_passed_when_empty() { + let options = ClaudeStartOptions { + session_name: Some(String::new()), + ..Default::default() + }; + // When session_name is Some(""), no --name arg should be produced + let has_name = options.session_name.as_deref().map(|n| !n.is_empty()).unwrap_or(false); + assert!(!has_name); + } } diff --git a/src/lib/commands/slashCommands.ts b/src/lib/commands/slashCommands.ts index 78f1a10..d19e871 100644 --- a/src/lib/commands/slashCommands.ts +++ b/src/lib/commands/slashCommands.ts @@ -71,6 +71,7 @@ async function changeDirectory(path: string): Promise { enable_claudeai_mcp_servers: config.enable_claudeai_mcp_servers ?? true, auto_memory_directory: config.auto_memory_directory || null, model_overrides: config.model_overrides || null, + session_name: null, }, }); @@ -152,6 +153,7 @@ async function startNewConversation(): Promise { enable_claudeai_mcp_servers: config.enable_claudeai_mcp_servers ?? true, auto_memory_directory: config.auto_memory_directory || null, model_overrides: config.model_overrides || null, + session_name: null, }, }); diff --git a/src/lib/components/CliVersion.svelte b/src/lib/components/CliVersion.svelte index 2361492..b48166f 100644 --- a/src/lib/components/CliVersion.svelte +++ b/src/lib/components/CliVersion.svelte @@ -2,7 +2,7 @@ import { invoke } from "@tauri-apps/api/core"; import { onMount } from "svelte"; - const SUPPORTED_CLI_VERSION = "2.1.74"; + const SUPPORTED_CLI_VERSION = "2.1.80"; let installedVersion = $state("Loading..."); let latestNpmVersion = $state(null); diff --git a/src/lib/components/ConfigSidebar.svelte b/src/lib/components/ConfigSidebar.svelte index e7b17d2..0a26a2b 100644 --- a/src/lib/components/ConfigSidebar.svelte +++ b/src/lib/components/ConfigSidebar.svelte @@ -610,13 +610,15 @@ id="max-output-tokens" type="number" min="1" - placeholder="Default (32000)" + max="128000" + placeholder="Default (model-dependent)" bind:value={config.max_output_tokens} 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)]" />

- Sets CLAUDE_CODE_MAX_OUTPUT_TOKENS — increase if responses are - being cut off mid-reply + Sets CLAUDE_CODE_MAX_OUTPUT_TOKENS. Maximum: 128k tokens + for Opus 4.6 and Sonnet 4.6 (64k default for Opus 4.6, 32k for other models). Increase if + responses are being cut off.

diff --git a/src/lib/components/ElicitationModal.svelte b/src/lib/components/ElicitationModal.svelte new file mode 100644 index 0000000..0b3a4ca --- /dev/null +++ b/src/lib/components/ElicitationModal.svelte @@ -0,0 +1,188 @@ + + + + +{#if isVisible && elicitation} +
+
+
+
+ 💬 +
+
+

MCP Server Request

+ {#if elicitation.server_name} +

from: {elicitation.server_name}

+ {:else} +

Input required from MCP server

+ {/if} +
+
+ +
+

{elicitation.message}

+
+ +
+ +
+ +
+ + +
+
+
+{/if} diff --git a/src/lib/components/InputBar.svelte b/src/lib/components/InputBar.svelte index fbb799f..718ebc2 100644 --- a/src/lib/components/InputBar.svelte +++ b/src/lib/components/InputBar.svelte @@ -406,6 +406,7 @@ User: ${formattedMessage}`; enable_claudeai_mcp_servers: config.enable_claudeai_mcp_servers ?? true, auto_memory_directory: config.auto_memory_directory || null, model_overrides: config.model_overrides || null, + session_name: null, }, }); diff --git a/src/lib/components/MemoryBrowserPanel.svelte b/src/lib/components/MemoryBrowserPanel.svelte index 72d1bb3..b9088fc 100644 --- a/src/lib/components/MemoryBrowserPanel.svelte +++ b/src/lib/components/MemoryBrowserPanel.svelte @@ -14,6 +14,7 @@ interface MemoryFileInfo { path: string; heading: string | null; + last_modified?: string; // Unix timestamp in seconds as a string, optional for backwards compat } interface MemoryFilesResponse { @@ -65,6 +66,16 @@ return file.heading ?? getFileName(file.path); } + function formatLastModified(ts: string | undefined): string { + if (!ts) return ""; + const date = new Date(Number(ts) * 1000); + return date.toLocaleDateString(undefined, { + year: "numeric", + month: "short", + day: "numeric", + }); + } + async function sendMemoryCommand() { const conversationId = get(claudeStore.activeConversationId); if (!conversationId) return; @@ -205,7 +216,12 @@ d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" /> - {getDisplayName(file)} +
+ {getDisplayName(file)} + {#if file.last_modified} + {formatLastModified(file.last_modified)} + {/if} +
{/each} @@ -416,7 +432,7 @@ .file-item { display: flex; - align-items: center; + align-items: flex-start; gap: 0.75rem; padding: 0.75rem 1rem; background: var(--bg-secondary); @@ -445,6 +461,13 @@ flex-shrink: 0; } + .file-info { + display: flex; + flex-direction: column; + gap: 0.125rem; + overflow: hidden; + } + .file-name { font-size: 0.875rem; font-weight: 500; @@ -453,6 +476,15 @@ white-space: nowrap; } + .file-date { + font-size: 0.75rem; + color: var(--text-secondary); + } + + .file-item.active .file-date { + color: rgba(255, 255, 255, 0.75); + } + .file-viewer { display: flex; flex-direction: column; diff --git a/src/lib/components/PermissionModal.svelte b/src/lib/components/PermissionModal.svelte index f4035f3..9f8f7e1 100644 --- a/src/lib/components/PermissionModal.svelte +++ b/src/lib/components/PermissionModal.svelte @@ -93,6 +93,7 @@ enable_claudeai_mcp_servers: config.enable_claudeai_mcp_servers ?? true, auto_memory_directory: config.auto_memory_directory || null, model_overrides: config.model_overrides || null, + session_name: null, }, }); diff --git a/src/lib/components/StatusBar.svelte b/src/lib/components/StatusBar.svelte index 9b69a63..a4d9887 100644 --- a/src/lib/components/StatusBar.svelte +++ b/src/lib/components/StatusBar.svelte @@ -160,6 +160,7 @@ if (!conversationId) { throw new Error("No active conversation"); } + const activeConversationForName = get(conversationsStore.activeConversation); await invoke("start_claude", { conversationId, options: { @@ -177,6 +178,7 @@ enable_claudeai_mcp_servers: currentConfig.enable_claudeai_mcp_servers ?? true, auto_memory_directory: currentConfig.auto_memory_directory || null, model_overrides: currentConfig.model_overrides || null, + session_name: activeConversationForName?.name || null, }, }); @@ -338,6 +340,7 @@ enable_claudeai_mcp_servers: currentConfig.enable_claudeai_mcp_servers ?? true, auto_memory_directory: currentConfig.auto_memory_directory || null, model_overrides: currentConfig.model_overrides || null, + session_name: null, }, }); diff --git a/src/lib/components/TaskLoopPanel.svelte b/src/lib/components/TaskLoopPanel.svelte index cacd72a..82daaba 100644 --- a/src/lib/components/TaskLoopPanel.svelte +++ b/src/lib/components/TaskLoopPanel.svelte @@ -222,6 +222,7 @@ enable_claudeai_mcp_servers: cfg.enable_claudeai_mcp_servers ?? true, auto_memory_directory: cfg.auto_memory_directory || null, model_overrides: cfg.model_overrides || null, + session_name: null, }, }); } catch (error) { diff --git a/src/lib/components/UserQuestionModal.svelte b/src/lib/components/UserQuestionModal.svelte index bcaf500..e887b7f 100644 --- a/src/lib/components/UserQuestionModal.svelte +++ b/src/lib/components/UserQuestionModal.svelte @@ -112,6 +112,7 @@ enable_claudeai_mcp_servers: config.enable_claudeai_mcp_servers ?? true, auto_memory_directory: config.auto_memory_directory || null, model_overrides: config.model_overrides || null, + session_name: null, }, }); diff --git a/src/lib/stores/claude.ts b/src/lib/stores/claude.ts index 3b3cdcb..246e8b5 100644 --- a/src/lib/stores/claude.ts +++ b/src/lib/stores/claude.ts @@ -22,6 +22,7 @@ export const claudeStore = { terminalLines: conversationsStore.terminalLines, pendingPermission: conversationsStore.pendingPermission, pendingQuestion: conversationsStore.pendingQuestion, + pendingElicitation: conversationsStore.pendingElicitation, isProcessing: conversationsStore.isProcessing, grantedTools: conversationsStore.grantedTools, pendingRetryMessage: conversationsStore.pendingRetryMessage, @@ -57,6 +58,10 @@ export const claudeStore = { clearQuestion: conversationsStore.clearQuestion, requestQuestionForConversation: conversationsStore.requestQuestionForConversation, clearQuestionForConversation: conversationsStore.clearQuestionForConversation, + requestElicitation: conversationsStore.requestElicitation, + clearElicitation: conversationsStore.clearElicitation, + requestElicitationForConversation: conversationsStore.requestElicitationForConversation, + clearElicitationForConversation: conversationsStore.clearElicitationForConversation, grantTool: conversationsStore.grantTool, revokeAllTools: conversationsStore.revokeAllTools, isToolGranted: conversationsStore.isToolGranted, @@ -126,6 +131,12 @@ export const hasQuestionPending = derived( ($conversation) => $conversation?.pendingQuestion !== null ); +export const hasElicitationPending = derived( + claudeStore.activeConversation, + ($conversation) => + $conversation?.pendingElicitation !== null && $conversation?.pendingElicitation !== undefined +); + // Derived store to check if Claude is currently processing (can be interrupted) export const isClaudeProcessing = derived( [claudeStore.connectionStatus, characterState], diff --git a/src/lib/stores/conversations.ts b/src/lib/stores/conversations.ts index cf8bf31..f34615a 100644 --- a/src/lib/stores/conversations.ts +++ b/src/lib/stores/conversations.ts @@ -2,6 +2,7 @@ import { writable, derived, get } from "svelte/store"; import type { TerminalLine, ConnectionStatus, + ElicitationEvent, PermissionRequest, UserQuestionEvent, Attachment, @@ -32,6 +33,7 @@ export interface Conversation { grantedTools: Set; pendingPermissions: PermissionRequest[]; pendingQuestion: UserQuestionEvent | null; + pendingElicitation: ElicitationEvent | null; scrollPosition: number; createdAt: Date; lastActivityAt: Date; @@ -157,6 +159,7 @@ function createConversationsStore() { grantedTools: new Set(), pendingPermissions: [], pendingQuestion: null, + pendingElicitation: null, scrollPosition: -1, // -1 means "scroll to bottom" (auto-scroll) createdAt: new Date(), lastActivityAt: new Date(), @@ -221,6 +224,10 @@ function createConversationsStore() { ($conv) => $conv?.pendingPermissions || [] ); const pendingQuestion = derived(activeConversation, ($conv) => $conv?.pendingQuestion || null); + const pendingElicitation = derived( + activeConversation, + ($conv) => $conv?.pendingElicitation ?? null + ); const scrollPosition = derived(activeConversation, ($conv) => $conv?.scrollPosition ?? -1); const attachments = derived(activeConversation, ($conv) => $conv?.attachments || []); const worktreeInfo = derived(activeConversation, ($conv) => $conv?.worktreeInfo ?? null); @@ -234,6 +241,7 @@ function createConversationsStore() { pendingPermission: { subscribe: pendingPermission.subscribe }, pendingPermissions: { subscribe: pendingPermissions.subscribe }, pendingQuestion: { subscribe: pendingQuestion.subscribe }, + pendingElicitation: { subscribe: pendingElicitation.subscribe }, isProcessing: { subscribe: isProcessing.subscribe }, grantedTools: { subscribe: grantedTools.subscribe }, pendingRetryMessage: { subscribe: pendingRetryMessage.subscribe }, @@ -399,6 +407,52 @@ function createConversationsStore() { return convs; }); }, + requestElicitation: (elicitation: ElicitationEvent) => { + const activeId = get(activeConversationId); + if (!activeId) return; + + conversations.update((convs) => { + const conv = convs.get(activeId); + if (conv) { + conv.pendingElicitation = elicitation; + conv.lastActivityAt = new Date(); + } + return convs; + }); + }, + clearElicitation: () => { + const activeId = get(activeConversationId); + if (!activeId) return; + + conversations.update((convs) => { + const conv = convs.get(activeId); + if (conv) { + conv.pendingElicitation = null; + conv.lastActivityAt = new Date(); + } + return convs; + }); + }, + requestElicitationForConversation: (conversationId: string, elicitation: ElicitationEvent) => { + conversations.update((convs) => { + const conv = convs.get(conversationId); + if (conv) { + conv.pendingElicitation = elicitation; + conv.lastActivityAt = new Date(); + } + return convs; + }); + }, + clearElicitationForConversation: (conversationId: string) => { + conversations.update((convs) => { + const conv = convs.get(conversationId); + if (conv) { + conv.pendingElicitation = null; + conv.lastActivityAt = new Date(); + } + return convs; + }); + }, setPendingRetryMessage: (message: string | null) => pendingRetryMessage.set(message), // Conversation management diff --git a/src/lib/stores/toasts.test.ts b/src/lib/stores/toasts.test.ts index 113baed..0066e37 100644 --- a/src/lib/stores/toasts.test.ts +++ b/src/lib/stores/toasts.test.ts @@ -187,6 +187,33 @@ describe("toastStore", () => { }); }); + describe("addError", () => { + it("adds an error toast with the warning icon", () => { + toastStore.addError("Something went wrong"); + 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("Something went wrong"); + expect(toast.icon).toBe("⚠️"); + expect(typeof toast.id).toBe("string"); + expect(toast.id.length).toBeGreaterThan(0); + } + }); + + it("auto-dismisses after 6000ms", () => { + toastStore.addError("Rate limit reached"); + expect(get(toastStore)).toHaveLength(1); + + vi.advanceTimersByTime(5999); + 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"); diff --git a/src/lib/stores/toasts.ts b/src/lib/stores/toasts.ts index 897d357..423b518 100644 --- a/src/lib/stores/toasts.ts +++ b/src/lib/stores/toasts.ts @@ -68,6 +68,13 @@ function createToastStore() { setTimeout(() => remove(id), 4000); } + function addError(message: string) { + const id = crypto.randomUUID(); + const toast: InfoToast = { id, kind: "info", message, icon: "⚠️" }; + update((toasts) => [...toasts, toast]); + setTimeout(() => remove(id), 6000); + } + function addAchievement(achievement: AchievementUnlockedEvent["achievement"]) { const id = crypto.randomUUID(); const toast: AchievementToast = { id, kind: "achievement", achievement }; @@ -82,7 +89,7 @@ function createToastStore() { // Update toasts are persistent — no auto-dismiss } - return { subscribe, addInfo, addAchievement, addUpdate, remove }; + return { subscribe, addInfo, addError, addAchievement, addUpdate, remove }; } export const toastStore = createToastStore(); diff --git a/src/lib/tauri.ts b/src/lib/tauri.ts index c1e5252..ac33c02 100644 --- a/src/lib/tauri.ts +++ b/src/lib/tauri.ts @@ -8,7 +8,10 @@ import { initStatsListener, resetSessionStats } from "$lib/stores/stats"; import { initAchievementsListener } from "$lib/stores/achievements"; import type { ConnectionStatus, + ElicitationEvent, PermissionPromptEvent, + PostCompactEvent, + StopFailureEvent, UserQuestionEvent, } from "$lib/types/messages"; import type { CharacterState } from "$lib/types/states"; @@ -406,7 +409,8 @@ export async function initializeTauriListeners() { | "rate-limit" | "compact-prompt" | "worktree" - | "config-change", + | "config-change" + | "elicitation", content, tool_name || undefined, costData, @@ -425,7 +429,8 @@ export async function initializeTauriListeners() { | "rate-limit" | "compact-prompt" | "worktree" - | "config-change", + | "config-change" + | "elicitation", content, tool_name || undefined, costData, @@ -611,6 +616,56 @@ export async function initializeTauriListeners() { } }); unlisteners.push(questionUnlisten); + + const elicitationUnlisten = await listen("claude:elicitation", (event) => { + const elicitationEvent = event.payload; + if (elicitationEvent.conversation_id) { + claudeStore.requestElicitationForConversation( + elicitationEvent.conversation_id, + elicitationEvent + ); + } else { + claudeStore.requestElicitation(elicitationEvent); + } + }); + unlisteners.push(elicitationUnlisten); + + const elicitationResultUnlisten = await listen<{ conversation_id?: string }>( + "claude:elicitation-result", + (event) => { + const { conversation_id } = event.payload; + if (conversation_id) { + claudeStore.clearElicitationForConversation(conversation_id); + } else { + claudeStore.clearElicitation(); + } + } + ); + unlisteners.push(elicitationResultUnlisten); + + const stopFailureUnlisten = await listen("claude:stop-failure", (event) => { + const { stop_reason, error_type } = event.payload; + + characterState.setTemporaryState("error", 3000); + + let message: string; + if (stop_reason === "rate_limit") { + message = "Rate limit reached"; + } else if (stop_reason === "auth_failure" || stop_reason === "authentication") { + message = "Authentication failed"; + } else { + message = `API error: ${stop_reason ?? error_type ?? "unknown"}`; + } + + toastStore.addError(message); + }); + unlisteners.push(stopFailureUnlisten); + + const postCompactUnlisten = await listen("claude:post-compact", () => { + toastStore.addInfo("Context compacted", "🗜️"); + characterState.setTemporaryState("success", 2000); + }); + unlisteners.push(postCompactUnlisten); } export function cleanupTauriListeners() { diff --git a/src/lib/types/messages.ts b/src/lib/types/messages.ts index 966ebb1..754f94c 100644 --- a/src/lib/types/messages.ts +++ b/src/lib/types/messages.ts @@ -10,7 +10,8 @@ export interface TerminalLine { | "rate-limit" | "compact-prompt" | "worktree" - | "config-change"; + | "config-change" + | "elicitation"; content: string; timestamp: Date; toolName?: string; @@ -162,6 +163,30 @@ export interface UserQuestionEvent { conversation_id?: string; } +export interface ElicitationEvent { + message: string; + server_name?: string; + request_id?: string; + conversation_id?: string; +} + +export interface ElicitationResultEvent { + action: string; + request_id?: string; + conversation_id?: string; +} + +export interface StopFailureEvent { + stop_reason?: string; + error_type?: string; + conversation_id?: string; +} + +export interface PostCompactEvent { + session_id?: string; + conversation_id?: string; +} + export type ConnectionStatus = "disconnected" | "connecting" | "connected" | "error"; export interface Attachment { diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index 5aae88d..e77d9e8 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -36,6 +36,7 @@ import type { CharacterState } from "$lib/types/states"; import PermissionModal from "$lib/components/PermissionModal.svelte"; import UserQuestionModal from "$lib/components/UserQuestionModal.svelte"; + import ElicitationModal from "$lib/components/ElicitationModal.svelte"; import ConfigSidebar from "$lib/components/ConfigSidebar.svelte"; import AchievementsPanel from "$lib/components/AchievementsPanel.svelte"; import ToastContainer from "$lib/components/ToastContainer.svelte"; @@ -593,6 +594,7 @@ +