From 24313facca77f3ed1895a69673de83300ddc49a2 Mon Sep 17 00:00:00 2001 From: Hikari Date: Tue, 24 Feb 2026 10:44:22 -0800 Subject: [PATCH 01/14] feat: collapsible tool lines in terminal Long tool messages (e.g. Bash commands) are now stored in full and displayed collapsed by default, with a toggle button to expand/collapse. Removes backend truncation of Bash commands at 50 chars. --- src-tauri/src/wsl_bridge.rs | 11 ++---- src/lib/components/Terminal.svelte | 57 ++++++++++++++++++++++++++++++ 2 files changed, 59 insertions(+), 9 deletions(-) diff --git a/src-tauri/src/wsl_bridge.rs b/src-tauri/src/wsl_bridge.rs index b06cd33..8a125b5 100644 --- a/src-tauri/src/wsl_bridge.rs +++ b/src-tauri/src/wsl_bridge.rs @@ -1689,12 +1689,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 +1850,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] diff --git a/src/lib/components/Terminal.svelte b/src/lib/components/Terminal.svelte index 616231c..242d4ce 100644 --- a/src/lib/components/Terminal.svelte +++ b/src/lib/components/Terminal.svelte @@ -1,6 +1,7 @@
{copiedMessageId === line.id ? "Copied!" : "Copy"}
+ {:else if line.type === "tool" && isToolContentLong(maskPaths(line.content, hidePaths))} + + + + {:else} -- 2.52.0 From 986dd54dd9a50ddcdc2ac6b0235ba8f74d03a54c Mon Sep 17 00:00:00 2001 From: Hikari Date: Tue, 24 Feb 2026 12:31:24 -0800 Subject: [PATCH 02/14] feat: hold tab at connecting until greeting response arrives Track greeting-pending conversations so the tab stays yellow until Claude actually responds, rather than going green as soon as the process starts. Also fixes the disconnect handler missing a status update that left tabs stuck at yellow after the process died. --- src/lib/tauri.ts | 52 ++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 48 insertions(+), 4 deletions(-) diff --git a/src/lib/tauri.ts b/src/lib/tauri.ts index 1d5d767..828cae6 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,6 +306,19 @@ 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( -- 2.52.0 From 302963f6c0de2c112a1299093f8750ad5624182b Mon Sep 17 00:00:00 2001 From: Hikari Date: Tue, 24 Feb 2026 12:47:41 -0800 Subject: [PATCH 03/14] feat: add supported CLI version badge to header Adds a second badge alongside the existing CLI version display showing the highest audited version (currently 2.1.33). The badge is colour-coded: green when installed matches supported, amber when ahead, red when behind, and neutral grey whilst loading. --- src/lib/components/CliVersion.svelte | 102 ++++++++++++++++++++++----- 1 file changed, 83 insertions(+), 19 deletions(-) diff --git a/src/lib/components/CliVersion.svelte b/src/lib/components/CliVersion.svelte index affb99a..bfc6c5f 100644 --- a/src/lib/components/CliVersion.svelte +++ b/src/lib/components/CliVersion.svelte @@ -2,15 +2,39 @@ import { invoke } from "@tauri-apps/api/core"; import { onMount } from "svelte"; - let version = $state("Loading..."); + const SUPPORTED_CLI_VERSION = "2.1.33"; + + 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 supportedBadgeState = $derived.by(() => { + if (installedVersion === "Loading..." || installedVersion === "Unknown") { + return "neutral"; + } + const cmp = compareVersions(installedVersion, 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 +43,50 @@ }); -
- - - - - CLI {version} +
+
+ + + + + CLI {installedVersion} +
+ +
+ + + + Supported {SUPPORTED_CLI_VERSION} +
-- 2.52.0 From ac965ebe92bf89509c21c0883217587532199175 Mon Sep 17 00:00:00 2001 From: Hikari Date: Tue, 24 Feb 2026 14:35:51 -0800 Subject: [PATCH 06/14] feat: add auth status display and account management to settings sidebar Implements issue #153. Adds Account section to ConfigSidebar with: - Claude auth status (logged in/out, email, org, plan, API source) - API key override indicator reading from local Hikari config - Login/logout action buttons - Refresh button for manual status updates Adds Rust commands: get_auth_status, auth_login, auth_logout --- src-tauri/src/commands.rs | 130 ++++++++++++++++++++ src-tauri/src/lib.rs | 3 + src/lib/components/ConfigSidebar.svelte | 151 ++++++++++++++++++++++++ 3 files changed, 284 insertions(+) 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/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/lib/components/ConfigSidebar.svelte b/src/lib/components/ConfigSidebar.svelte index 80ed171..014931f 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({ @@ -56,6 +57,20 @@ 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 +84,9 @@ configStore.isSidebarOpen.subscribe((open) => { isOpen = open; + if (open && authStatus === null) { + void refreshAuthStatus(); + } }); configStore.saveError.subscribe((error) => { @@ -111,6 +129,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 +284,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} +
+
+

-- 2.52.0 From 1c02ca1bb587ea06cd6ad72fcb88d788cdf7d117 Mon Sep 17 00:00:00 2001 From: Hikari Date: Tue, 24 Feb 2026 16:28:33 -0800 Subject: [PATCH 07/14] feat: parse and display rate_limit_event messages from Claude CLI Closes #155 - Add RateLimitInfo struct and RateLimitEvent variant to ClaudeMessage - Emit rate-limit OutputEvent with human-readable message in wsl_bridge - Add rate-limit line type to TerminalLine union and Terminal rendering - Display rate-limit lines in amber with [rate-limit] prefix - Add Terminal.test.ts with 28 tests for getLineClass, getLinePrefix, formatTime, isToolContentLong, and truncateToolContent --- src-tauri/src/types.rs | 98 ++++++++++++ src-tauri/src/wsl_bridge.rs | 46 ++++++ src/lib/components/Terminal.svelte | 8 + src/lib/components/Terminal.test.ts | 230 ++++++++++++++++++++++++++++ src/lib/tauri.ts | 4 +- src/lib/types/messages.ts | 2 +- 6 files changed, 385 insertions(+), 3 deletions(-) create mode 100644 src/lib/components/Terminal.test.ts diff --git a/src-tauri/src/types.rs b/src-tauri/src/types.rs index 19c6153..c89bb63 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)] @@ -446,4 +471,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 8a125b5..e8e4725 100644 --- a/src-tauri/src/wsl_bridge.rs +++ b/src-tauri/src/wsl_bridge.rs @@ -1521,6 +1521,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(); @@ -1629,6 +1646,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 { diff --git a/src/lib/components/Terminal.svelte b/src/lib/components/Terminal.svelte index 242d4ce..ebd682b 100644 --- a/src/lib/components/Terminal.svelte +++ b/src/lib/components/Terminal.svelte @@ -93,6 +93,8 @@ return "terminal-error"; case "thinking": return "terminal-thinking"; + case "rate-limit": + return "terminal-rate-limit"; default: return "terminal-default"; } @@ -110,6 +112,8 @@ return "[tool]"; case "error": return "[error]"; + case "rate-limit": + return "[rate-limit]"; default: return ""; } @@ -362,6 +366,10 @@ color: var(--terminal-error, #f87171); } + .terminal-rate-limit { + color: var(--terminal-rate-limit, #fb923c); + } + .terminal-default { color: var(--text-primary); } diff --git a/src/lib/components/Terminal.test.ts b/src/lib/components/Terminal.test.ts new file mode 100644 index 0000000..53dce78 --- /dev/null +++ b/src/lib/components/Terminal.test.ts @@ -0,0 +1,230 @@ +/** + * 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"; + 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]"; + 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-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 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/tauri.ts b/src/lib/tauri.ts index 828cae6..3727d4f 100644 --- a/src/lib/tauri.ts +++ b/src/lib/tauri.ts @@ -323,7 +323,7 @@ export async function initializeTauriListeners() { 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", content, tool_name || undefined, costData, @@ -332,7 +332,7 @@ 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", content, tool_name || undefined, costData, diff --git a/src/lib/types/messages.ts b/src/lib/types/messages.ts index 1a9e7cd..358bef7 100644 --- a/src/lib/types/messages.ts +++ b/src/lib/types/messages.ts @@ -1,6 +1,6 @@ export interface TerminalLine { id: string; - type: "user" | "assistant" | "system" | "tool" | "error" | "thinking"; + type: "user" | "assistant" | "system" | "tool" | "error" | "thinking" | "rate-limit"; content: string; timestamp: Date; toolName?: string; -- 2.52.0 From 108a1b16b2936dd3918510f16af91f95ad600c66 Mon Sep 17 00:00:00 2001 From: Hikari Date: Tue, 24 Feb 2026 17:18:44 -0800 Subject: [PATCH 08/14] feat: detect prompt-too-long errors and offer compact action Closes #158 - Detect "Prompt is too long" in assistant text blocks in wsl_bridge - Emit error line type instead of assistant for better visibility - Emit compact-prompt event after the error line - Render a compact-prompt line as an action button in the terminal - Clicking the button sends /compact to the active session - Add getLineClass/getLinePrefix tests for compact-prompt type --- src-tauri/src/wsl_bridge.rs | 24 ++++++++++++++++-- src/lib/components/Terminal.svelte | 38 ++++++++++++++++++++++++++++- src/lib/components/Terminal.test.ts | 10 ++++++++ src/lib/tauri.ts | 20 +++++++++++++-- src/lib/types/messages.ts | 10 +++++++- 5 files changed, 96 insertions(+), 6 deletions(-) diff --git a/src-tauri/src/wsl_bridge.rs b/src-tauri/src/wsl_bridge.rs index e8e4725..120de9f 100644 --- a/src-tauri/src/wsl_bridge.rs +++ b/src-tauri/src/wsl_bridge.rs @@ -1082,17 +1082,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; diff --git a/src/lib/components/Terminal.svelte b/src/lib/components/Terminal.svelte index ebd682b..9933d3c 100644 --- a/src/lib/components/Terminal.svelte +++ b/src/lib/components/Terminal.svelte @@ -1,6 +1,7 @@