diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 60d8cb3..ec07102 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -1360,6 +1360,136 @@ pub async fn get_claude_version() -> Result { } } +// ==================== Auth Commands ==================== + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ClaudeAuthStatus { + pub is_logged_in: bool, + pub email: Option, + pub org_name: Option, + pub api_key_source: Option, + pub api_provider: Option, + pub subscription_type: Option, +} + +#[tauri::command] +pub async fn get_auth_status() -> Result { + tracing::debug!("Getting Claude auth status"); + + let output = create_claude_command() + .args(["auth", "status"]) + .output() + .map_err(|e| format!("Failed to run claude auth status: {}", e))?; + + let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string(); + let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); + let raw = if stdout.is_empty() { &stderr } else { &stdout }; + + if let Ok(json) = serde_json::from_str::(raw) { + let is_logged_in = json + .get("loggedIn") + .and_then(|v| v.as_bool()) + .unwrap_or(false); + + let email = json + .get("email") + .and_then(|v| v.as_str()) + .map(String::from); + + let org_name = json + .get("orgName") + .and_then(|v| v.as_str()) + .map(String::from); + + let api_key_source = json + .get("apiKeySource") + .and_then(|v| v.as_str()) + .map(String::from); + + let api_provider = json + .get("apiProvider") + .and_then(|v| v.as_str()) + .map(String::from); + + let subscription_type = json + .get("subscriptionType") + .and_then(|v| v.as_str()) + .map(String::from); + + tracing::info!("Claude auth status: logged_in={}", is_logged_in); + Ok(ClaudeAuthStatus { + is_logged_in, + email, + org_name, + api_key_source, + api_provider, + subscription_type, + }) + } else { + // Non-JSON output: fall back to heuristic + let lower = raw.to_lowercase(); + let is_logged_in = output.status.success() + && !lower.contains("not logged in") + && !lower.contains("not authenticated") + && !lower.contains("no account"); + tracing::info!("Claude auth status (non-JSON): logged_in={}", is_logged_in); + Ok(ClaudeAuthStatus { + is_logged_in, + email: None, + org_name: None, + api_key_source: None, + api_provider: None, + subscription_type: None, + }) + } +} + +#[tauri::command] +pub async fn auth_login() -> Result { + tracing::info!("Running claude auth login"); + + let output = create_claude_command() + .args(["auth", "login"]) + .output() + .map_err(|e| format!("Failed to run claude auth login: {}", e))?; + + let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string(); + let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); + + if output.status.success() { + let message = if stdout.is_empty() { "Login successful".to_string() } else { stdout }; + tracing::info!("Claude auth login succeeded"); + Ok(message) + } else { + let error = if stderr.is_empty() { stdout } else { stderr }; + tracing::error!("Claude auth login failed: {}", error); + Err(format!("Login failed: {}", error)) + } +} + +#[tauri::command] +pub async fn auth_logout() -> Result { + tracing::info!("Running claude auth logout"); + + let output = create_claude_command() + .args(["auth", "logout"]) + .output() + .map_err(|e| format!("Failed to run claude auth logout: {}", e))?; + + let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string(); + let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); + + if output.status.success() { + let message = if stdout.is_empty() { "Logged out successfully".to_string() } else { stdout }; + tracing::info!("Claude auth logout succeeded"); + Ok(message) + } else { + let error = if stderr.is_empty() { stdout } else { stderr }; + tracing::error!("Claude auth logout failed: {}", error); + Err(format!("Logout failed: {}", error)) + } +} + // ==================== Plugin Management Commands ==================== #[derive(Debug, Clone, Serialize, Deserialize)] diff --git a/src-tauri/src/config.rs b/src-tauri/src/config.rs index 0e0cfce..6a21c23 100644 --- a/src-tauri/src/config.rs +++ b/src-tauri/src/config.rs @@ -25,6 +25,12 @@ pub struct ClaudeStartOptions { #[serde(default)] pub resume_session_id: Option, + + #[serde(default)] + pub use_worktree: bool, + + #[serde(default)] + pub disable_1m_context: bool, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -113,6 +119,12 @@ pub struct HikariConfig { #[serde(default = "default_discord_rpc_enabled")] pub discord_rpc_enabled: bool, + + #[serde(default)] + pub use_worktree: bool, + + #[serde(default)] + pub disable_1m_context: bool, } impl Default for HikariConfig { @@ -145,6 +157,8 @@ impl Default for HikariConfig { budget_action: BudgetAction::Warn, budget_warning_threshold: 0.8, discord_rpc_enabled: true, + use_worktree: false, + disable_1m_context: false, } } } @@ -252,6 +266,8 @@ mod tests { assert_eq!(config.budget_action, BudgetAction::Warn); assert!((config.budget_warning_threshold - 0.8).abs() < f32::EPSILON); assert!(config.discord_rpc_enabled); + assert!(!config.use_worktree); + assert!(!config.disable_1m_context); } #[test] @@ -284,6 +300,8 @@ mod tests { budget_action: BudgetAction::Block, budget_warning_threshold: 0.75, discord_rpc_enabled: true, + use_worktree: true, + disable_1m_context: false, }; let json = serde_json::to_string(&config).unwrap(); diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 6c9c1a8..70aea97 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -195,6 +195,9 @@ pub fn run() { close_application, list_memory_files, get_claude_version, + get_auth_status, + auth_login, + auth_logout, list_plugins, install_plugin, uninstall_plugin, diff --git a/src-tauri/src/types.rs b/src-tauri/src/types.rs index 19c6153..8fa1a9e 100644 --- a/src-tauri/src/types.rs +++ b/src-tauri/src/types.rs @@ -63,6 +63,26 @@ pub struct PermissionDenial { pub tool_input: serde_json::Value, } +/// Rate limit information from a `rate_limit_event` message. +/// All fields are optional to ensure forward-compatibility as the Claude CLI evolves. +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct RateLimitInfo { + #[serde(default)] + pub requests_limit: Option, + #[serde(default)] + pub requests_remaining: Option, + #[serde(default)] + pub requests_reset: Option, + #[serde(default)] + pub tokens_limit: Option, + #[serde(default)] + pub tokens_remaining: Option, + #[serde(default)] + pub tokens_reset: Option, + #[serde(default)] + pub retry_after_ms: Option, +} + #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(tag = "type")] pub enum ClaudeMessage { @@ -100,6 +120,11 @@ pub enum ClaudeMessage { #[serde(default)] usage: Option, }, + #[serde(rename = "rate_limit_event")] + RateLimitEvent { + #[serde(default)] + rate_limit_info: RateLimitInfo, + }, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -280,6 +305,8 @@ pub struct AgentEndEvent { pub duration_ms: Option, #[serde(skip_serializing_if = "Option::is_none")] pub num_turns: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub last_assistant_message: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -446,4 +473,77 @@ mod tests { assert!(serialized.contains("\"input_tokens\":100")); assert!(serialized.contains("\"output_tokens\":50")); } + + #[test] + fn test_rate_limit_info_default() { + let info = RateLimitInfo::default(); + assert!(info.requests_limit.is_none()); + assert!(info.requests_remaining.is_none()); + assert!(info.requests_reset.is_none()); + assert!(info.tokens_limit.is_none()); + assert!(info.tokens_remaining.is_none()); + assert!(info.tokens_reset.is_none()); + assert!(info.retry_after_ms.is_none()); + } + + #[test] + fn test_rate_limit_event_deserialization_empty_info() { + let json = r#"{"type":"rate_limit_event","rate_limit_info":{}}"#; + let msg: ClaudeMessage = serde_json::from_str(json).unwrap(); + assert!(matches!(msg, ClaudeMessage::RateLimitEvent { .. })); + } + + #[test] + fn test_rate_limit_event_deserialization_no_info() { + // rate_limit_info field is optional via #[serde(default)] + let json = r#"{"type":"rate_limit_event"}"#; + let msg: ClaudeMessage = serde_json::from_str(json).unwrap(); + assert!(matches!(msg, ClaudeMessage::RateLimitEvent { .. })); + } + + #[test] + fn test_rate_limit_event_deserialization_with_data() { + let json = r#"{ + "type": "rate_limit_event", + "rate_limit_info": { + "requests_limit": 1000, + "requests_remaining": 0, + "requests_reset": "2024-01-01T00:01:00Z", + "tokens_limit": 50000, + "tokens_remaining": 0, + "tokens_reset": "2024-01-01T00:01:00Z", + "retry_after_ms": 60000 + } + }"#; + let msg: ClaudeMessage = serde_json::from_str(json).unwrap(); + if let ClaudeMessage::RateLimitEvent { rate_limit_info } = msg { + assert_eq!(rate_limit_info.requests_limit, Some(1000)); + assert_eq!(rate_limit_info.requests_remaining, Some(0)); + assert_eq!( + rate_limit_info.requests_reset, + Some("2024-01-01T00:01:00Z".to_string()) + ); + assert_eq!(rate_limit_info.retry_after_ms, Some(60000)); + } else { + panic!("Expected RateLimitEvent variant"); + } + } + + #[test] + fn test_rate_limit_event_ignores_unknown_fields() { + // Ensures forward-compat: unknown fields in rate_limit_info are silently ignored + let json = r#"{ + "type": "rate_limit_event", + "rate_limit_info": { + "requests_remaining": 0, + "some_future_field": "some_value" + } + }"#; + let msg: ClaudeMessage = serde_json::from_str(json).unwrap(); + if let ClaudeMessage::RateLimitEvent { rate_limit_info } = msg { + assert_eq!(rate_limit_info.requests_remaining, Some(0)); + } else { + panic!("Expected RateLimitEvent variant"); + } + } } diff --git a/src-tauri/src/wsl_bridge.rs b/src-tauri/src/wsl_bridge.rs index b06cd33..77119c6 100644 --- a/src-tauri/src/wsl_bridge.rs +++ b/src-tauri/src/wsl_bridge.rs @@ -265,6 +265,11 @@ impl WslBridge { } } + // Add worktree flag if requested + if options.use_worktree { + cmd.arg("--worktree"); + } + cmd.current_dir(working_dir); // Set API key as environment variable if specified @@ -274,6 +279,11 @@ impl WslBridge { } } + // Disable 1M context window if requested + if options.disable_1m_context { + cmd.env("CLAUDE_CODE_DISABLE_1M_CONTEXT", "1"); + } + cmd } else { // Running on Windows - use wsl with bash login shell to ensure PATH is loaded @@ -314,6 +324,11 @@ impl WslBridge { } } + // Disable 1M context window if requested + if options.disable_1m_context { + claude_cmd.push_str("CLAUDE_CODE_DISABLE_1M_CONTEXT=1 "); + } + claude_cmd.push_str( "claude --output-format stream-json --input-format stream-json --verbose", ); @@ -351,6 +366,11 @@ impl WslBridge { } } + // Add worktree flag if requested + if options.use_worktree { + claude_cmd.push_str(" --worktree"); + } + // Use bash -lc to load login profile (ensures PATH includes claude) cmd.args(["-e", "bash", "-lc", &claude_cmd]); @@ -745,17 +765,28 @@ fn handle_stderr( conversation_id: conversation_id.clone(), duration_ms: None, num_turns: None, + last_assistant_message: stop_data.last_assistant_message, }, ); } } } - // Still emit the stderr line as output + // Hook events are informational — emit with distinct types instead of error + let line_type = if line.contains("[WorktreeCreate Hook]") + || line.contains("[WorktreeRemove Hook]") + { + "worktree" + } else if line.contains("[ConfigChange Hook]") { + "config-change" + } else { + "error" + }; + let _ = app.emit( "claude:output", OutputEvent { - line_type: "error".to_string(), + line_type: line_type.to_string(), content: line, tool_name: None, conversation_id: conversation_id.clone(), @@ -808,27 +839,78 @@ fn parse_subagent_start_hook(line: &str) -> Option { #[derive(Debug)] struct SubagentStopData { parent_tool_use_id: Option, + last_assistant_message: Option, +} + +/// Extracts the content of a Rust Debug-formatted `Some("...")` field from a hook line. +/// Handles escaped characters (e.g. `\"` → `"`, `\\` → `\`, `\n` → newline). +/// Returns `None` if the field is absent or formatted as `None`. +fn extract_debug_string_value(line: &str, key: &str) -> Option { + let prefix = format!("{}=Some(\"", 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 } fn parse_subagent_stop_hook(line: &str) -> Option { - // Parse: [SubagentStop Hook] ... parent_tool_use_id=Some("toolu_xxx"), ... + // Parse: [SubagentStop Hook] ... parent_tool_use_id=Some("toolu_xxx"), last_assistant_message=Some("..."), ... - // 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(\"") - .nth(1)? - .split('"') - .next() - .map(|s| s.to_string()) - } else { - None - }; + let parent_tool_use_id = extract_debug_string_value(line, "parent_tool_use_id"); + let last_assistant_message = extract_debug_string_value(line, "last_assistant_message"); Some(SubagentStopData { parent_tool_use_id, + last_assistant_message, }) } +/// 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 { + match content { + serde_json::Value::String(s) if !s.is_empty() => Some(s.clone()), + serde_json::Value::Array(blocks) => { + let texts: Vec = blocks + .iter() + .filter_map(|block| { + if block.get("type")?.as_str()? == "text" { + block.get("text")?.as_str().map(String::from) + } else { + None + } + }) + .collect(); + if texts.is_empty() { + None + } else { + Some(texts.join("\n")) + } + } + _ => None, + } +} + fn process_json_line( line: &str, app: &AppHandle, @@ -1082,17 +1164,37 @@ fn process_json_line( stats.write().increment_code_blocks(); } + let is_prompt_too_long = text.starts_with("Prompt is too long"); + let _ = app.emit( "claude:output", OutputEvent { - line_type: "assistant".to_string(), + line_type: if is_prompt_too_long { + "error".to_string() + } else { + "assistant".to_string() + }, content: text.clone(), tool_name: None, conversation_id: conversation_id.clone(), - cost: message_cost.clone(), // Include cost with assistant text + cost: message_cost.clone(), parent_tool_use_id: parent_tool_use_id.clone(), }, ); + + if is_prompt_too_long { + let _ = app.emit( + "claude:output", + OutputEvent { + line_type: "compact-prompt".to_string(), + content: String::new(), + tool_name: None, + conversation_id: conversation_id.clone(), + cost: None, + parent_tool_use_id: None, + }, + ); + } } ContentBlock::Thinking { thinking } => { state = CharacterState::Thinking; @@ -1110,8 +1212,8 @@ fn process_json_line( } ContentBlock::ToolResult { tool_use_id, + content, is_error, - .. } => { // Emit agent-end for all tool results // The frontend will ignore IDs that don't match known agents @@ -1129,6 +1231,7 @@ fn process_json_line( conversation_id: conversation_id.clone(), duration_ms: None, num_turns: None, + last_assistant_message: extract_tool_result_text(content), }, ); } @@ -1521,6 +1624,23 @@ fn process_json_line( emit_state_change(app, state, None, conversation_id.clone()); } + ClaudeMessage::RateLimitEvent { rate_limit_info } => { + tracing::warn!("Rate limit event received: {:?}", rate_limit_info); + + let content = format_rate_limit_message(rate_limit_info); + let _ = app.emit( + "claude:output", + OutputEvent { + line_type: "rate-limit".to_string(), + content, + tool_name: None, + conversation_id: conversation_id.clone(), + cost: None, + parent_tool_use_id: None, + }, + ); + } + ClaudeMessage::User { message } => { // Increment message count for user messages stats.write().increment_messages(); @@ -1529,8 +1649,8 @@ fn process_json_line( for block in &message.content { if let ContentBlock::ToolResult { tool_use_id, + content, is_error, - .. } = block { let now = SystemTime::now() @@ -1547,6 +1667,7 @@ fn process_json_line( conversation_id: conversation_id.clone(), duration_ms: None, num_turns: None, + last_assistant_message: extract_tool_result_text(content), }, ); } @@ -1629,6 +1750,35 @@ fn get_tool_state(tool_name: &str) -> CharacterState { } } +fn format_rate_limit_message(info: &crate::types::RateLimitInfo) -> String { + let mut parts = Vec::new(); + + if let (Some(remaining), Some(limit)) = (info.requests_remaining, info.requests_limit) { + parts.push(format!("requests: {}/{}", remaining, limit)); + } + + if let (Some(remaining), Some(limit)) = (info.tokens_remaining, info.tokens_limit) { + parts.push(format!("tokens: {}/{}", remaining, limit)); + } + + if let Some(reset) = &info.requests_reset { + parts.push(format!("resets at {}", reset)); + } else if let Some(reset) = &info.tokens_reset { + parts.push(format!("resets at {}", reset)); + } + + if let Some(retry_ms) = info.retry_after_ms { + let secs = retry_ms / 1000; + parts.push(format!("retry after {}s", secs)); + } + + if parts.is_empty() { + "Rate limit reached".to_string() + } else { + format!("Rate limit reached — {}", parts.join(", ")) + } +} + fn format_tool_description(name: &str, input: &serde_json::Value) -> String { // Helper function to check if a path is a memory file fn is_memory_path(path: &str) -> bool { @@ -1689,12 +1839,7 @@ fn format_tool_description(name: &str, input: &serde_json::Value) -> String { } "Bash" => { if let Some(cmd) = input.get("command").and_then(|v| v.as_str()) { - let truncated = if cmd.len() > 50 { - format!("{}...", &cmd[..50]) - } else { - cmd.to_string() - }; - format!("Running: {}", truncated) + format!("Running: {}", cmd) } else { "Running command...".to_string() } @@ -1855,9 +2000,7 @@ mod tests { let long_cmd = "a".repeat(100); let input = serde_json::json!({"command": long_cmd}); let desc = format_tool_description("Bash", &input); - assert!(desc.starts_with("Running: ")); - assert!(desc.ends_with("...")); - assert!(desc.len() < 70); + assert_eq!(desc, format!("Running: {}", long_cmd)); } #[test] @@ -2076,5 +2219,140 @@ mod tests { assert!(result.is_some()); let data = result.unwrap(); assert_eq!(data.parent_tool_use_id, None); + assert_eq!(data.last_assistant_message, None); + } + + #[test] + fn test_parse_subagent_stop_hook_with_last_message() { + let line = r#"[SubagentStop Hook] stop_hook_active=true, parent_tool_use_id=Some("toolu_01ABC123"), last_assistant_message=Some("Task completed successfully."), session_id=123"#; + let result = parse_subagent_stop_hook(line); + + assert!(result.is_some()); + let data = result.unwrap(); + assert_eq!(data.parent_tool_use_id, Some("toolu_01ABC123".to_string())); + assert_eq!( + data.last_assistant_message, + Some("Task completed successfully.".to_string()) + ); + } + + #[test] + fn test_parse_subagent_stop_hook_with_last_message_none() { + let line = r#"[SubagentStop Hook] stop_hook_active=true, parent_tool_use_id=Some("toolu_01ABC123"), last_assistant_message=None, session_id=123"#; + let result = parse_subagent_stop_hook(line); + + assert!(result.is_some()); + let data = result.unwrap(); + assert_eq!(data.last_assistant_message, None); + } + + #[test] + fn test_extract_debug_string_value_simple() { + let line = r#"key=Some("hello world")"#; + assert_eq!( + extract_debug_string_value(line, "key"), + Some("hello world".to_string()) + ); + } + + #[test] + fn test_extract_debug_string_value_with_escaped_quotes() { + let line = r#"key=Some("say \"hi\" there")"#; + assert_eq!( + extract_debug_string_value(line, "key"), + Some(r#"say "hi" there"#.to_string()) + ); + } + + #[test] + fn test_extract_debug_string_value_none_variant() { + let line = "key=None"; + assert_eq!(extract_debug_string_value(line, "key"), None); + } + + #[test] + fn test_extract_debug_string_value_missing_key() { + let line = "other=Some(\"value\")"; + assert_eq!(extract_debug_string_value(line, "key"), None); + } + + #[test] + fn test_parse_subagent_stop_hook_with_commas_in_message() { + let line = r#"[SubagentStop Hook] parent_tool_use_id=Some("toolu_01"), last_assistant_message=Some("Found 3 files, all passing.")"#; + let result = parse_subagent_stop_hook(line); + + assert!(result.is_some()); + let data = result.unwrap(); + assert_eq!( + data.last_assistant_message, + Some("Found 3 files, all passing.".to_string()) + ); + } + + // extract_tool_result_text tests + #[test] + fn test_extract_tool_result_text_plain_string() { + let content = serde_json::json!("Hello from agent"); + assert_eq!( + extract_tool_result_text(&content), + Some("Hello from agent".to_string()) + ); + } + + #[test] + fn test_extract_tool_result_text_empty_string() { + let content = serde_json::json!(""); + assert_eq!(extract_tool_result_text(&content), None); + } + + #[test] + fn test_extract_tool_result_text_array_single_text_block() { + let content = serde_json::json!([{"type": "text", "text": "Agent completed the task."}]); + assert_eq!( + extract_tool_result_text(&content), + Some("Agent completed the task.".to_string()) + ); + } + + #[test] + fn test_extract_tool_result_text_array_multiple_text_blocks() { + let content = serde_json::json!([ + {"type": "text", "text": "First part."}, + {"type": "text", "text": "Second part."} + ]); + assert_eq!( + extract_tool_result_text(&content), + Some("First part.\nSecond part.".to_string()) + ); + } + + #[test] + fn test_extract_tool_result_text_array_non_text_block() { + let content = serde_json::json!([{"type": "image", "source": {"type": "base64"}}]); + assert_eq!(extract_tool_result_text(&content), None); + } + + #[test] + fn test_extract_tool_result_text_array_mixed_blocks() { + let content = serde_json::json!([ + {"type": "image", "source": {}}, + {"type": "text", "text": "Found results."} + ]); + assert_eq!( + extract_tool_result_text(&content), + Some("Found results.".to_string()) + ); + } + + #[test] + fn test_extract_tool_result_text_null() { + let content = serde_json::Value::Null; + assert_eq!(extract_tool_result_text(&content), None); + } + + #[test] + fn test_extract_tool_result_text_empty_array() { + let content = serde_json::json!([]); + assert_eq!(extract_tool_result_text(&content), None); } } diff --git a/src/lib/commands/slashCommands.ts b/src/lib/commands/slashCommands.ts index a1e4147..8d252c8 100644 --- a/src/lib/commands/slashCommands.ts +++ b/src/lib/commands/slashCommands.ts @@ -61,6 +61,8 @@ async function changeDirectory(path: string): Promise { custom_instructions: config.custom_instructions || null, mcp_servers_json: config.mcp_servers_json || null, allowed_tools: allAllowedTools, + use_worktree: config.use_worktree ?? false, + disable_1m_context: config.disable_1m_context ?? false, }, }); @@ -135,6 +137,8 @@ async function startNewConversation(): Promise { custom_instructions: config.custom_instructions || null, mcp_servers_json: config.mcp_servers_json || null, allowed_tools: allAllowedTools, + use_worktree: config.use_worktree ?? false, + disable_1m_context: config.disable_1m_context ?? false, }, }); diff --git a/src/lib/components/AgentMonitorPanel.svelte b/src/lib/components/AgentMonitorPanel.svelte index ef7770c..6efc412 100644 --- a/src/lib/components/AgentMonitorPanel.svelte +++ b/src/lib/components/AgentMonitorPanel.svelte @@ -318,6 +318,16 @@ Errored / Killed {/if} + + + {#if agent.lastAssistantMessage} +

