Files
hikari-desktop/src-tauri/src/wsl_bridge.rs
T
hikari 7781b2caab feat: add extended thinking blocks display
Implements support for displaying Claude's extended thinking blocks in
the conversation UI with a collapsible, visually distinct component.

Changes:
- Backend: Update wsl_bridge.rs to emit thinking blocks with dedicated
  line_type instead of system messages
- Types: Add "thinking" to TerminalLine type union
- Config: Add show_thinking_blocks toggle (default: true)
- UI: Create ThinkingBlock.svelte component with collapsible interface
- Terminal: Update to conditionally render thinking blocks
- Settings: Add toggle in Appearance section of ConfigSidebar

The ThinkingBlock component features:
- Lightbulb icon to indicate extended thinking
- Collapsible/expandable with animated chevron
- Distinct styling: dimmed, italic, monospace
- Timestamp display
- Respects global show_thinking_blocks config setting

Note: Extended thinking support in Claude Code CLI appears to be in
development. This implementation is ready and will automatically display
thinking blocks once the CLI begins emitting them.

Issue: #120

 Implemented by Hikari~ 🌸
2026-02-07 12:42:34 -08:00

1799 lines
68 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
use std::io::{BufRead, BufReader, Write};
use std::process::{Child, ChildStdin, Command, Stdio};
use std::sync::Arc;
use std::thread;
use std::time::{SystemTime, UNIX_EPOCH};
use tauri::{AppHandle, Emitter};
use tempfile::NamedTempFile;
#[cfg(target_os = "windows")]
use std::os::windows::process::CommandExt;
use crate::achievements::{get_achievement_info, AchievementUnlockedEvent};
use crate::commands::record_cost;
use crate::config::ClaudeStartOptions;
use crate::stats::{calculate_cost, StatsUpdateEvent, UsageStats};
use crate::types::{
AgentEndEvent, AgentStartEvent, CharacterState, ClaudeMessage, ConnectionEvent,
ConnectionStatus, ContentBlock, MessageCost, OutputEvent, PermissionPromptEvent,
PermissionPromptEventItem, QuestionOption, SessionEvent, StateChangeEvent,
UserQuestionEvent, WorkingDirectoryEvent,
};
use parking_lot::RwLock;
use std::cell::RefCell;
thread_local! {
/// Stores pending tool uses from the most recent Assistant message
/// to enable batching permission requests for sibling cancelled tools
static PENDING_TOOL_USES: RefCell<Vec<PendingToolUse>> = const { RefCell::new(Vec::new()) };
}
#[derive(Debug, Clone)]
struct PendingToolUse {
tool_use_id: String,
tool_name: String,
tool_input: serde_json::Value,
}
const SEARCH_TOOLS: [&str; 5] = ["Read", "Glob", "Grep", "WebSearch", "WebFetch"];
const CODING_TOOLS: [&str; 3] = ["Edit", "Write", "NotebookEdit"];
fn detect_wsl() -> bool {
// Check /proc/version for WSL indicators
if let Ok(version) = std::fs::read_to_string("/proc/version") {
let version_lower = version.to_lowercase();
if version_lower.contains("microsoft") || version_lower.contains("wsl") {
return true;
}
}
// Fallback: check for WSLInterop
if std::path::Path::new("/proc/sys/fs/binfmt_misc/WSLInterop").exists() {
return true;
}
// Check for WSL environment variable
if std::env::var("WSL_DISTRO_NAME").is_ok() {
return true;
}
false
}
fn find_claude_binary() -> Option<String> {
// Check common installation locations for claude
let home = std::env::var("HOME").ok()?;
let paths_to_check = [
format!("{}/.local/bin/claude", home),
format!("{}/.claude/local/claude", home),
"/usr/local/bin/claude".to_string(),
"/usr/bin/claude".to_string(),
];
for path in &paths_to_check {
if std::path::Path::new(path).exists() {
return Some(path.clone());
}
}
// Fall back to checking PATH via which
if let Ok(output) = Command::new("which").arg("claude").output() {
if output.status.success() {
let path = String::from_utf8_lossy(&output.stdout).trim().to_string();
if !path.is_empty() {
return Some(path);
}
}
}
None
}
pub struct WslBridge {
process: Option<Child>,
stdin: Option<ChildStdin>,
working_directory: String,
session_id: Option<String>,
mcp_config_file: Option<NamedTempFile>,
stats: Arc<RwLock<UsageStats>>,
conversation_id: Option<String>,
}
impl WslBridge {
pub fn new() -> Self {
WslBridge {
process: None,
stdin: None,
working_directory: String::new(),
session_id: None,
mcp_config_file: None,
stats: Arc::new(RwLock::new(UsageStats::new())),
conversation_id: None,
}
}
pub fn new_with_conversation_id(conversation_id: String) -> Self {
WslBridge {
process: None,
stdin: None,
working_directory: String::new(),
session_id: None,
mcp_config_file: None,
stats: Arc::new(RwLock::new(UsageStats::new())),
conversation_id: Some(conversation_id),
}
}
pub fn start(&mut self, app: AppHandle, options: ClaudeStartOptions) -> Result<(), String> {
if self.process.is_some() {
return Err("Process already running".to_string());
}
// Load saved achievements and stats when starting a new session
let app_clone = app.clone();
let stats = self.stats.clone();
tauri::async_runtime::spawn(async move {
tracing::info!("Loading saved achievements...");
let achievements = crate::achievements::load_achievements(&app_clone).await;
tracing::info!(
"Loaded {} unlocked achievements",
achievements.unlocked.len()
);
tracing::info!("Loading saved stats...");
let persisted_stats = crate::stats::load_stats(&app_clone).await;
let mut stats_guard = stats.write();
stats_guard.achievements = achievements;
if let Some(persisted) = persisted_stats {
tracing::info!("Applying persisted lifetime stats");
stats_guard.apply_persisted(persisted);
}
});
let working_dir = &options.working_dir;
self.working_directory = working_dir.clone();
emit_connection_status(
&app,
ConnectionStatus::Connecting,
self.conversation_id.clone(),
);
// Create temp file for MCP config if provided
let mcp_config_path = if let Some(ref mcp_json) = options.mcp_servers_json {
if !mcp_json.trim().is_empty() {
// Validate JSON before writing
serde_json::from_str::<serde_json::Value>(mcp_json)
.map_err(|e| format!("Invalid MCP servers JSON: {}", e))?;
let mut temp_file = NamedTempFile::new()
.map_err(|e| format!("Failed to create temp file for MCP config: {}", e))?;
temp_file
.write_all(mcp_json.as_bytes())
.map_err(|e| format!("Failed to write MCP config: {}", e))?;
temp_file
.flush()
.map_err(|e| format!("Failed to flush MCP config: {}", e))?;
let path = temp_file.path().to_string_lossy().to_string();
self.mcp_config_file = Some(temp_file);
Some(path)
} else {
None
}
} else {
None
};
// Detect if we're running inside WSL or on Windows
let is_wsl = detect_wsl();
tracing::debug!("is_wsl: {}", is_wsl);
tracing::debug!("options: {:?}", options);
let mut command = if is_wsl {
// Running inside WSL - call claude directly
// Try to find claude in common locations since GUI apps may not inherit shell PATH
let claude_path = find_claude_binary().ok_or_else(|| {
"Could not find claude binary. Is Claude Code installed?".to_string()
})?;
tracing::debug!("Found claude at: {}", claude_path);
tracing::debug!("Working dir: {}", working_dir);
let mut cmd = Command::new(&claude_path);
cmd.args([
"--output-format",
"stream-json",
"--input-format",
"stream-json",
"--verbose",
"--debug",
"hooks",
]);
// Add model if specified
if let Some(ref model) = options.model {
if !model.is_empty() {
cmd.args(["--model", model]);
}
}
// Add allowed tools if any
for tool in &options.allowed_tools {
cmd.args(["--allowedTools", tool]);
}
// Add custom instructions as system prompt if specified
if let Some(ref instructions) = options.custom_instructions {
if !instructions.is_empty() {
cmd.args(["--system-prompt", instructions]);
}
}
// Add MCP config if provided
if let Some(ref mcp_path) = mcp_config_path {
cmd.args(["--mcp-config", mcp_path]);
}
// Add resume flag if session ID provided
if let Some(ref session_id) = options.resume_session_id {
if !session_id.is_empty() {
cmd.args(["--resume", session_id]);
}
}
cmd.current_dir(working_dir);
// Set API key as environment variable if specified
if let Some(ref api_key) = options.api_key {
if !api_key.is_empty() {
cmd.env("ANTHROPIC_API_KEY", api_key);
}
}
cmd
} else {
// Running on Windows - use wsl with bash login shell to ensure PATH is loaded
tracing::debug!("Windows path - using wsl");
let mut cmd = Command::new("wsl");
// Build the claude command with all arguments
let mut claude_cmd = format!("cd '{}' && ", working_dir);
// Set API key as environment variable if specified
if let Some(ref api_key) = options.api_key {
if !api_key.is_empty() {
claude_cmd.push_str(&format!("ANTHROPIC_API_KEY='{}' ", api_key));
}
}
claude_cmd.push_str(
"claude --output-format stream-json --input-format stream-json --verbose",
);
// Add model if specified
if let Some(ref model) = options.model {
if !model.is_empty() {
claude_cmd.push_str(&format!(" --model '{}'", model));
}
}
// Add allowed tools if any
for tool in &options.allowed_tools {
claude_cmd.push_str(&format!(" --allowedTools '{}'", tool));
}
// Add custom instructions as system prompt if specified
if let Some(ref instructions) = options.custom_instructions {
if !instructions.is_empty() {
// Escape single quotes in instructions
let escaped = instructions.replace('\'', "'\\''");
claude_cmd.push_str(&format!(" --system-prompt '{}'", escaped));
}
}
// Add MCP config if provided
if let Some(ref mcp_path) = mcp_config_path {
claude_cmd.push_str(&format!(" --mcp-config '{}'", mcp_path));
}
// Add resume flag if session ID provided
if let Some(ref session_id) = options.resume_session_id {
if !session_id.is_empty() {
claude_cmd.push_str(&format!(" --resume '{}'", session_id));
}
}
// Use bash -lc to load login profile (ensures PATH includes claude)
cmd.args(["-e", "bash", "-lc", &claude_cmd]);
// Hide the console window on Windows
#[cfg(target_os = "windows")]
cmd.creation_flags(0x08000000); // CREATE_NO_WINDOW
cmd
};
command
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped());
let mut child = command.spawn().map_err(|e| {
tracing::error!("Spawn error: {:?}", e);
format!("Failed to spawn process: {}", e)
})?;
let stdin = child.stdin.take();
let stdout = child.stdout.take();
let stderr = child.stderr.take();
self.stdin = stdin;
self.process = Some(child);
// Note: We no longer reset stats here - stats persist across reconnects
// Stats are only reset when explicitly disconnecting via stop()
// Load saved achievements
let app_handle = app.clone();
let stats_clone = self.stats.clone();
tokio::spawn(async move {
let saved_progress = crate::achievements::load_achievements(&app_handle).await;
stats_clone.write().achievements = saved_progress;
});
if let Some(stdout) = stdout {
let app_clone = app.clone();
let stats_clone = self.stats.clone();
let conv_id = self.conversation_id.clone();
thread::spawn(move || {
handle_stdout(stdout, app_clone, stats_clone, conv_id);
});
}
if let Some(stderr) = stderr {
let app_clone = app.clone();
let conv_id = self.conversation_id.clone();
thread::spawn(move || {
handle_stderr(stderr, app_clone, conv_id);
});
}
emit_connection_status(
&app,
ConnectionStatus::Connected,
self.conversation_id.clone(),
);
Ok(())
}
pub fn send_message(&mut self, message: &str) -> Result<(), String> {
let stdin = self.stdin.as_mut().ok_or("Process not running")?;
// Track input for cost estimation on interrupt
{
let mut stats = self.stats.write();
stats.current_request_input = Some(message.to_string());
stats.current_request_output_chars = 0;
stats.current_request_thinking_chars = 0;
stats.current_request_tools.clear();
}
let input = serde_json::json!({
"type": "user",
"message": {
"role": "user",
"content": [{
"type": "text",
"text": message
}]
}
});
let json_line = serde_json::to_string(&input).map_err(|e| e.to_string())?;
stdin
.write_all(format!("{}\n", json_line).as_bytes())
.map_err(|e| format!("Failed to write to stdin: {}", e))?;
stdin
.flush()
.map_err(|e| format!("Failed to flush stdin: {}", e))?;
Ok(())
}
pub fn send_tool_result(
&mut self,
tool_use_id: &str,
result: serde_json::Value,
) -> Result<(), String> {
let stdin = self.stdin.as_mut().ok_or("Process not running")?;
// The content should be a JSON string representation of the result
let content_str = serde_json::to_string(&result).map_err(|e| e.to_string())?;
let input = serde_json::json!({
"type": "user",
"message": {
"role": "user",
"content": [{
"type": "tool_result",
"tool_use_id": tool_use_id,
"content": content_str
}]
}
});
let json_line = serde_json::to_string(&input).map_err(|e| e.to_string())?;
stdin
.write_all(format!("{}\n", json_line).as_bytes())
.map_err(|e| format!("Failed to write to stdin: {}", e))?;
stdin
.flush()
.map_err(|e| format!("Failed to flush stdin: {}", e))?;
Ok(())
}
pub fn interrupt(&mut self, app: &AppHandle) -> Result<(), String> {
// Due to persistent bug in Claude Code where ESC/Ctrl+C doesn't work,
// we have to kill the process. This is the only reliable way to stop it.
// See: https://github.com/anthropics/claude-code/issues/3455
if let Some(mut process) = self.process.take() {
// Estimate cost for interrupted request before killing
self.estimate_interrupted_request_cost(app);
// Kill the process immediately
let _ = process.kill();
let _ = process.wait();
// Clear stdin
self.stdin = None;
// Clear tracking fields
{
let mut stats = self.stats.write();
stats.current_request_input = None;
stats.current_request_output_chars = 0;
stats.current_request_thinking_chars = 0;
stats.current_request_tools.clear();
}
// Keep session_id and working directory for user reference
// The user will see what session was interrupted
// Emit disconnected status
emit_connection_status(
app,
ConnectionStatus::Disconnected,
self.conversation_id.clone(),
);
Ok(())
} else {
Err("No active process to interrupt".to_string())
}
}
fn estimate_interrupted_request_cost(&mut self, app: &AppHandle) {
// Read tracking data from stats
let (input_chars, output_chars, thinking_chars, tools, model) = {
let stats = self.stats.read();
// Only estimate if we have tracked content
if stats.current_request_input.is_none()
&& stats.current_request_output_chars == 0
&& stats.current_request_thinking_chars == 0
&& stats.current_request_tools.is_empty() {
return;
}
let input_chars = stats.current_request_input.as_ref().map(|s| s.len() as u64).unwrap_or(0);
let model = stats.model.clone().unwrap_or_else(|| "claude-sonnet-4-5-20250929".to_string());
(input_chars, stats.current_request_output_chars, stats.current_request_thinking_chars, stats.current_request_tools.clone(), model)
};
tracing::info!("[COST ESTIMATION] Estimating cost for interrupted request");
// Use conservative 3.5 chars/token for estimation (vs standard 4)
let estimated_input_tokens = (input_chars as f64 / 3.5).ceil() as u64;
let estimated_output_tokens = ((output_chars as f64 / 3.5).ceil() as u64)
+ ((thinking_chars as f64 / 3.5).ceil() as u64);
// Add tool overhead based on session averages
let mut tool_overhead_tokens = 0u64;
{
let stats = self.stats.read();
for tool_name in &tools {
if let Some(tool_stats) = stats.session_tools_usage.get(tool_name) {
if tool_stats.call_count > 0 {
// Use session average tokens per call for this tool
let avg_tokens = (tool_stats.estimated_input_tokens + tool_stats.estimated_output_tokens)
/ tool_stats.call_count;
tool_overhead_tokens += avg_tokens;
tracing::info!("[COST ESTIMATION] Tool {} average: {} tokens", tool_name, avg_tokens);
}
}
}
}
let total_estimated_input = estimated_input_tokens + tool_overhead_tokens;
let total_estimated_output = estimated_output_tokens;
// Add 20% safety margin to overestimate
let safety_margin = 1.2;
let conservative_input = (total_estimated_input as f64 * safety_margin).ceil() as u64;
let conservative_output = (total_estimated_output as f64 * safety_margin).ceil() as u64;
tracing::info!("[COST ESTIMATION] Input: {} chars → {} tokens (+ {} tool overhead) × 1.2 safety = {} tokens",
input_chars, estimated_input_tokens, tool_overhead_tokens, conservative_input);
tracing::info!("[COST ESTIMATION] Output: {} chars → {} tokens × 1.2 safety = {} tokens",
output_chars + thinking_chars,
estimated_output_tokens, conservative_output);
// Calculate cost (no cache tokens for interrupted requests)
let estimated_cost = calculate_cost(
conservative_input,
conservative_output,
&model,
None,
None,
);
tracing::info!("[COST ESTIMATION] Estimated cost: ${:.4} (conservative)", estimated_cost);
// Add to stats with estimated flag
{
let mut stats_guard = self.stats.write();
stats_guard.add_usage(
conservative_input,
conservative_output,
&model,
None,
None,
);
}
// Emit stats update
let stats_update_event = StatsUpdateEvent {
stats: self.stats.read().clone(),
};
let _ = app.emit("claude:stats", stats_update_event);
// Record to historical cost tracking (mark as estimated)
let app_clone = app.clone();
tauri::async_runtime::spawn(async move {
record_cost(&app_clone, conservative_input, conservative_output, estimated_cost).await;
});
}
pub fn stop(&mut self, app: &AppHandle) {
if let Some(mut process) = self.process.take() {
let _ = process.kill();
let _ = process.wait();
}
self.stdin = None;
self.session_id = None;
self.mcp_config_file = None; // Temp file is automatically deleted when dropped
// Save lifetime stats before resetting session
let stats_snapshot = self.stats.read().clone();
let app_clone = app.clone();
tauri::async_runtime::spawn(async move {
tracing::info!("Saving stats on session stop...");
if let Err(e) = crate::stats::save_stats(&app_clone, &stats_snapshot).await {
tracing::error!("Failed to save stats: {}", e);
} else {
tracing::info!("Stats saved successfully on session stop");
}
});
// Reset session stats on explicit disconnect
self.stats.write().reset_session();
emit_connection_status(
app,
ConnectionStatus::Disconnected,
self.conversation_id.clone(),
);
}
pub fn is_running(&self) -> bool {
self.process.is_some()
}
pub fn get_working_directory(&self) -> &str {
&self.working_directory
}
pub fn get_stats(&self) -> UsageStats {
self.stats.read().clone()
}
}
impl Default for WslBridge {
fn default() -> Self {
Self::new()
}
}
fn handle_stdout(
stdout: std::process::ChildStdout,
app: AppHandle,
stats: Arc<RwLock<UsageStats>>,
conversation_id: Option<String>,
) {
let reader = BufReader::new(stdout);
for line in reader.lines() {
match line {
Ok(line) if !line.is_empty() => {
if let Err(e) = process_json_line(&line, &app, &stats, &conversation_id) {
tracing::error!("Error processing line: {}", e);
}
}
Err(e) => {
tracing::error!("Error reading stdout: {}", e);
break;
}
_ => {}
}
}
emit_connection_status(&app, ConnectionStatus::Disconnected, conversation_id);
}
fn handle_stderr(
stderr: std::process::ChildStderr,
app: AppHandle,
conversation_id: Option<String>,
) {
let reader = BufReader::new(stderr);
for line in reader.lines() {
match line {
Ok(line) if !line.is_empty() => {
// Check if this is a SubagentStart hook message
if line.contains("[SubagentStart Hook]") {
if let Some(agent_data) = parse_subagent_start_hook(&line) {
tracing::debug!("Parsed SubagentStart hook: agent_id={}, parent={:?}",
agent_data.agent_id, agent_data.parent_tool_use_id);
// Emit an agent-update event with the agent_id
let _ = app.emit(
"claude:agent-update",
serde_json::json!({
"conversationId": conversation_id.clone(),
"toolUseId": agent_data.parent_tool_use_id,
"agentId": agent_data.agent_id,
}),
);
}
}
// Still emit the stderr line as output
let _ = app.emit(
"claude:output",
OutputEvent {
line_type: "error".to_string(),
content: line,
tool_name: None,
conversation_id: conversation_id.clone(),
cost: None,
parent_tool_use_id: None,
},
);
}
Err(_) => break,
_ => {}
}
}
}
#[derive(Debug)]
struct SubagentStartData {
agent_id: String,
parent_tool_use_id: Option<String>,
}
fn parse_subagent_start_hook(line: &str) -> Option<SubagentStartData> {
// Parse: [SubagentStart Hook] agent_id=agent-xxx, parent_tool_use_id=Some("toolu_xxx"), ...
// Extract agent_id
let agent_id = line
.split("agent_id=")
.nth(1)?
.split(',')
.next()?
.trim()
.to_string();
// 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
};
Some(SubagentStartData {
agent_id,
parent_tool_use_id,
})
}
fn process_json_line(
line: &str,
app: &AppHandle,
stats: &Arc<RwLock<UsageStats>>,
conversation_id: &Option<String>,
) -> Result<(), String> {
let message: ClaudeMessage = serde_json::from_str(line)
.map_err(|e| format!("Failed to parse JSON: {} - Line: {}", e, line))?;
match &message {
ClaudeMessage::System {
subtype,
session_id,
cwd,
..
} => {
if subtype == "init" {
if let Some(id) = session_id {
let _ = app.emit(
"claude:session",
SessionEvent {
session_id: id.clone(),
conversation_id: conversation_id.clone(),
},
);
}
if let Some(dir) = cwd {
let _ = app.emit(
"claude:cwd",
WorkingDirectoryEvent {
directory: dir.clone(),
conversation_id: conversation_id.clone(),
},
);
}
emit_state_change(app, CharacterState::Idle, None, conversation_id.clone());
}
}
ClaudeMessage::Assistant { message, parent_tool_use_id } => {
let mut state = CharacterState::Typing;
let mut tool_name = None;
// Collect all tool names in this message for token attribution
let tools_in_message: Vec<String> = message
.content
.iter()
.filter_map(|block| match block {
ContentBlock::ToolUse { name, .. } => Some(name.clone()),
_ => None,
})
.collect();
// Store pending tool uses for permission batching (only for top-level, not subagents)
if parent_tool_use_id.is_none() {
PENDING_TOOL_USES.with(|pending| {
let tool_uses: Vec<PendingToolUse> = message
.content
.iter()
.filter_map(|block| match block {
ContentBlock::ToolUse { id, name, input } => Some(PendingToolUse {
tool_use_id: id.clone(),
tool_name: name.clone(),
tool_input: input.clone(),
}),
_ => None,
})
.collect();
// Append to existing pending tools instead of replacing
pending.borrow_mut().extend(tool_uses);
});
}
// Track message cost for display
let mut message_cost: Option<MessageCost> = None;
// Only update stats if we have usage information
if let Some(usage) = &message.usage {
// Get model from message, or fall back to last known model from stats
let model = message.model.clone().or_else(|| {
let stats_guard = stats.read();
stats_guard.model.clone()
}).unwrap_or_else(|| {
tracing::warn!("No model info available for cost calculation, using default");
"claude-sonnet-4-5-20250929".to_string()
});
// Calculate cost for historical tracking (including cache tokens)
let cost_usd = calculate_cost(
usage.input_tokens,
usage.output_tokens,
&model,
usage.cache_creation_input_tokens,
usage.cache_read_input_tokens,
);
tracing::info!("Assistant message tokens - input: {}, output: {}, cache_creation: {:?}, cache_read: {:?}, cost: ${:.4}",
usage.input_tokens,
usage.output_tokens,
usage.cache_creation_input_tokens,
usage.cache_read_input_tokens,
cost_usd
);
// Store cost for later use in output events
message_cost = Some(MessageCost {
input_tokens: usage.input_tokens,
output_tokens: usage.output_tokens,
cost_usd,
});
// Batch all stats updates in a single write lock
{
let mut stats_guard = stats.write();
stats_guard.increment_messages();
stats_guard.add_usage(
usage.input_tokens,
usage.output_tokens,
&model,
usage.cache_creation_input_tokens,
usage.cache_read_input_tokens,
);
stats_guard.get_session_duration();
// Attribute tokens to tools if any tools were used in this message
if !tools_in_message.is_empty() {
let per_tool_input = usage.input_tokens / tools_in_message.len() as u64;
let per_tool_output = usage.output_tokens / tools_in_message.len() as u64;
for tool in &tools_in_message {
stats_guard.add_tool_tokens(tool, per_tool_input, per_tool_output);
}
}
}
// Record to historical cost tracking
let app_clone = app.clone();
let input = usage.input_tokens;
let output = usage.output_tokens;
tauri::async_runtime::spawn(async move {
record_cost(&app_clone, input, output, cost_usd).await;
});
// Don't emit here - we'll emit on Result message instead
// This reduces the frequency of updates
} else {
// Just increment message count if no usage info
stats.write().increment_messages();
}
for block in &message.content {
match block {
ContentBlock::ToolUse { id, name, input } => {
tool_name = Some(name.clone());
state = get_tool_state(name);
// Batch tool tracking updates
{
let mut stats_guard = stats.write();
stats_guard.increment_tool_usage(name);
// Track file operations
match name.as_str() {
"Edit" => stats_guard.increment_files_edited(),
"Write" => stats_guard.increment_files_created(),
_ => {}
}
}
// Emit agent-start event for Task tool invocations
// Support both "Task" and "Task(agent_type)" syntax (CLI v2.1.33+)
if name == "Task" || name.starts_with("Task(") {
let description = input
.get("description")
.and_then(|v| v.as_str())
.unwrap_or("Subagent")
.to_string();
let subagent_type = input
.get("subagent_type")
.and_then(|v| v.as_str())
.unwrap_or("unknown")
.to_string();
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_millis() as u64;
tracing::debug!(
"Emitting agent-start: id={}, desc={}, type={}, parent={:?}",
id, description, subagent_type, parent_tool_use_id
);
let _ = app.emit(
"claude:agent-start",
AgentStartEvent {
tool_use_id: id.clone(),
agent_id: None, // Will be updated when SubagentStart hook is received
description,
subagent_type,
started_at: now,
conversation_id: conversation_id.clone(),
parent_tool_use_id: parent_tool_use_id.clone(),
},
);
}
let desc = format_tool_description(name, input);
let _ = app.emit(
"claude:output",
OutputEvent {
line_type: "tool".to_string(),
content: desc,
tool_name: Some(name.clone()),
conversation_id: conversation_id.clone(),
cost: None, // Tool use doesn't have separate cost
parent_tool_use_id: parent_tool_use_id.clone(),
},
);
}
ContentBlock::Text { text } => {
// Count code blocks in the text
let code_blocks = text.matches("```").count() / 2;
for _ in 0..code_blocks {
stats.write().increment_code_blocks();
}
let _ = app.emit(
"claude:output",
OutputEvent {
line_type: "assistant".to_string(),
content: text.clone(),
tool_name: None,
conversation_id: conversation_id.clone(),
cost: message_cost.clone(), // Include cost with assistant text
parent_tool_use_id: parent_tool_use_id.clone(),
},
);
}
ContentBlock::Thinking { thinking } => {
state = CharacterState::Thinking;
let _ = app.emit(
"claude:output",
OutputEvent {
line_type: "thinking".to_string(),
content: thinking.clone(),
tool_name: None,
conversation_id: conversation_id.clone(),
cost: None,
parent_tool_use_id: parent_tool_use_id.clone(),
},
);
}
ContentBlock::ToolResult {
tool_use_id,
is_error,
..
} => {
// Emit agent-end for all tool results
// The frontend will ignore IDs that don't match known agents
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_millis() as u64;
let _ = app.emit(
"claude:agent-end",
AgentEndEvent {
tool_use_id: tool_use_id.clone(),
ended_at: now,
is_error: is_error.unwrap_or(false),
conversation_id: conversation_id.clone(),
duration_ms: None,
num_turns: None,
},
);
}
}
}
emit_state_change(app, state, tool_name, conversation_id.clone());
}
ClaudeMessage::StreamEvent { event } => {
if event.event_type == "content_block_start" {
if let Some(block) = &event.content_block {
// Track tool calls for cost estimation
if block.block_type == "tool_use" {
if let Some(name) = &block.name {
let mut stats_guard = stats.write();
stats_guard.current_request_tools.push(name.clone());
}
}
let state = match block.block_type.as_str() {
"thinking" => CharacterState::Thinking,
"text" => CharacterState::Typing,
"tool_use" => {
if let Some(name) = &block.name {
get_tool_state(name)
} else {
CharacterState::Typing
}
}
_ => CharacterState::Typing,
};
emit_state_change(app, state, block.name.clone(), conversation_id.clone());
}
} else if event.event_type == "content_block_delta" {
if let Some(delta) = &event.delta {
if let Some(text) = &delta.text {
// Track output characters for cost estimation
{
let mut stats_guard = stats.write();
stats_guard.current_request_output_chars += text.len() as u64;
}
let _ = app.emit("claude:stream", text.clone());
} else if let Some(thinking) = &delta.thinking {
// Track thinking characters for cost estimation
let mut stats_guard = stats.write();
stats_guard.current_request_thinking_chars += thinking.len() as u64;
}
}
}
}
ClaudeMessage::Result {
subtype,
result,
permission_denials,
usage,
duration_ms,
num_turns,
} => {
tracing::info!(
"Received Result message: subtype={}, has_denials={}, denial_count={:?}",
subtype,
permission_denials.is_some(),
permission_denials.as_ref().map(|d| d.len())
);
let state = if subtype == "success" {
CharacterState::Success
} else {
CharacterState::Error
};
// Capture pending tool uses before clearing them
// We'll use these for permission batching if there are denials
let captured_pending_tools = PENDING_TOOL_USES.with(|pending| {
let tools = pending.borrow().clone();
// Clear immediately so they don't accumulate across requests
pending.borrow_mut().clear();
tools
});
tracing::debug!(
"Captured {} pending tool use(s): {:?}",
captured_pending_tools.len(),
captured_pending_tools.iter().map(|t| &t.tool_name).collect::<Vec<_>>()
);
// Log turn metrics if available
if let Some(duration) = duration_ms {
tracing::info!("Turn completed in {}ms", duration);
}
if let Some(turns) = num_turns {
tracing::info!("Turn count: {}", turns);
}
// Track token usage from Result messages if available
// This captures tokens from tool outputs and other operations
if let Some(usage_info) = usage {
// We need the model info to calculate cost properly
// For now, use the last known model from stats
let model = {
let stats_guard = stats.read();
stats_guard.model.clone().unwrap_or_else(|| "claude-opus-4-20250514".to_string())
};
// Calculate cost for historical tracking (including cache tokens)
let cost_usd = calculate_cost(
usage_info.input_tokens,
usage_info.output_tokens,
&model,
usage_info.cache_creation_input_tokens,
usage_info.cache_read_input_tokens,
);
let mut stats_guard = stats.write();
stats_guard.add_usage(
usage_info.input_tokens,
usage_info.output_tokens,
&model,
usage_info.cache_creation_input_tokens,
usage_info.cache_read_input_tokens,
);
tracing::info!("Result message tokens - input: {}, output: {}, cache_creation: {:?}, cache_read: {:?}",
usage_info.input_tokens,
usage_info.output_tokens,
usage_info.cache_creation_input_tokens,
usage_info.cache_read_input_tokens
);
// Record to historical cost tracking
let app_clone = app.clone();
let input = usage_info.input_tokens;
let output = usage_info.output_tokens;
tauri::async_runtime::spawn(async move {
record_cost(&app_clone, input, output, cost_usd).await;
});
}
// Clear tracking fields since request completed successfully
{
let mut stats_guard = stats.write();
stats_guard.current_request_input = None;
stats_guard.current_request_output_chars = 0;
stats_guard.current_request_thinking_chars = 0;
stats_guard.current_request_tools.clear();
}
// Always emit updated stats on result message (less frequent)
// This includes the latest session duration
let newly_unlocked = {
let mut stats_guard = stats.write();
stats_guard.get_session_duration();
tracing::info!("Checking achievements after result message...");
let unlocked = stats_guard.check_achievements();
tracing::info!("Newly unlocked achievements: {:?}", unlocked);
unlocked
};
// Emit achievement events for any newly unlocked achievements
for achievement_id in &newly_unlocked {
let info = get_achievement_info(achievement_id);
let _ = app.emit(
"achievement:unlocked",
AchievementUnlockedEvent { achievement: info },
);
}
// Save achievements after unlocking new ones
if !newly_unlocked.is_empty() {
tracing::info!("Saving newly unlocked achievements: {:?}", newly_unlocked);
let app_handle = app.clone();
let achievements_progress = stats.read().achievements.clone();
// Use Tauri's async runtime instead of tokio::spawn
tauri::async_runtime::spawn(async move {
tracing::info!("Spawned save task for achievements");
if let Err(e) =
crate::achievements::save_achievements(&app_handle, &achievements_progress)
.await
{
tracing::error!("Failed to save achievements: {}", e);
} else {
tracing::info!("Achievement save task completed successfully");
}
});
}
let current_stats = stats.read().clone();
let stats_event = StatsUpdateEvent {
stats: current_stats.clone(),
};
let _ = app.emit("claude:stats", stats_event);
// Save stats periodically (every 10 messages to avoid excessive disk writes)
if current_stats.session_messages_exchanged.is_multiple_of(10)
&& current_stats.session_messages_exchanged > 0
{
let app_handle = app.clone();
tauri::async_runtime::spawn(async move {
tracing::info!("Periodic stats save (every 10 messages)...");
if let Err(e) = crate::stats::save_stats(&app_handle, &current_stats).await {
tracing::error!("Failed to save stats: {}", e);
}
});
}
// Only emit error results - success content is already sent via Assistant message
if subtype != "success" {
if let Some(text) = result {
let _ = app.emit(
"claude:output",
OutputEvent {
line_type: "error".to_string(),
content: text.clone(),
tool_name: None,
conversation_id: conversation_id.clone(),
cost: None,
parent_tool_use_id: None,
},
);
}
}
// Check for permission denials and emit prompts for each
if let Some(denials) = permission_denials {
// Only process if there are actually denials
if !denials.is_empty() {
let mut regular_permission_requests = Vec::new();
// Get denied tool IDs for later comparison
let denied_tool_ids: Vec<String> = denials.iter()
.map(|d| d.tool_use_id.clone())
.collect();
// Helper function to check if a tool is a system tool that should never require permission
let is_system_tool = |tool_name: &str| -> bool {
matches!(tool_name, "ExitPlanMode" | "EnterPlanMode")
};
for denial in denials {
// Skip system tools that should never require permission
if is_system_tool(&denial.tool_name) {
tracing::debug!(
"Skipping system tool: {} (id: {})",
denial.tool_name,
denial.tool_use_id
);
continue;
}
tracing::debug!(
"Processing permission denial for: {} (id: {})",
denial.tool_name,
denial.tool_use_id
);
// Special handling for AskUserQuestion tool
if denial.tool_name == "AskUserQuestion" {
if let Some(questions) = denial
.tool_input
.get("questions")
.and_then(|q| q.as_array())
{
// For now, handle the first question (most common case)
if let Some(first_question) = questions.first() {
let question_text = first_question
.get("question")
.and_then(|q| q.as_str())
.unwrap_or("Claude has a question for you")
.to_string();
let header = first_question
.get("header")
.and_then(|h| h.as_str())
.map(|s| s.to_string());
let multi_select = first_question
.get("multiSelect")
.and_then(|m| m.as_bool())
.unwrap_or(false);
let options: Vec<QuestionOption> = first_question
.get("options")
.and_then(|opts| opts.as_array())
.map(|opts| {
opts.iter()
.filter_map(|opt| {
let label =
opt.get("label").and_then(|l| l.as_str())?;
let description = opt
.get("description")
.and_then(|d| d.as_str())
.map(|s| s.to_string());
Some(QuestionOption {
label: label.to_string(),
description,
})
})
.collect()
})
.unwrap_or_default();
let _ = app.emit(
"claude:question",
UserQuestionEvent {
id: denial.tool_use_id.clone(),
question: question_text,
header,
options,
multi_select,
conversation_id: conversation_id.clone(),
},
);
}
}
} else {
let description =
format_tool_description(&denial.tool_name, &denial.tool_input);
regular_permission_requests.push(PermissionPromptEventItem {
id: denial.tool_use_id.clone(),
tool_name: denial.tool_name.clone(),
tool_input: denial.tool_input.clone(),
description,
});
}
}
// Check for sibling tools that may have been cancelled
// Add them to the permission batch so they can be approved together
for tool_use in captured_pending_tools.iter() {
// Skip system tools that should never require permission
if is_system_tool(&tool_use.tool_name) {
continue;
}
// Only add tools that weren't explicitly denied (these are likely cancelled siblings)
if !denied_tool_ids.contains(&tool_use.tool_use_id) {
let description = format_tool_description(&tool_use.tool_name, &tool_use.tool_input);
regular_permission_requests.push(PermissionPromptEventItem {
id: tool_use.tool_use_id.clone(),
tool_name: tool_use.tool_name.clone(),
tool_input: tool_use.tool_input.clone(),
description,
});
}
}
// Emit all regular permission requests as a single batched event
if !regular_permission_requests.is_empty() {
tracing::info!(
"Emitting permission event for {} tool(s) in conversation {:?}",
regular_permission_requests.len(),
conversation_id
);
for req in &regular_permission_requests {
tracing::debug!(
"Permission requested: {} (id: {})",
req.tool_name,
req.id
);
}
let _ = app.emit(
"claude:permission",
PermissionPromptEvent {
permissions: regular_permission_requests,
conversation_id: conversation_id.clone(),
},
);
emit_state_change(
app,
CharacterState::Permission,
None,
conversation_id.clone(),
);
return Ok(());
}
// Show permission state if there were any question denials
if !denials.is_empty() {
emit_state_change(
app,
CharacterState::Permission,
None,
conversation_id.clone(),
);
return Ok(());
}
} // end of else block for non-empty denials
} // end of if let Some(denials)
emit_state_change(app, state, None, conversation_id.clone());
}
ClaudeMessage::User { message } => {
// Increment message count for user messages
stats.write().increment_messages();
// Process content blocks for tool results (e.g., background Task agent completions)
for block in &message.content {
if let ContentBlock::ToolResult {
tool_use_id,
is_error,
..
} = block
{
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_millis() as u64;
let _ = app.emit(
"claude:agent-end",
AgentEndEvent {
tool_use_id: tool_use_id.clone(),
ended_at: now,
is_error: is_error.unwrap_or(false),
conversation_id: conversation_id.clone(),
duration_ms: None,
num_turns: None,
},
);
}
}
// Extract text content from the message
let message_text = message
.content
.iter()
.filter_map(|block| match block {
crate::types::ContentBlock::Text { text } => Some(text.clone()),
_ => None,
})
.collect::<Vec<String>>()
.join(" ");
// Check achievements after user message
let newly_unlocked = {
let mut stats_guard = stats.write();
tracing::info!("User sent message, checking achievements...");
// Check message-based achievements
let mut unlocked = crate::achievements::check_message_achievements(
&message_text,
&mut stats_guard.achievements,
);
// Check stats-based achievements
let stats_unlocked = stats_guard.check_achievements();
unlocked.extend(stats_unlocked);
unlocked
};
// Emit achievement events for any newly unlocked achievements
for achievement_id in &newly_unlocked {
tracing::info!("User message unlocked achievement: {:?}", achievement_id);
let info = get_achievement_info(achievement_id);
let _ = app.emit(
"achievement:unlocked",
AchievementUnlockedEvent { achievement: info },
);
}
// Save achievements after unlocking new ones
if !newly_unlocked.is_empty() {
tracing::info!("Saving newly unlocked achievements from user message");
let app_handle = app.clone();
let achievements_progress = stats.read().achievements.clone();
tauri::async_runtime::spawn(async move {
if let Err(e) =
crate::achievements::save_achievements(&app_handle, &achievements_progress)
.await
{
tracing::error!("Failed to save achievements: {}", e);
} else {
tracing::info!("Achievements saved after user message");
}
});
}
emit_state_change(app, CharacterState::Thinking, None, conversation_id.clone());
}
}
Ok(())
}
fn get_tool_state(tool_name: &str) -> CharacterState {
if SEARCH_TOOLS.contains(&tool_name) {
CharacterState::Searching
} else if CODING_TOOLS.contains(&tool_name) {
CharacterState::Coding
} else if tool_name.starts_with("mcp__") {
CharacterState::Mcp
} else if tool_name == "Task" || tool_name.starts_with("Task(") {
CharacterState::Thinking
} else {
CharacterState::Typing
}
}
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 {
path.contains("/.claude/") && (path.contains("/memory/") || path.ends_with("/MEMORY.md"))
}
match name {
"Read" => {
if let Some(path) = input.get("file_path").and_then(|v| v.as_str()) {
if is_memory_path(path) {
// Extract just the filename for cleaner display
let filename = path.split('/').next_back().unwrap_or(path);
format!("📝 Reading memory: {}", filename)
} else {
format!("Reading file: {}", path)
}
} else {
"Reading file...".to_string()
}
}
"Glob" => {
if let Some(pattern) = input.get("pattern").and_then(|v| v.as_str()) {
format!("Searching for files: {}", pattern)
} else {
"Searching for files...".to_string()
}
}
"Grep" => {
if let Some(pattern) = input.get("pattern").and_then(|v| v.as_str()) {
format!("Searching for: {}", pattern)
} else {
"Searching in files...".to_string()
}
}
"Edit" => {
if let Some(path) = input.get("file_path").and_then(|v| v.as_str()) {
if is_memory_path(path) {
let filename = path.split('/').next_back().unwrap_or(path);
format!("💾 Updating memory: {}", filename)
} else {
format!("Editing: {}", path)
}
} else {
"Editing file...".to_string()
}
}
"Write" => {
if let Some(path) = input.get("file_path").and_then(|v| v.as_str()) {
if is_memory_path(path) {
let filename = path.split('/').next_back().unwrap_or(path);
format!("💾 Writing memory: {}", filename)
} else {
format!("Editing: {}", path)
}
} else {
"Editing file...".to_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)
} else {
"Running command...".to_string()
}
}
_ => format!("Using tool: {}", name),
}
}
fn emit_state_change(
app: &AppHandle,
state: CharacterState,
tool_name: Option<String>,
conversation_id: Option<String>,
) {
let _ = app.emit(
"claude:state",
StateChangeEvent {
state,
tool_name,
conversation_id,
},
);
}
fn emit_connection_status(
app: &AppHandle,
status: ConnectionStatus,
conversation_id: Option<String>,
) {
let _ = app.emit(
"claude:connection",
ConnectionEvent {
status,
conversation_id,
},
);
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_get_tool_state_search_tools() {
assert!(matches!(get_tool_state("Read"), CharacterState::Searching));
assert!(matches!(get_tool_state("Glob"), CharacterState::Searching));
assert!(matches!(get_tool_state("Grep"), CharacterState::Searching));
assert!(matches!(
get_tool_state("WebSearch"),
CharacterState::Searching
));
assert!(matches!(
get_tool_state("WebFetch"),
CharacterState::Searching
));
}
#[test]
fn test_get_tool_state_coding_tools() {
assert!(matches!(get_tool_state("Edit"), CharacterState::Coding));
assert!(matches!(get_tool_state("Write"), CharacterState::Coding));
assert!(matches!(
get_tool_state("NotebookEdit"),
CharacterState::Coding
));
}
#[test]
fn test_get_tool_state_mcp_tools() {
assert!(matches!(
get_tool_state("mcp__github__create_issue"),
CharacterState::Mcp
));
assert!(matches!(
get_tool_state("mcp__notion__search"),
CharacterState::Mcp
));
}
#[test]
fn test_get_tool_state_task() {
assert!(matches!(get_tool_state("Task"), CharacterState::Thinking));
// Test CLI v2.1.33+ Task(agent_type) syntax
assert!(matches!(
get_tool_state("Task(Explore)"),
CharacterState::Thinking
));
assert!(matches!(
get_tool_state("Task(Plan)"),
CharacterState::Thinking
));
assert!(matches!(
get_tool_state("Task(general-purpose)"),
CharacterState::Thinking
));
}
#[test]
fn test_get_tool_state_unknown() {
assert!(matches!(
get_tool_state("SomeUnknownTool"),
CharacterState::Typing
));
assert!(matches!(get_tool_state("Bash"), CharacterState::Typing));
}
#[test]
fn test_format_tool_description_read() {
let input = serde_json::json!({"file_path": "/home/test/file.txt"});
let desc = format_tool_description("Read", &input);
assert_eq!(desc, "Reading file: /home/test/file.txt");
}
#[test]
fn test_format_tool_description_read_no_path() {
let input = serde_json::json!({});
let desc = format_tool_description("Read", &input);
assert_eq!(desc, "Reading file...");
}
#[test]
fn test_format_tool_description_glob() {
let input = serde_json::json!({"pattern": "**/*.rs"});
let desc = format_tool_description("Glob", &input);
assert_eq!(desc, "Searching for files: **/*.rs");
}
#[test]
fn test_format_tool_description_grep() {
let input = serde_json::json!({"pattern": "TODO"});
let desc = format_tool_description("Grep", &input);
assert_eq!(desc, "Searching for: TODO");
}
#[test]
fn test_format_tool_description_edit() {
let input = serde_json::json!({"file_path": "/home/test/main.rs"});
let desc = format_tool_description("Edit", &input);
assert_eq!(desc, "Editing: /home/test/main.rs");
}
#[test]
fn test_format_tool_description_write() {
let input = serde_json::json!({"file_path": "/home/test/new.txt"});
let desc = format_tool_description("Write", &input);
assert_eq!(desc, "Editing: /home/test/new.txt");
}
#[test]
fn test_format_tool_description_bash_short() {
let input = serde_json::json!({"command": "ls -la"});
let desc = format_tool_description("Bash", &input);
assert_eq!(desc, "Running: ls -la");
}
#[test]
fn test_format_tool_description_bash_long() {
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);
}
#[test]
fn test_format_tool_description_unknown() {
let input = serde_json::json!({"some": "data"});
let desc = format_tool_description("CustomTool", &input);
assert_eq!(desc, "Using tool: CustomTool");
}
#[test]
fn test_format_tool_description_memory_read() {
let input =
serde_json::json!({"file_path": "/home/user/.claude/projects/test/memory/MEMORY.md"});
let desc = format_tool_description("Read", &input);
assert_eq!(desc, "📝 Reading memory: MEMORY.md");
}
#[test]
fn test_format_tool_description_memory_write() {
let input = serde_json::json!(
{"file_path": "/home/user/.claude/projects/test/memory/notes.md"}
);
let desc = format_tool_description("Write", &input);
assert_eq!(desc, "💾 Writing memory: notes.md");
}
#[test]
fn test_format_tool_description_memory_edit() {
let input = serde_json::json!(
{"file_path": "/home/user/.claude/projects/test/memory/patterns.md"}
);
let desc = format_tool_description("Edit", &input);
assert_eq!(desc, "💾 Updating memory: patterns.md");
}
#[test]
fn test_format_tool_description_non_memory_read() {
let input = serde_json::json!({"file_path": "/home/user/code/test.txt"});
let desc = format_tool_description("Read", &input);
assert_eq!(desc, "Reading file: /home/user/code/test.txt");
}
#[test]
fn test_wsl_bridge_new() {
let bridge = WslBridge::new();
assert!(!bridge.is_running());
assert_eq!(bridge.get_working_directory(), "");
}
#[test]
fn test_wsl_bridge_default() {
let bridge = WslBridge::default();
assert!(!bridge.is_running());
}
#[test]
fn test_create_shared_bridge_manager() {
use crate::bridge_manager::create_shared_bridge_manager;
let shared = create_shared_bridge_manager();
let manager = shared.lock();
assert!(manager.get_active_conversations().is_empty());
}
}