From 1c02ca1bb587ea06cd6ad72fcb88d788cdf7d117 Mon Sep 17 00:00:00 2001 From: Hikari Date: Tue, 24 Feb 2026 16:28:33 -0800 Subject: [PATCH] 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;