+ {agent.lastAssistantMessage} +

+ {/if} {/each} {/if} diff --git a/src/lib/components/CliVersion.svelte b/src/lib/components/CliVersion.svelte index affb99a..273fea4 100644 --- a/src/lib/components/CliVersion.svelte +++ b/src/lib/components/CliVersion.svelte @@ -2,15 +2,43 @@ import { invoke } from "@tauri-apps/api/core"; import { onMount } from "svelte"; - let version = $state("Loading..."); + const SUPPORTED_CLI_VERSION = "2.1.50"; + + let installedVersion = $state("Loading..."); + + function compareVersions(a: string, b: string): number { + const aParts = a.split(".").map(Number); + const bParts = b.split(".").map(Number); + for (let i = 0; i < Math.max(aParts.length, bParts.length); i++) { + const aVal = aParts[i] ?? 0; + const bVal = bParts[i] ?? 0; + if (aVal > bVal) return 1; + if (aVal < bVal) return -1; + } + return 0; + } + + let displayVersion = $derived(installedVersion.split(" (")[0]); + + let supportedBadgeState = $derived.by(() => { + if (installedVersion === "Loading..." || installedVersion === "Unknown") { + return "neutral"; + } + const semverMatch = /(\d+\.\d+\.\d+)/.exec(installedVersion); + if (!semverMatch) return "neutral"; + const cmp = compareVersions(semverMatch[1], SUPPORTED_CLI_VERSION); + if (cmp > 0) return "ahead"; + if (cmp < 0) return "behind"; + return "current"; + }); async function fetchVersion() { try { const result = await invoke("get_claude_version"); - version = result; + installedVersion = result; } catch (error) { console.error("Failed to get Claude CLI version:", error); - version = "Unknown"; + installedVersion = "Unknown"; } } @@ -19,25 +47,60 @@ }); -
- - - - - CLI {version} +
+
+ + + + + CLI {displayVersion} +
+ +
+ + + + Supported {SUPPORTED_CLI_VERSION} +
+ + {#if supportedBadgeState === "ahead"} + Your version is newer, some features may not be supported + {:else if supportedBadgeState === "behind"} + Your version is out of date, please update to ensure compatibility + {/if}
diff --git a/src/lib/components/ConfigSidebar.svelte b/src/lib/components/ConfigSidebar.svelte index 80ed171..c4905f0 100644 --- a/src/lib/components/ConfigSidebar.svelte +++ b/src/lib/components/ConfigSidebar.svelte @@ -12,6 +12,7 @@ } from "$lib/stores/config"; import { claudeStore } from "$lib/stores/claude"; import { getCurrentWindow } from "@tauri-apps/api/window"; + import { invoke } from "@tauri-apps/api/core"; import CostSummary from "./CostSummary.svelte"; let config: HikariConfig = $state({ @@ -52,10 +53,26 @@ budget_warning_threshold: 0.8, discord_rpc_enabled: true, show_thinking_blocks: true, + use_worktree: false, + disable_1m_context: false, }); let showCustomThemeEditor = $state(false); + interface AuthStatus { + is_logged_in: boolean; + email: string | null; + org_name: string | null; + api_key_source: string | null; + api_provider: string | null; + subscription_type: string | null; + } + + let authStatus: AuthStatus | null = $state(null); + let authLoading = $state(false); + let authActionLoading = $state(false); + let authError: string | null = $state(null); + let isOpen = $state(false); let isSaving = $state(false); let saveError: string | null = $state(null); @@ -69,6 +86,9 @@ configStore.isSidebarOpen.subscribe((open) => { isOpen = open; + if (open && authStatus === null) { + void refreshAuthStatus(); + } }); configStore.saveError.subscribe((error) => { @@ -111,6 +131,44 @@ "Task", ]; + async function refreshAuthStatus() { + authLoading = true; + authError = null; + try { + authStatus = await invoke("get_auth_status"); + } catch (e) { + authError = String(e); + } finally { + authLoading = false; + } + } + + async function handleAuthLogin() { + authActionLoading = true; + authError = null; + try { + await invoke("auth_login"); + await refreshAuthStatus(); + } catch (e) { + authError = String(e); + } finally { + authActionLoading = false; + } + } + + async function handleAuthLogout() { + authActionLoading = true; + authError = null; + try { + await invoke("auth_logout"); + await refreshAuthStatus(); + } catch (e) { + authError = String(e); + } finally { + authActionLoading = false; + } + } + async function handleSave() { isSaving = true; saveError = null; @@ -228,6 +286,101 @@
{/if} + +
+

+ Account +

+ + {#if authLoading} +
Checking auth status...
+ {:else if authStatus} +
+ + + {authStatus.is_logged_in ? "Logged in" : "Not logged in"} + +
+ {#if authStatus.email || authStatus.org_name || authStatus.api_key_source || config.api_key} +
+ {#if authStatus.email} +
+
Email
+
{authStatus.email}
+
+ {/if} + {#if authStatus.org_name} +
+
Org
+
{authStatus.org_name}
+
+ {/if} + {#if authStatus.api_key_source} +
+
API key
+
{authStatus.api_key_source}
+
+ {/if} + {#if authStatus.subscription_type} +
+
Plan
+
{authStatus.subscription_type}
+
+ {/if} +
+
Override
+
+ {#if config.api_key} + {config.streamer_mode ? "Custom key set 🔒" : "Custom key set"} + {:else} + None + {/if} +
+
+
+ {/if} + {:else} +
Auth status unavailable
+ {/if} + + {#if authError} +
+ {authError} +
+ {/if} + +
+ + {#if authStatus && !authStatus.is_logged_in} + + {:else if authStatus && authStatus.is_logged_in} + + {/if} +
+
+

@@ -322,6 +475,37 @@ class="w-full px-3 py-2 bg-[var(--bg-primary)] border border-[var(--border-color)] rounded-lg text-[var(--text-primary)] focus:outline-none focus:border-[var(--accent-primary)] resize-none" > + + +
+ +

+ Launch sessions with --worktree for isolated git worktree environments +

+
+ + +
+ +

+ Sets CLAUDE_CODE_DISABLE_1M_CONTEXT=1 to opt out of the extended + context window +

+

diff --git a/src/lib/components/InputBar.svelte b/src/lib/components/InputBar.svelte index f911354..62ea928 100644 --- a/src/lib/components/InputBar.svelte +++ b/src/lib/components/InputBar.svelte @@ -362,6 +362,8 @@ User: ${formattedMessage}`; custom_instructions: config.custom_instructions || null, mcp_servers_json: config.mcp_servers_json || null, allowed_tools: allAllowedTools, + use_worktree: config.use_worktree ?? false, + disable_1m_context: config.disable_1m_context ?? false, }, }); diff --git a/src/lib/components/PermissionModal.svelte b/src/lib/components/PermissionModal.svelte index 69e1f08..5943ff5 100644 --- a/src/lib/components/PermissionModal.svelte +++ b/src/lib/components/PermissionModal.svelte @@ -87,6 +87,8 @@ custom_instructions: config.custom_instructions || null, mcp_servers_json: config.mcp_servers_json || null, allowed_tools: newGrantedTools, + use_worktree: config.use_worktree ?? false, + disable_1m_context: config.disable_1m_context ?? false, }, }); diff --git a/src/lib/components/StatusBar.svelte b/src/lib/components/StatusBar.svelte index df53531..e4099b9 100644 --- a/src/lib/components/StatusBar.svelte +++ b/src/lib/components/StatusBar.svelte @@ -101,6 +101,8 @@ budget_warning_threshold: 0.8, discord_rpc_enabled: true, show_thinking_blocks: true, + use_worktree: false, + disable_1m_context: false, }); let streamerModeActive = $state(false); @@ -178,6 +180,8 @@ custom_instructions: currentConfig.custom_instructions || null, mcp_servers_json: currentConfig.mcp_servers_json || null, allowed_tools: allAllowedTools, + use_worktree: currentConfig.use_worktree ?? false, + disable_1m_context: currentConfig.disable_1m_context ?? false, }, }); @@ -289,6 +293,8 @@ custom_instructions: currentConfig.custom_instructions || null, mcp_servers_json: currentConfig.mcp_servers_json || null, allowed_tools: allAllowedTools, + use_worktree: currentConfig.use_worktree ?? false, + disable_1m_context: currentConfig.disable_1m_context ?? false, }, }); diff --git a/src/lib/components/Terminal.svelte b/src/lib/components/Terminal.svelte index 616231c..1d1f665 100644 --- a/src/lib/components/Terminal.svelte +++ b/src/lib/components/Terminal.svelte @@ -1,6 +1,8 @@
[{line.toolName}] {/if} - {#if line.type === "assistant" || line.type === "user"} + {#if line.type === "compact-prompt"} + + {:else if line.type === "assistant" || line.type === "user"}
{copiedMessageId === line.id ? "Copied!" : "Copy"}
+ {:else if line.type === "tool" && isToolContentLong(maskPaths(line.content, hidePaths))} + + + + {:else} diff --git a/src/lib/components/Terminal.test.ts b/src/lib/components/Terminal.test.ts new file mode 100644 index 0000000..4d6344d --- /dev/null +++ b/src/lib/components/Terminal.test.ts @@ -0,0 +1,264 @@ +/** + * Terminal Component Tests + * + * Tests the pure helper functions extracted from the Terminal component: + * - getLineClass: maps line types to CSS class names + * - getLinePrefix: maps line types to display prefixes + * - formatTime: formats a Date as "HH:MM AM/PM" + * - isToolContentLong: checks if tool content exceeds collapse threshold + * - truncateToolContent: truncates long tool content with ellipsis + * + * Manual testing checklist: + * - [ ] rate-limit lines appear in amber + * - [ ] error lines appear in red + * - [ ] tool lines appear in purple + * - [ ] system lines appear in grey italic + * - [ ] user lines appear in cyan + * - [ ] assistant lines appear in primary text colour + * - [ ] long tool content is collapsed by default with a toggle button + */ + +import { describe, it, expect } from "vitest"; + +// Mirror functions from Terminal.svelte for isolated testing + +function getLineClass(type: string): string { + switch (type) { + case "user": + return "terminal-user"; + case "assistant": + return "terminal-assistant"; + case "system": + return "terminal-system italic"; + case "tool": + return "terminal-tool"; + case "error": + return "terminal-error"; + case "thinking": + return "terminal-thinking"; + case "rate-limit": + return "terminal-rate-limit"; + case "compact-prompt": + return "terminal-compact-prompt"; + case "worktree": + return "terminal-worktree"; + case "config-change": + return "terminal-config-change"; + default: + return "terminal-default"; + } +} + +function getLinePrefix(type: string): string { + switch (type) { + case "user": + return ">"; + case "assistant": + return ""; + case "system": + return "[system]"; + case "tool": + return "[tool]"; + case "error": + return "[error]"; + case "rate-limit": + return "[rate-limit]"; + case "worktree": + return "[worktree]"; + case "config-change": + return "[config]"; + default: + return ""; + } +} + +function formatTime(date: Date): string { + return date.toLocaleTimeString("en-US", { + hour: "2-digit", + minute: "2-digit", + }); +} + +const TOOL_COLLAPSE_THRESHOLD = 60; + +function isToolContentLong(content: string): boolean { + return content.length > TOOL_COLLAPSE_THRESHOLD; +} + +function truncateToolContent(content: string): string { + return content.slice(0, TOOL_COLLAPSE_THRESHOLD) + "…"; +} + +// --- + +describe("getLineClass", () => { + it("returns terminal-user for user lines", () => { + expect(getLineClass("user")).toBe("terminal-user"); + }); + + it("returns terminal-assistant for assistant lines", () => { + expect(getLineClass("assistant")).toBe("terminal-assistant"); + }); + + it("returns terminal-system italic for system lines", () => { + expect(getLineClass("system")).toBe("terminal-system italic"); + }); + + it("returns terminal-tool for tool lines", () => { + expect(getLineClass("tool")).toBe("terminal-tool"); + }); + + it("returns terminal-error for error lines", () => { + expect(getLineClass("error")).toBe("terminal-error"); + }); + + it("returns terminal-thinking for thinking lines", () => { + expect(getLineClass("thinking")).toBe("terminal-thinking"); + }); + + it("returns terminal-rate-limit for rate-limit lines", () => { + expect(getLineClass("rate-limit")).toBe("terminal-rate-limit"); + }); + + it("returns terminal-compact-prompt for compact-prompt lines", () => { + expect(getLineClass("compact-prompt")).toBe("terminal-compact-prompt"); + }); + + it("returns terminal-worktree for worktree lines", () => { + expect(getLineClass("worktree")).toBe("terminal-worktree"); + }); + + it("returns terminal-config-change for config-change lines", () => { + expect(getLineClass("config-change")).toBe("terminal-config-change"); + }); + + it("returns terminal-default for unknown line types", () => { + expect(getLineClass("unknown")).toBe("terminal-default"); + expect(getLineClass("")).toBe("terminal-default"); + expect(getLineClass("random-future-type")).toBe("terminal-default"); + }); +}); + +describe("getLinePrefix", () => { + it("returns > for user lines", () => { + expect(getLinePrefix("user")).toBe(">"); + }); + + it("returns empty string for assistant lines", () => { + expect(getLinePrefix("assistant")).toBe(""); + }); + + it("returns [system] for system lines", () => { + expect(getLinePrefix("system")).toBe("[system]"); + }); + + it("returns [tool] for tool lines", () => { + expect(getLinePrefix("tool")).toBe("[tool]"); + }); + + it("returns [error] for error lines", () => { + expect(getLinePrefix("error")).toBe("[error]"); + }); + + it("returns [rate-limit] for rate-limit lines", () => { + expect(getLinePrefix("rate-limit")).toBe("[rate-limit]"); + }); + + it("returns empty string for compact-prompt lines (button renders instead)", () => { + expect(getLinePrefix("compact-prompt")).toBe(""); + }); + + it("returns [worktree] for worktree lines", () => { + expect(getLinePrefix("worktree")).toBe("[worktree]"); + }); + + it("returns [config] for config-change lines", () => { + expect(getLinePrefix("config-change")).toBe("[config]"); + }); + + it("returns empty string for thinking lines (no prefix)", () => { + expect(getLinePrefix("thinking")).toBe(""); + }); + + it("returns empty string for unknown line types", () => { + expect(getLinePrefix("unknown")).toBe(""); + expect(getLinePrefix("")).toBe(""); + }); +}); + +describe("formatTime", () => { + it("formats time in 12-hour format with AM/PM", () => { + const date = new Date(2026, 1, 7, 14, 35); + const formatted = formatTime(date); + expect(formatted).toMatch(/\d{2}:\d{2}\s?(AM|PM)/i); + }); + + it("formats afternoon times correctly", () => { + const date = new Date(2026, 1, 7, 14, 35); + const formatted = formatTime(date); + expect(formatted).toContain("02:35"); + expect(formatted.toUpperCase()).toContain("PM"); + }); + + it("formats morning times correctly", () => { + const date = new Date(2026, 1, 7, 9, 5); + const formatted = formatTime(date); + expect(formatted).toContain("09:05"); + expect(formatted.toUpperCase()).toContain("AM"); + }); + + it("formats midnight correctly", () => { + const date = new Date(2026, 1, 7, 0, 0); + const formatted = formatTime(date); + expect(formatted).toContain("12:00"); + expect(formatted.toUpperCase()).toContain("AM"); + }); + + it("formats noon correctly", () => { + const date = new Date(2026, 1, 7, 12, 0); + const formatted = formatTime(date); + expect(formatted).toContain("12:00"); + expect(formatted.toUpperCase()).toContain("PM"); + }); +}); + +describe("isToolContentLong", () => { + it("returns false for content at or below the threshold", () => { + const exactThreshold = "x".repeat(TOOL_COLLAPSE_THRESHOLD); + expect(isToolContentLong(exactThreshold)).toBe(false); + }); + + it("returns true for content exceeding the threshold", () => { + const overThreshold = "x".repeat(TOOL_COLLAPSE_THRESHOLD + 1); + expect(isToolContentLong(overThreshold)).toBe(true); + }); + + it("returns false for short content", () => { + expect(isToolContentLong("short")).toBe(false); + }); + + it("returns false for empty content", () => { + expect(isToolContentLong("")).toBe(false); + }); +}); + +describe("truncateToolContent", () => { + it("truncates content to the threshold length with an ellipsis", () => { + const long = "x".repeat(100); + const result = truncateToolContent(long); + expect(result).toBe("x".repeat(TOOL_COLLAPSE_THRESHOLD) + "…"); + }); + + it("keeps content shorter than threshold unchanged (plus ellipsis)", () => { + const short = "hello"; + const result = truncateToolContent(short); + expect(result).toBe("hello…"); + }); + + it("uses the unicode ellipsis character (not three dots)", () => { + const long = "x".repeat(100); + const result = truncateToolContent(long); + expect(result.endsWith("…")).toBe(true); + expect(result.endsWith("...")).toBe(false); + }); +}); diff --git a/src/lib/components/UserQuestionModal.svelte b/src/lib/components/UserQuestionModal.svelte index 822fc52..f26b3da 100644 --- a/src/lib/components/UserQuestionModal.svelte +++ b/src/lib/components/UserQuestionModal.svelte @@ -106,6 +106,8 @@ custom_instructions: config.custom_instructions || null, mcp_servers_json: config.mcp_servers_json || null, allowed_tools: grantedToolsList, + use_worktree: config.use_worktree ?? false, + disable_1m_context: config.disable_1m_context ?? false, }, }); diff --git a/src/lib/stores/agents.test.ts b/src/lib/stores/agents.test.ts index 1051186..5806c9e 100644 --- a/src/lib/stores/agents.test.ts +++ b/src/lib/stores/agents.test.ts @@ -177,6 +177,32 @@ describe("agents store", () => { const agents = get(getAgentsForConversation(conversationId)); expect(agents[0].status).toBe("running"); // Status unchanged }); + + it("stores lastAssistantMessage when provided", () => { + const agent = createMockAgent({ status: "running" }); + agentStore.addAgent(conversationId, agent); + + agentStore.endAgent( + conversationId, + agent.toolUseId, + Date.now(), + false, + "Task completed successfully." + ); + + const agents = get(getAgentsForConversation(conversationId)); + expect(agents[0].lastAssistantMessage).toBe("Task completed successfully."); + }); + + it("leaves lastAssistantMessage undefined when not provided", () => { + const agent = createMockAgent({ status: "running" }); + agentStore.addAgent(conversationId, agent); + + agentStore.endAgent(conversationId, agent.toolUseId, Date.now(), false); + + const agents = get(getAgentsForConversation(conversationId)); + expect(agents[0].lastAssistantMessage).toBeUndefined(); + }); }); describe("markAllErrored", () => { diff --git a/src/lib/stores/agents.ts b/src/lib/stores/agents.ts index 406a4ca..4ea5251 100644 --- a/src/lib/stores/agents.ts +++ b/src/lib/stores/agents.ts @@ -45,7 +45,13 @@ function createAgentStore() { }); }, - endAgent(conversationId: string, toolUseId: string, endedAt: number, isError: boolean) { + endAgent( + conversationId: string, + toolUseId: string, + endedAt: number, + isError: boolean, + lastAssistantMessage?: string + ) { agentsByConversation.update((state) => { const agents = state[conversationId]; if (!agents) return state; @@ -62,6 +68,7 @@ function createAgentStore() { endedAt, status: isError ? "errored" : "completed", durationMs, + lastAssistantMessage, }; return { diff --git a/src/lib/stores/config.test.ts b/src/lib/stores/config.test.ts index 5f9e625..883e48c 100644 --- a/src/lib/stores/config.test.ts +++ b/src/lib/stores/config.test.ts @@ -194,6 +194,8 @@ describe("config store", () => { budget_warning_threshold: 0.8, discord_rpc_enabled: true, show_thinking_blocks: true, + use_worktree: false, + disable_1m_context: false, }; expect(config.model).toBe("claude-sonnet-4"); @@ -240,6 +242,8 @@ describe("config store", () => { budget_warning_threshold: 0.8, discord_rpc_enabled: true, show_thinking_blocks: true, + use_worktree: false, + disable_1m_context: false, }; expect(config.model).toBeNull(); @@ -785,6 +789,8 @@ describe("config store", () => { budget_warning_threshold: 0.9, discord_rpc_enabled: false, show_thinking_blocks: true, + use_worktree: false, + disable_1m_context: false, }; const mockInvokeImpl = vi.mocked(invoke); diff --git a/src/lib/stores/config.ts b/src/lib/stores/config.ts index b904379..c4b3bcd 100644 --- a/src/lib/stores/config.ts +++ b/src/lib/stores/config.ts @@ -47,6 +47,10 @@ export interface HikariConfig { discord_rpc_enabled: boolean; // Thinking blocks settings show_thinking_blocks: boolean; + // Worktree isolation + use_worktree: boolean; + // Disable 1M context window + disable_1m_context: boolean; } const defaultConfig: HikariConfig = { @@ -87,6 +91,8 @@ const defaultConfig: HikariConfig = { budget_warning_threshold: 0.8, discord_rpc_enabled: true, show_thinking_blocks: true, + use_worktree: false, + disable_1m_context: false, }; function createConfigStore() { diff --git a/src/lib/tauri.ts b/src/lib/tauri.ts index 1d5d767..ad26771 100644 --- a/src/lib/tauri.ts +++ b/src/lib/tauri.ts @@ -29,6 +29,7 @@ interface StateChangePayload { } const connectedConversations = new Set(); +const greetingPendingConversations = new Set(); let unlisteners: Array<() => void> = []; let skipNextGreeting = false; @@ -55,17 +56,17 @@ function generateGreetingPrompt(): string { return `[System: A new session has started. It's currently ${timeOfDay}. Please greet the user warmly and briefly. Keep it short - just 1-2 sentences.]`; } -async function sendGreeting(conversationId: string) { +async function sendGreeting(conversationId: string): Promise { // Check if we should skip this greeting if (skipNextGreeting) { skipNextGreeting = false; // Reset the flag - return; + return false; } const config = configStore.getConfig(); if (!config.greeting_enabled) { - return; + return false; } const greetingPrompt = config.greeting_custom_prompt?.trim() || generateGreetingPrompt(); @@ -81,10 +82,12 @@ async function sendGreeting(conversationId: string) { conversationId, message: greetingPrompt, }); + return true; } catch (error) { console.error("Failed to send greeting:", error); claudeStore.addLineToConversation(conversationId, "error", `Failed to send greeting: ${error}`); characterState.setTemporaryState("error", 3000); + return false; } } @@ -118,6 +121,7 @@ interface WorkingDirectoryPayload { export async function cleanupConversationTracking(conversationId: string) { connectedConversations.delete(conversationId); + greetingPendingConversations.delete(conversationId); // Clean up any temp files associated with this conversation try { @@ -173,7 +177,24 @@ export async function initializeTauriListeners() { if (!connectedConversations.has(targetConversationId)) { connectedConversations.add(targetConversationId); resetSessionStats(); // Reset session stats on new connection - await sendGreeting(targetConversationId); + + // Immediately hold the tab at yellow while we wait for the greeting response. + // This avoids a brief green flash before the greeting is even sent. + greetingPendingConversations.add(targetConversationId); + claudeStore.setConnectionStatusForConversation( + targetConversationId, + "connecting" as ConnectionStatus + ); + + const greetingSent = await sendGreeting(targetConversationId); + if (!greetingSent) { + // Greeting was disabled or failed — flip straight to connected. + greetingPendingConversations.delete(targetConversationId); + claudeStore.setConnectionStatusForConversation( + targetConversationId, + "connected" as ConnectionStatus + ); + } } } } else if (status === "disconnected") { @@ -191,6 +212,7 @@ export async function initializeTauriListeners() { // Only remove from connected set if we're not about to reconnect if (!skipNextGreeting && targetConversationId) { connectedConversations.delete(targetConversationId); + greetingPendingConversations.delete(targetConversationId); } // Don't add system message if we're about to reconnect @@ -205,6 +227,14 @@ export async function initializeTauriListeners() { todos.clear(); } + // Update the tab's connection status on real disconnects + if (!skipNextGreeting && targetConversationId) { + claudeStore.setConnectionStatusForConversation( + targetConversationId, + "disconnected" as ConnectionStatus + ); + } + // Update character state for this conversation if (targetConversationId) { claudeStore.setCharacterStateForConversation(targetConversationId, "idle"); @@ -214,6 +244,7 @@ export async function initializeTauriListeners() { if (targetConversationId) { connectedConversations.delete(targetConversationId); + greetingPendingConversations.delete(targetConversationId); claudeStore.addLineToConversation(targetConversationId, "error", "Connection error"); } @@ -275,11 +306,34 @@ export async function initializeTauriListeners() { } : undefined; + // Flip to connected when first assistant message arrives after greeting + if ( + conversation_id && + line_type === "assistant" && + greetingPendingConversations.has(conversation_id) + ) { + greetingPendingConversations.delete(conversation_id); + claudeStore.setConnectionStatusForConversation( + conversation_id, + "connected" as ConnectionStatus + ); + } + // Always store the output to the correct conversation if (conversation_id) { claudeStore.addLineToConversation( conversation_id, - line_type as "user" | "assistant" | "system" | "tool" | "error" | "thinking", + line_type as + | "user" + | "assistant" + | "system" + | "tool" + | "error" + | "thinking" + | "rate-limit" + | "compact-prompt" + | "worktree" + | "config-change", content, tool_name || undefined, costData, @@ -288,7 +342,17 @@ export async function initializeTauriListeners() { } else { // Fallback to active conversation if no conversation_id provided claudeStore.addLine( - line_type as "user" | "assistant" | "system" | "tool" | "error" | "thinking", + line_type as + | "user" + | "assistant" + | "system" + | "tool" + | "error" + | "thinking" + | "rate-limit" + | "compact-prompt" + | "worktree" + | "config-change", content, tool_name || undefined, costData, @@ -410,10 +474,17 @@ export async function initializeTauriListeners() { unlisteners.push(agentUpdateUnlisten); const agentEndUnlisten = await listen("claude:agent-end", (event) => { - const { tool_use_id, ended_at, is_error, conversation_id } = event.payload; + const { tool_use_id, ended_at, is_error, conversation_id, last_assistant_message } = + event.payload; const targetConversationId = conversation_id || get(claudeStore.activeConversationId); if (targetConversationId) { - agentStore.endAgent(targetConversationId, tool_use_id, ended_at, is_error); + agentStore.endAgent( + targetConversationId, + tool_use_id, + ended_at, + is_error, + last_assistant_message + ); } }); unlisteners.push(agentEndUnlisten); diff --git a/src/lib/types/agents.ts b/src/lib/types/agents.ts index 2cb53cd..cc9e3b5 100644 --- a/src/lib/types/agents.ts +++ b/src/lib/types/agents.ts @@ -12,6 +12,7 @@ export interface AgentInfo { durationMs?: number; characterName: string; characterAvatar: string; + lastAssistantMessage?: string; } export interface AgentStartPayload { @@ -31,4 +32,5 @@ export interface AgentEndPayload { conversation_id?: string; duration_ms?: number; num_turns?: number; + last_assistant_message?: string; } diff --git a/src/lib/types/messages.ts b/src/lib/types/messages.ts index 1a9e7cd..15ca219 100644 --- a/src/lib/types/messages.ts +++ b/src/lib/types/messages.ts @@ -1,6 +1,16 @@ export interface TerminalLine { id: string; - type: "user" | "assistant" | "system" | "tool" | "error" | "thinking"; + type: + | "user" + | "assistant" + | "system" + | "tool" + | "error" + | "thinking" + | "rate-limit" + | "compact-prompt" + | "worktree" + | "config-change"; content: string; timestamp: Date; toolName?: string;