diff --git a/src-tauri/src/parsers/claude.rs b/src-tauri/src/parsers/claude.rs index 0b09f78..a516858 100644 --- a/src-tauri/src/parsers/claude.rs +++ b/src-tauri/src/parsers/claude.rs @@ -58,6 +58,19 @@ fn is_meta_message(value: &serde_json::Value) -> bool { .unwrap_or(false) } +/// Check if an assistant message is a synthetic placeholder (e.g. generated by +/// Claude Code for local commands like `/context` or `/model`). +/// These carry `model: ""` and all-zero usage, so they should be +/// excluded from conversation turns and stats. +fn is_synthetic_assistant(value: &serde_json::Value) -> bool { + value + .get("message") + .and_then(|m| m.get("model")) + .and_then(|m| m.as_str()) + .map(|s| s == "") + .unwrap_or(false) +} + fn parse_model_capacity_suffix(model: &str) -> Option { let captures = model_capacity_suffix_regex().captures(model.trim())?; let value = captures.get(1)?.as_str().parse::().ok()?; @@ -236,6 +249,11 @@ impl ClaudeParser { } if msg_type == "user" || msg_type == "assistant" { + // Skip synthetic assistant placeholders for local commands + if msg_type == "assistant" && is_synthetic_assistant(&value) { + continue; + } + message_count += 1; // Extract model from assistant messages @@ -449,6 +467,10 @@ impl ClaudeParser { } match msg_type { + "assistant" if is_synthetic_assistant(&value) => { + // Skip synthetic assistant placeholders for local commands + continue; + } "user" => { let content = extract_user_content(&value); @@ -1036,6 +1058,103 @@ mod tests { assert_eq!(resolved, PathBuf::from("/Users/default/.claude")); } + #[test] + fn synthetic_assistant_excluded_from_detail() { + let path = std::env::temp_dir().join(format!( + "codeg-claude-synthetic-{}.jsonl", + uuid::Uuid::new_v4() + )); + let mut file = fs::File::create(&path).expect("create temp jsonl"); + // Normal user message + writeln!( + file, + "{}", + json!({ + "type": "user", + "sessionId": "synth-test", + "timestamp": "2026-03-01T10:00:00Z", + "uuid": "u1", + "cwd": "/tmp/demo", + "message": { + "content": [{"type": "text", "text": "hello"}] + } + }) + ) + .unwrap(); + // Normal assistant message with real usage + writeln!( + file, + "{}", + json!({ + "type": "assistant", + "sessionId": "synth-test", + "timestamp": "2026-03-01T10:00:02Z", + "uuid": "a1", + "message": { + "model": "claude-sonnet-4-6", + "content": [{"type": "text", "text": "world"}], + "usage": { + "input_tokens": 1000, + "output_tokens": 200, + "cache_creation_input_tokens": 300, + "cache_read_input_tokens": 400 + } + } + }) + ) + .unwrap(); + // Synthetic assistant from a local command like /context + writeln!( + file, + "{}", + json!({ + "type": "assistant", + "sessionId": "synth-test", + "timestamp": "2026-03-01T10:01:00Z", + "uuid": "a2", + "message": { + "model": "", + "content": [{"type": "text", "text": "No response requested."}], + "usage": { + "input_tokens": 0, + "output_tokens": 0, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 0 + } + } + }) + ) + .unwrap(); + + let parser = ClaudeParser { + base_dir: PathBuf::new(), + }; + let detail = parser + .parse_conversation_detail(&path, "synth-test") + .expect("parse detail"); + fs::remove_file(&path).unwrap(); + + // Should have 2 turns (user + real assistant), synthetic is excluded + assert_eq!(detail.turns.len(), 2); + assert!( + !detail + .turns + .iter() + .any(|t| t.blocks.iter().any(|b| matches!( + b, + ContentBlock::Text { text } if text == "No response requested." + ))), + "synthetic assistant content should not appear in turns" + ); + + // Stats should reflect only the real assistant usage + let stats = detail.session_stats.expect("session stats"); + assert_eq!(stats.context_window_used_tokens, Some(1700)); + assert_eq!(stats.context_window_max_tokens, Some(200_000)); + let total = stats.total_tokens.expect("total tokens"); + assert_eq!(total, 1900); // 1000 + 200 + 300 + 400 + } + #[test] fn extract_user_content_parses_claude_base64_image_block() { let value = json!({ diff --git a/src/components/layout/status-bar-tokens.tsx b/src/components/layout/status-bar-tokens.tsx index df6b7c7..8a86484 100644 --- a/src/components/layout/status-bar-tokens.tsx +++ b/src/components/layout/status-bar-tokens.tsx @@ -65,8 +65,12 @@ export function StatusBarTokens() { getConnSnapshot ) - const liveContextUsed = activeConn?.usage?.used ?? null - const liveContextMax = activeConn?.usage?.size ?? null + const rawLiveUsed = activeConn?.usage?.used ?? null + const rawLiveSize = activeConn?.usage?.size ?? null + // Treat live used=0 as "no data" so we fall back to sessionStats — + // Claude Code sends used=0 for synthetic local commands (/context etc.) + const liveContextUsed = rawLiveUsed != null && rawLiveUsed > 0 ? rawLiveUsed : null + const liveContextMax = rawLiveSize != null && rawLiveSize > 0 ? rawLiveSize : null const contextUsed = liveContextUsed ?? sessionStats?.context_window_used_tokens ?? null diff --git a/src/contexts/acp-connections-context.tsx b/src/contexts/acp-connections-context.tsx index d45dae1..f2c9255 100644 --- a/src/contexts/acp-connections-context.tsx +++ b/src/contexts/acp-connections-context.tsx @@ -975,6 +975,12 @@ function connectionsReducer( case "USAGE_UPDATE": { const conn = state.get(action.contextKey) if (!conn) return state + // Ignore usage updates that reset used to 0 when we already have + // valid data — these come from synthetic responses for local commands + // like /context and would overwrite the real context window usage. + if (action.usage.used === 0 && conn.usage && conn.usage.used > 0) { + return state + } if ( conn.usage?.used === action.usage.used && conn.usage?.size === action.usage.size