diff --git a/backend/src/executor.rs b/backend/src/executor.rs index b6b117b8..52cbd195 100644 --- a/backend/src/executor.rs +++ b/backend/src/executor.rs @@ -710,6 +710,7 @@ fn parse_session_id_from_line(line: &str) -> Option { #[cfg(test)] mod tests { use super::*; + use crate::executors::{AmpExecutor, ClaudeExecutor}; #[test] fn test_parse_claude_session_id() { @@ -779,4 +780,116 @@ mod tests { .unwrap() .contains("**Formula:** a² + b² = c²")); } + + #[test] + fn test_amp_log_normalization() { + let amp_executor = AmpExecutor; + let amp_logs = r#"{"type":"initial","threadID":"T-f8f7fec0-b330-47ab-b63a-b72c42f1ef6a"} +{"type":"messages","messages":[[0,{"role":"user","content":[{"type":"text","text":"Task title: Create and start should open task\nTask description: When I press 'create & start' on task creation dialog it should then open the task in the sidebar"}],"meta":{"sentAt":1751544747623}}]],"toolResults":[]} +{"type":"messages","messages":[[1,{"role":"assistant","content":[{"type":"thinking","thinking":"The user wants to implement a feature where pressing \"create & start\" on the task creation dialog should open the task in the sidebar."},{"type":"text","text":"I'll help you implement the \"create & start\" functionality. Let me explore the codebase to understand the current task creation and sidebar structure."},{"type":"tool_use","id":"toolu_01FQqskzGAhZaZu8H6qSs5pV","name":"todo_write","input":{"todos":[{"id":"1","content":"Explore task creation dialog component","status":"todo","priority":"high"}]}}],"state":{"type":"complete","stopReason":"tool_use"}}]],"toolResults":[]}"#; + + let result = amp_executor + .normalize_logs(amp_logs, "/tmp/test-worktree") + .unwrap(); + + assert_eq!(result.executor_type, "amp"); + assert_eq!( + result.session_id, + Some("T-f8f7fec0-b330-47ab-b63a-b72c42f1ef6a".to_string()) + ); + assert!(!result.entries.is_empty()); + + // Check that we have user message, assistant message, thinking, and tool use entries + let user_messages: Vec<_> = result + .entries + .iter() + .filter(|e| matches!(e.entry_type, NormalizedEntryType::UserMessage)) + .collect(); + assert!(!user_messages.is_empty()); + + let assistant_messages: Vec<_> = result + .entries + .iter() + .filter(|e| matches!(e.entry_type, NormalizedEntryType::AssistantMessage)) + .collect(); + assert!(!assistant_messages.is_empty()); + + let thinking_entries: Vec<_> = result + .entries + .iter() + .filter(|e| matches!(e.entry_type, NormalizedEntryType::Thinking)) + .collect(); + assert!(!thinking_entries.is_empty()); + + let tool_uses: Vec<_> = result + .entries + .iter() + .filter(|e| matches!(e.entry_type, NormalizedEntryType::ToolUse { .. })) + .collect(); + assert!(!tool_uses.is_empty()); + + // Check that tool use content is concise (not the old verbose format) + let todo_tool_use = tool_uses.iter().find(|e| match &e.entry_type { + NormalizedEntryType::ToolUse { tool_name, .. } => tool_name == "todo_write", + _ => false, + }); + assert!(todo_tool_use.is_some()); + let todo_tool_use = todo_tool_use.unwrap(); + // Should be concise, not "Tool: todo_write with input: ..." + assert_eq!( + todo_tool_use.content, + "TODO List:\n⏳ Explore task creation dialog component (high)" + ); + } + + #[test] + fn test_claude_log_normalization() { + let claude_executor = ClaudeExecutor; + let claude_logs = r#"{"type":"system","subtype":"init","cwd":"/private/tmp/mission-control-worktree-8ff34214-7bb4-4a5a-9f47-bfdf79e20368","session_id":"499dcce4-04aa-4a3e-9e0c-ea0228fa87c9","tools":["Task","Bash","Glob","Grep","LS","exit_plan_mode","Read","Edit","MultiEdit","Write","NotebookRead","NotebookEdit","WebFetch","TodoRead","TodoWrite","WebSearch"],"mcp_servers":[],"model":"claude-sonnet-4-20250514","permissionMode":"bypassPermissions","apiKeySource":"none"} +{"type":"assistant","message":{"id":"msg_014xUHgkAhs6cRx5WVT3s7if","type":"message","role":"assistant","model":"claude-sonnet-4-20250514","content":[{"type":"text","text":"I'll help you list your projects using vibe-kanban. Let me first explore the codebase to understand how vibe-kanban works and find your projects."}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":4,"cache_creation_input_tokens":13497,"cache_read_input_tokens":0,"output_tokens":1,"service_tier":"standard"}},"parent_tool_use_id":null,"session_id":"499dcce4-04aa-4a3e-9e0c-ea0228fa87c9"} +{"type":"assistant","message":{"id":"msg_014xUHgkAhs6cRx5WVT3s7if","type":"message","role":"assistant","model":"claude-sonnet-4-20250514","content":[{"type":"tool_use","id":"toolu_01Br3TvXdmW6RPGpB5NihTHh","name":"Task","input":{"description":"Find vibe-kanban projects","prompt":"I need to find and list projects using vibe-kanban."}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":4,"cache_creation_input_tokens":13497,"cache_read_input_tokens":0,"output_tokens":1,"service_tier":"standard"}},"parent_tool_use_id":null,"session_id":"499dcce4-04aa-4a3e-9e0c-ea0228fa87c9"}"#; + + let result = claude_executor + .normalize_logs(claude_logs, "/tmp/test-worktree") + .unwrap(); + + assert_eq!(result.executor_type, "claude"); + assert_eq!( + result.session_id, + Some("499dcce4-04aa-4a3e-9e0c-ea0228fa87c9".to_string()) + ); + assert!(!result.entries.is_empty()); + + // Check that we have system, assistant message, and tool use entries + let system_messages: Vec<_> = result + .entries + .iter() + .filter(|e| matches!(e.entry_type, NormalizedEntryType::SystemMessage)) + .collect(); + assert!(!system_messages.is_empty()); + + let assistant_messages: Vec<_> = result + .entries + .iter() + .filter(|e| matches!(e.entry_type, NormalizedEntryType::AssistantMessage)) + .collect(); + assert!(!assistant_messages.is_empty()); + + let tool_uses: Vec<_> = result + .entries + .iter() + .filter(|e| matches!(e.entry_type, NormalizedEntryType::ToolUse { .. })) + .collect(); + assert!(!tool_uses.is_empty()); + + // Check that tool use content is concise (not the old verbose format) + let task_tool_use = tool_uses.iter().find(|e| match &e.entry_type { + NormalizedEntryType::ToolUse { tool_name, .. } => tool_name == "Task", + _ => false, + }); + assert!(task_tool_use.is_some()); + let task_tool_use = task_tool_use.unwrap(); + // Should be the task description, not "Tool: Task with input: ..." + assert_eq!(task_tool_use.content, "Find vibe-kanban projects"); + } } diff --git a/backend/src/executor_tests.rs b/backend/src/executor_tests.rs deleted file mode 100644 index c95feac5..00000000 --- a/backend/src/executor_tests.rs +++ /dev/null @@ -1,116 +0,0 @@ -#[cfg(test)] -mod tests { - use crate::{ - executor::{Executor, NormalizedEntryType}, - executors::{AmpExecutor, ClaudeExecutor}, - }; - - #[test] - fn test_amp_log_normalization() { - let amp_executor = AmpExecutor; - let amp_logs = r#"{"type":"initial","threadID":"T-f8f7fec0-b330-47ab-b63a-b72c42f1ef6a"} -{"type":"messages","messages":[[0,{"role":"user","content":[{"type":"text","text":"Task title: Create and start should open task\nTask description: When I press 'create & start' on task creation dialog it should then open the task in the sidebar"}],"meta":{"sentAt":1751544747623}}]],"toolResults":[]} -{"type":"messages","messages":[[1,{"role":"assistant","content":[{"type":"thinking","thinking":"The user wants to implement a feature where pressing \"create & start\" on the task creation dialog should open the task in the sidebar."},{"type":"text","text":"I'll help you implement the \"create & start\" functionality. Let me explore the codebase to understand the current task creation and sidebar structure."},{"type":"tool_use","id":"toolu_01FQqskzGAhZaZu8H6qSs5pV","name":"todo_write","input":{"todos":[{"id":"1","content":"Explore task creation dialog component","status":"todo","priority":"high"}]}}],"state":{"type":"complete","stopReason":"tool_use"}}]],"toolResults":[]}"#; - - let result = amp_executor - .normalize_logs(amp_logs, "/tmp/test-worktree") - .unwrap(); - - assert_eq!(result.executor_type, "amp"); - assert_eq!( - result.session_id, - Some("T-f8f7fec0-b330-47ab-b63a-b72c42f1ef6a".to_string()) - ); - assert!(!result.entries.is_empty()); - - // Check that we have user message, assistant message, thinking, and tool use entries - let user_messages: Vec<_> = result - .entries - .iter() - .filter(|e| matches!(e.entry_type, NormalizedEntryType::UserMessage)) - .collect(); - assert!(!user_messages.is_empty()); - - let assistant_messages: Vec<_> = result - .entries - .iter() - .filter(|e| matches!(e.entry_type, NormalizedEntryType::AssistantMessage)) - .collect(); - assert!(!assistant_messages.is_empty()); - - let thinking_entries: Vec<_> = result - .entries - .iter() - .filter(|e| matches!(e.entry_type, NormalizedEntryType::Thinking)) - .collect(); - assert!(!thinking_entries.is_empty()); - - let tool_uses: Vec<_> = result - .entries - .iter() - .filter(|e| matches!(e.entry_type, NormalizedEntryType::ToolUse { .. })) - .collect(); - assert!(!tool_uses.is_empty()); - - // Check that tool use content is concise (not the old verbose format) - let todo_tool_use = tool_uses.iter().find(|e| match &e.entry_type { - NormalizedEntryType::ToolUse { tool_name, .. } => tool_name == "todo_write", - _ => false, - }); - assert!(todo_tool_use.is_some()); - let todo_tool_use = todo_tool_use.unwrap(); - // Should be concise, not "Tool: todo_write with input: ..." - assert_eq!(todo_tool_use.content, "Managing TODO list"); - } - - #[test] - fn test_claude_log_normalization() { - let claude_executor = ClaudeExecutor; - let claude_logs = r#"{"type":"system","subtype":"init","cwd":"/private/tmp/mission-control-worktree-8ff34214-7bb4-4a5a-9f47-bfdf79e20368","session_id":"499dcce4-04aa-4a3e-9e0c-ea0228fa87c9","tools":["Task","Bash","Glob","Grep","LS","exit_plan_mode","Read","Edit","MultiEdit","Write","NotebookRead","NotebookEdit","WebFetch","TodoRead","TodoWrite","WebSearch"],"mcp_servers":[],"model":"claude-sonnet-4-20250514","permissionMode":"bypassPermissions","apiKeySource":"none"} -{"type":"assistant","message":{"id":"msg_014xUHgkAhs6cRx5WVT3s7if","type":"message","role":"assistant","model":"claude-sonnet-4-20250514","content":[{"type":"text","text":"I'll help you list your projects using vibe-kanban. Let me first explore the codebase to understand how vibe-kanban works and find your projects."}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":4,"cache_creation_input_tokens":13497,"cache_read_input_tokens":0,"output_tokens":1,"service_tier":"standard"}},"parent_tool_use_id":null,"session_id":"499dcce4-04aa-4a3e-9e0c-ea0228fa87c9"} -{"type":"assistant","message":{"id":"msg_014xUHgkAhs6cRx5WVT3s7if","type":"message","role":"assistant","model":"claude-sonnet-4-20250514","content":[{"type":"tool_use","id":"toolu_01Br3TvXdmW6RPGpB5NihTHh","name":"Task","input":{"description":"Find vibe-kanban projects","prompt":"I need to find and list projects using vibe-kanban."}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":4,"cache_creation_input_tokens":13497,"cache_read_input_tokens":0,"output_tokens":1,"service_tier":"standard"}},"parent_tool_use_id":null,"session_id":"499dcce4-04aa-4a3e-9e0c-ea0228fa87c9"}"#; - - let result = claude_executor - .normalize_logs(claude_logs, "/tmp/test-worktree") - .unwrap(); - - assert_eq!(result.executor_type, "claude"); - assert_eq!( - result.session_id, - Some("499dcce4-04aa-4a3e-9e0c-ea0228fa87c9".to_string()) - ); - assert!(!result.entries.is_empty()); - - // Check that we have system, assistant message, and tool use entries - let system_messages: Vec<_> = result - .entries - .iter() - .filter(|e| matches!(e.entry_type, NormalizedEntryType::SystemMessage)) - .collect(); - assert!(!system_messages.is_empty()); - - let assistant_messages: Vec<_> = result - .entries - .iter() - .filter(|e| matches!(e.entry_type, NormalizedEntryType::AssistantMessage)) - .collect(); - assert!(!assistant_messages.is_empty()); - - let tool_uses: Vec<_> = result - .entries - .iter() - .filter(|e| matches!(e.entry_type, NormalizedEntryType::ToolUse { .. })) - .collect(); - assert!(!tool_uses.is_empty()); - - // Check that tool use content is concise (not the old verbose format) - let task_tool_use = tool_uses.iter().find(|e| match &e.entry_type { - NormalizedEntryType::ToolUse { tool_name, .. } => tool_name == "Task", - _ => false, - }); - assert!(task_tool_use.is_some()); - let task_tool_use = task_tool_use.unwrap(); - // Should be the task description, not "Tool: Task with input: ..." - assert_eq!(task_tool_use.content, "Find vibe-kanban projects"); - } -} diff --git a/backend/src/executors/amp.rs b/backend/src/executors/amp.rs index ce87fd04..08dd4ded 100644 --- a/backend/src/executors/amp.rs +++ b/backend/src/executors/amp.rs @@ -91,7 +91,7 @@ Task title: {}"#, fn normalize_logs( &self, logs: &str, - _worktree_path: &str, + worktree_path: &str, ) -> Result { use serde_json::Value; @@ -199,13 +199,16 @@ Task title: {}"#, .unwrap_or(&Value::Null); let action_type = self .extract_action_type( - tool_name, input, + tool_name, + input, + worktree_path, ); let content = self .generate_concise_content( tool_name, input, &action_type, + worktree_path, ); entries.push(NormalizedEntry { @@ -235,7 +238,7 @@ Task title: {}"#, true } // Ignore these JSON types - they're not relevant for task execution logs - "initial" | "token-usage" | "state" => true, + "initial" | "token-usage" | "state" | "shutdown" => true, _ => false, } } else { @@ -271,20 +274,19 @@ Task title: {}"#, } impl AmpExecutor { - /// Convert absolute paths to relative paths based on current working directory - fn make_path_relative(&self, path: &str) -> String { + /// Convert absolute paths to relative paths based on worktree path + fn make_path_relative(&self, path: &str, worktree_path: &str) -> String { let path_obj = Path::new(path); + let worktree_obj = Path::new(worktree_path); // If path is already relative, return as is if path_obj.is_relative() { return path.to_string(); } - // Try to get current working directory and make path relative to it - if let Ok(current_dir) = std::env::current_dir() { - if let Ok(relative_path) = path_obj.strip_prefix(¤t_dir) { - return relative_path.to_string_lossy().to_string(); - } + // Try to make path relative to worktree path + if let Ok(relative_path) = path_obj.strip_prefix(worktree_obj) { + return relative_path.to_string_lossy().to_string(); } // If we can't make it relative, return the original path @@ -296,37 +298,141 @@ impl AmpExecutor { tool_name: &str, input: &serde_json::Value, action_type: &ActionType, + worktree_path: &str, ) -> String { match action_type { - ActionType::FileRead { path } => path.clone(), - ActionType::FileWrite { path } => path.clone(), - ActionType::CommandRun { command } => command.clone(), - ActionType::Search { query } => query.clone(), - ActionType::WebFetch { url } => url.clone(), + ActionType::FileRead { path } => format!("`{}`", path), + ActionType::FileWrite { path } => format!("`{}`", path), + ActionType::CommandRun { command } => format!("`{}`", command), + ActionType::Search { query } => format!("`{}`", query), + ActionType::WebFetch { url } => format!("`{}`", url), ActionType::TaskCreate { description } => description.clone(), ActionType::Other { description: _ } => { // For other tools, try to extract key information or fall back to tool name match tool_name.to_lowercase().as_str() { - "todo_write" | "todo_read" => "Managing TODO list".to_string(), - "list_directory" | "ls" => { + "todowrite" | "todoread" | "todo_write" | "todo_read" => { + if let Some(todos) = input.get("todos").and_then(|t| t.as_array()) { + let mut todo_items = Vec::new(); + for todo in todos { + if let (Some(content), Some(status)) = ( + todo.get("content").and_then(|c| c.as_str()), + todo.get("status").and_then(|s| s.as_str()), + ) { + let emoji = match status { + "completed" => "✅", + "in_progress" | "in-progress" => "🔄", + "pending" | "todo" => "⏳", + _ => "📝", + }; + let priority = todo + .get("priority") + .and_then(|p| p.as_str()) + .unwrap_or("medium"); + todo_items + .push(format!("{} {} ({})", emoji, content, priority)); + } + } + if !todo_items.is_empty() { + format!("TODO List:\n{}", todo_items.join("\n")) + } else { + "Managing TODO list".to_string() + } + } else { + "Managing TODO list".to_string() + } + } + "ls" => { if let Some(path) = input.get("path").and_then(|p| p.as_str()) { - format!("List directory: {}", self.make_path_relative(path)) + let relative_path = self.make_path_relative(path, worktree_path); + if relative_path.is_empty() { + "List directory".to_string() + } else { + format!("List directory: `{}`", relative_path) + } } else { "List directory".to_string() } } - "codebase_search_agent" => { - if let Some(query) = input.get("query").and_then(|q| q.as_str()) { - format!("Search: {}", query) + "glob" => { + let pattern = input.get("pattern").and_then(|p| p.as_str()).unwrap_or("*"); + let path = input.get("path").and_then(|p| p.as_str()); + + if let Some(path) = path { + let relative_path = self.make_path_relative(path, worktree_path); + format!("Find files: `{}` in `{}`", pattern, relative_path) } else { - "Codebase search".to_string() + format!("Find files: `{}`", pattern) } } - "glob" => { - if let Some(pattern) = input.get("filePattern").and_then(|p| p.as_str()) { - format!("File pattern: {}", pattern) + "grep" => { + let pattern = input.get("pattern").and_then(|p| p.as_str()).unwrap_or(""); + let include = input.get("include").and_then(|i| i.as_str()); + let path = input.get("path").and_then(|p| p.as_str()); + + let mut parts = vec![format!("Search: `{}`", pattern)]; + if let Some(include) = include { + parts.push(format!("in `{}`", include)); + } + if let Some(path) = path { + let relative_path = self.make_path_relative(path, worktree_path); + parts.push(format!("at `{}`", relative_path)); + } + parts.join(" ") + } + "read" => { + if let Some(file_path) = input.get("file_path").and_then(|p| p.as_str()) { + let relative_path = self.make_path_relative(file_path, worktree_path); + format!("Read file: `{}`", relative_path) } else { - "File pattern search".to_string() + "Read file".to_string() + } + } + "write" => { + if let Some(file_path) = input.get("file_path").and_then(|p| p.as_str()) { + let relative_path = self.make_path_relative(file_path, worktree_path); + format!("Write file: `{}`", relative_path) + } else { + "Write file".to_string() + } + } + "edit" => { + if let Some(file_path) = input.get("file_path").and_then(|p| p.as_str()) { + let relative_path = self.make_path_relative(file_path, worktree_path); + format!("Edit file: `{}`", relative_path) + } else { + "Edit file".to_string() + } + } + "multiedit" => { + if let Some(file_path) = input.get("file_path").and_then(|p| p.as_str()) { + let relative_path = self.make_path_relative(file_path, worktree_path); + format!("Multi-edit file: `{}`", relative_path) + } else { + "Multi-edit file".to_string() + } + } + "bash" => { + if let Some(command) = input.get("command").and_then(|c| c.as_str()) { + format!("Run command: `{}`", command) + } else { + "Run command".to_string() + } + } + "webfetch" => { + if let Some(url) = input.get("url").and_then(|u| u.as_str()) { + format!("Fetch URL: `{}`", url) + } else { + "Fetch URL".to_string() + } + } + "task" => { + if let Some(description) = input.get("description").and_then(|d| d.as_str()) + { + format!("Task: {}", description) + } else if let Some(prompt) = input.get("prompt").and_then(|p| p.as_str()) { + format!("Task: {}", prompt) + } else { + "Task".to_string() } } _ => tool_name.to_string(), @@ -335,16 +441,21 @@ impl AmpExecutor { } } - fn extract_action_type(&self, tool_name: &str, input: &serde_json::Value) -> ActionType { + fn extract_action_type( + &self, + tool_name: &str, + input: &serde_json::Value, + worktree_path: &str, + ) -> ActionType { match tool_name.to_lowercase().as_str() { "read_file" | "read" => { if let Some(path) = input.get("path").and_then(|p| p.as_str()) { ActionType::FileRead { - path: self.make_path_relative(path), + path: self.make_path_relative(path, worktree_path), } } else if let Some(file_path) = input.get("file_path").and_then(|p| p.as_str()) { ActionType::FileRead { - path: self.make_path_relative(file_path), + path: self.make_path_relative(file_path, worktree_path), } } else { ActionType::Other { @@ -352,14 +463,14 @@ impl AmpExecutor { } } } - "edit_file" | "write" | "create_file" => { + "edit_file" | "write" | "create_file" | "edit" | "multiedit" => { if let Some(path) = input.get("path").and_then(|p| p.as_str()) { ActionType::FileWrite { - path: self.make_path_relative(path), + path: self.make_path_relative(path, worktree_path), } } else if let Some(file_path) = input.get("file_path").and_then(|p| p.as_str()) { ActionType::FileWrite { - path: self.make_path_relative(file_path), + path: self.make_path_relative(file_path, worktree_path), } } else { ActionType::Other { @@ -423,6 +534,15 @@ impl AmpExecutor { } } } + "glob" => ActionType::Other { + description: "File pattern search".to_string(), + }, + "ls" => ActionType::Other { + description: "List directory".to_string(), + }, + "todowrite" | "todoread" | "todo_write" | "todo_read" => ActionType::Other { + description: "Manage TODO list".to_string(), + }, _ => ActionType::Other { description: format!("Tool: {}", tool_name), }, diff --git a/backend/src/executors/claude.rs b/backend/src/executors/claude.rs index 0f74ebf9..0f164fa9 100644 --- a/backend/src/executors/claude.rs +++ b/backend/src/executors/claude.rs @@ -294,26 +294,77 @@ impl ClaudeExecutor { worktree_path: &str, ) -> String { match action_type { - ActionType::FileRead { path } => path.clone(), - ActionType::FileWrite { path } => path.clone(), - ActionType::CommandRun { command } => command.clone(), - ActionType::Search { query } => query.clone(), - ActionType::WebFetch { url } => url.clone(), + ActionType::FileRead { path } => format!("`{}`", path), + ActionType::FileWrite { path } => format!("`{}`", path), + ActionType::CommandRun { command } => format!("`{}`", command), + ActionType::Search { query } => format!("`{}`", query), + ActionType::WebFetch { url } => format!("`{}`", url), ActionType::TaskCreate { description } => description.clone(), ActionType::Other { description: _ } => { // For other tools, try to extract key information or fall back to tool name match tool_name.to_lowercase().as_str() { - "todoread" | "todowrite" => "Managing TODO list".to_string(), + "todoread" | "todowrite" => { + // Extract todo list from input to show actual todos + if let Some(todos) = input.get("todos").and_then(|t| t.as_array()) { + let mut todo_items = Vec::new(); + for todo in todos { + if let Some(content) = todo.get("content").and_then(|c| c.as_str()) + { + let status = todo + .get("status") + .and_then(|s| s.as_str()) + .unwrap_or("pending"); + let status_emoji = match status { + "completed" => "✅", + "in_progress" => "🔄", + "pending" | "todo" => "⏳", + _ => "📝", + }; + let priority = todo + .get("priority") + .and_then(|p| p.as_str()) + .unwrap_or("medium"); + todo_items.push(format!( + "{} {} ({})", + status_emoji, content, priority + )); + } + } + if !todo_items.is_empty() { + format!("TODO List:\n{}", todo_items.join("\n")) + } else { + "Managing TODO list".to_string() + } + } else { + "Managing TODO list".to_string() + } + } "ls" => { if let Some(path) = input.get("path").and_then(|p| p.as_str()) { - format!( - "List directory: {}", - self.make_path_relative(path, worktree_path) - ) + let relative_path = self.make_path_relative(path, worktree_path); + if relative_path.is_empty() { + "List directory".to_string() + } else { + format!("List directory: `{}`", relative_path) + } } else { "List directory".to_string() } } + "glob" => { + let pattern = input.get("pattern").and_then(|p| p.as_str()).unwrap_or("*"); + let path = input.get("path").and_then(|p| p.as_str()); + + if let Some(search_path) = path { + format!( + "Find files: `{}` in `{}`", + pattern, + self.make_path_relative(search_path, worktree_path) + ) + } else { + format!("Find files: `{}`", pattern) + } + } "codebase_search_agent" => { if let Some(query) = input.get("query").and_then(|q| q.as_str()) { format!("Search: {}", query) @@ -383,9 +434,9 @@ impl ClaudeExecutor { } } "glob" => { - if let Some(file_pattern) = input.get("filePattern").and_then(|p| p.as_str()) { - ActionType::Search { - query: file_pattern.to_string(), + if let Some(pattern) = input.get("pattern").and_then(|p| p.as_str()) { + ActionType::Other { + description: format!("Find files: {}", pattern), } } else { ActionType::Other { @@ -523,4 +574,153 @@ mod tests { let result = executor.make_path_relative(&absolute_path, test_worktree); assert_eq!(result, "src/main.rs"); } + + #[test] + fn test_todo_tool_content_extraction() { + let executor = ClaudeExecutor; + + // Test TodoWrite with actual todo list + let todo_input = serde_json::json!({ + "todos": [ + { + "id": "1", + "content": "Fix the navigation bug", + "status": "completed", + "priority": "high" + }, + { + "id": "2", + "content": "Add user authentication", + "status": "in_progress", + "priority": "medium" + }, + { + "id": "3", + "content": "Write documentation", + "status": "pending", + "priority": "low" + } + ] + }); + + let result = executor.generate_concise_content( + "TodoWrite", + &todo_input, + &ActionType::Other { + description: "Tool: TodoWrite".to_string(), + }, + "/tmp/test-worktree", + ); + + assert!(result.contains("TODO List:")); + assert!(result.contains("✅ Fix the navigation bug (high)")); + assert!(result.contains("🔄 Add user authentication (medium)")); + assert!(result.contains("⏳ Write documentation (low)")); + } + + #[test] + fn test_todo_tool_empty_list() { + let executor = ClaudeExecutor; + + // Test TodoWrite with empty todo list + let empty_input = serde_json::json!({ + "todos": [] + }); + + let result = executor.generate_concise_content( + "TodoWrite", + &empty_input, + &ActionType::Other { + description: "Tool: TodoWrite".to_string(), + }, + "/tmp/test-worktree", + ); + + assert_eq!(result, "Managing TODO list"); + } + + #[test] + fn test_todo_tool_no_todos_field() { + let executor = ClaudeExecutor; + + // Test TodoWrite with no todos field + let no_todos_input = serde_json::json!({ + "other_field": "value" + }); + + let result = executor.generate_concise_content( + "TodoWrite", + &no_todos_input, + &ActionType::Other { + description: "Tool: TodoWrite".to_string(), + }, + "/tmp/test-worktree", + ); + + assert_eq!(result, "Managing TODO list"); + } + + #[test] + fn test_glob_tool_content_extraction() { + let executor = ClaudeExecutor; + + // Test Glob with pattern and path + let glob_input = serde_json::json!({ + "pattern": "**/*.ts", + "path": "/tmp/test-worktree/src" + }); + + let result = executor.generate_concise_content( + "Glob", + &glob_input, + &ActionType::Other { + description: "Find files: **/*.ts".to_string(), + }, + "/tmp/test-worktree", + ); + + assert_eq!(result, "Find files: `**/*.ts` in `src`"); + } + + #[test] + fn test_glob_tool_pattern_only() { + let executor = ClaudeExecutor; + + // Test Glob with pattern only + let glob_input = serde_json::json!({ + "pattern": "*.js" + }); + + let result = executor.generate_concise_content( + "Glob", + &glob_input, + &ActionType::Other { + description: "Find files: *.js".to_string(), + }, + "/tmp/test-worktree", + ); + + assert_eq!(result, "Find files: `*.js`"); + } + + #[test] + fn test_ls_tool_content_extraction() { + let executor = ClaudeExecutor; + + // Test LS with path + let ls_input = serde_json::json!({ + "path": "/tmp/test-worktree/components" + }); + + let result = executor.generate_concise_content( + "LS", + &ls_input, + &ActionType::Other { + description: "Tool: LS".to_string(), + }, + "/tmp/test-worktree", + ); + + assert_eq!(result, "List directory: `components`"); + } } diff --git a/backend/src/lib.rs b/backend/src/lib.rs index 404b37d0..ecf5ea95 100644 --- a/backend/src/lib.rs +++ b/backend/src/lib.rs @@ -12,9 +12,6 @@ pub mod routes; pub mod services; pub mod utils; -#[cfg(test)] -mod executor_tests; - #[derive(RustEmbed)] #[folder = "../frontend/dist"] pub struct Assets; diff --git a/frontend/src/components/tasks/NormalizedConversationViewer.tsx b/frontend/src/components/tasks/NormalizedConversationViewer.tsx index 63f43927..84e8bd89 100644 --- a/frontend/src/components/tasks/NormalizedConversationViewer.tsx +++ b/frontend/src/components/tasks/NormalizedConversationViewer.tsx @@ -16,9 +16,10 @@ import { ChevronUp, ToggleLeft, ToggleRight, + CheckSquare, } from 'lucide-react'; -import Markdown from 'react-markdown'; import { makeRequest } from '@/lib/api'; +import { MarkdownRenderer } from '@/components/ui/markdown-renderer'; import type { NormalizedConversation, NormalizedEntry, @@ -50,7 +51,19 @@ const getEntryIcon = (entryType: NormalizedEntryType) => { return ; } if (entryType.type === 'tool_use') { - const { action_type } = entryType; + const { action_type, tool_name } = entryType; + + // Special handling for TODO tools + if ( + tool_name && + (tool_name.toLowerCase() === 'todowrite' || + tool_name.toLowerCase() === 'todoread' || + tool_name.toLowerCase() === 'todo_write' || + tool_name.toLowerCase() === 'todo_read') + ) { + return ; + } + if (action_type.action === 'file_read') { return ; } @@ -88,6 +101,18 @@ const getContentClassName = (entryType: NormalizedEntryType) => { return `${baseClasses} text-red-600 font-mono bg-red-50 dark:bg-red-950/20 px-2 py-1 rounded`; } + // Special styling for TODO lists + if ( + entryType.type === 'tool_use' && + entryType.tool_name && + (entryType.tool_name.toLowerCase() === 'todowrite' || + entryType.tool_name.toLowerCase() === 'todoread' || + entryType.tool_name.toLowerCase() === 'todo_write' || + entryType.tool_name.toLowerCase() === 'todo_read') + ) { + return `${baseClasses} font-mono text-purple-700 dark:text-purple-300 bg-purple-50 dark:bg-purple-950/20 px-2 py-1 rounded`; + } + return baseClasses; }; @@ -182,6 +207,37 @@ const clusterGeminiMessages = ( return clustered; }; +// Helper function to determine if content should be rendered as markdown +const shouldRenderMarkdown = (entryType: NormalizedEntryType) => { + // Render markdown for assistant messages and tool outputs that contain backticks + return ( + entryType.type === 'assistant_message' || + (entryType.type === 'tool_use' && + entryType.tool_name && + (entryType.tool_name.toLowerCase() === 'todowrite' || + entryType.tool_name.toLowerCase() === 'todoread' || + entryType.tool_name.toLowerCase() === 'todo_write' || + entryType.tool_name.toLowerCase() === 'todo_read' || + entryType.tool_name.toLowerCase() === 'glob' || + entryType.tool_name.toLowerCase() === 'ls' || + entryType.tool_name.toLowerCase() === 'list_directory' || + entryType.tool_name.toLowerCase() === 'read' || + entryType.tool_name.toLowerCase() === 'read_file' || + entryType.tool_name.toLowerCase() === 'write' || + entryType.tool_name.toLowerCase() === 'create_file' || + entryType.tool_name.toLowerCase() === 'edit' || + entryType.tool_name.toLowerCase() === 'edit_file' || + entryType.tool_name.toLowerCase() === 'multiedit' || + entryType.tool_name.toLowerCase() === 'bash' || + entryType.tool_name.toLowerCase() === 'run_command' || + entryType.tool_name.toLowerCase() === 'grep' || + entryType.tool_name.toLowerCase() === 'search' || + entryType.tool_name.toLowerCase() === 'webfetch' || + entryType.tool_name.toLowerCase() === 'web_fetch' || + entryType.tool_name.toLowerCase() === 'task')) + ); +}; + export function NormalizedConversationViewer({ executionProcess, projectId, @@ -358,7 +414,10 @@ export function NormalizedConversationViewer({
- {conversation.prompt} +
@@ -391,7 +450,14 @@ export function NormalizedConversationViewer({
{isExpanded ? ( - entry.content + shouldRenderMarkdown(entry.entry_type) ? ( + + ) : ( + entry.content + ) ) : ( <> {entry.content.split('\n')[0]} @@ -417,10 +483,11 @@ export function NormalizedConversationViewer({
) : (
- {entry.entry_type.type === 'assistant_message' ? ( -
- {entry.content} -
+ {shouldRenderMarkdown(entry.entry_type) ? ( + ) : ( entry.content )} diff --git a/frontend/src/components/tasks/TaskDetailsPanel.tsx b/frontend/src/components/tasks/TaskDetailsPanel.tsx index c7720bbe..2ce7ba36 100644 --- a/frontend/src/components/tasks/TaskDetailsPanel.tsx +++ b/frontend/src/components/tasks/TaskDetailsPanel.tsx @@ -19,7 +19,14 @@ import { DialogHeader, DialogTitle, } from '@/components/ui/dialog'; -import { FileText, ChevronDown, ChevronUp, Trash2 } from 'lucide-react'; +import { + FileText, + ChevronDown, + ChevronUp, + Trash2, + Eye, + EyeOff, +} from 'lucide-react'; import type { TaskWithAttemptStatus, EditorType, @@ -84,8 +91,10 @@ export function TaskDetailsPanel({ const [expandedSections, setExpandedSections] = useState>( new Set() ); + const [collapsedFiles, setCollapsedFiles] = useState>(new Set()); const [deletingFiles, setDeletingFiles] = useState>(new Set()); const [fileToDelete, setFileToDelete] = useState(null); + const [showDiffs, setShowDiffs] = useState(true); // Use the custom hook for all task details logic const { @@ -462,6 +471,35 @@ export function TaskDetailsPanel({ }); }; + const toggleFileCollapse = (filePath: string) => { + setCollapsedFiles((prev) => { + const newSet = new Set(prev); + if (newSet.has(filePath)) { + newSet.delete(filePath); + } else { + newSet.add(filePath); + } + return newSet; + }); + }; + + const collapseAllFiles = () => { + if (diff) { + setCollapsedFiles(new Set(diff.files.map((file) => file.path))); + } + }; + + const expandAllFiles = () => { + setCollapsedFiles(new Set()); + }; + + // Helper to check if all files with content are collapsed + const areAllFilesCollapsed = () => { + return ( + diff && diff.files.length > 0 && collapsedFiles.size === diff.files.length + ); + }; + const handleDeleteFileClick = (filePath: string) => { setFileToDelete(filePath); }; @@ -798,89 +836,163 @@ export function TaskDetailsPanel({ if (hasChanges) { return ( <> - {/* Top 2/3 - Code Changes */} -
- {diffLoading ? ( -
-
-

Loading changes...

-
- ) : diffError ? ( -
-

{diffError}

-
- ) : !diff || diff.files.length === 0 ? ( -
- -

No changes detected

-

- The worktree is identical to the base commit -

-
- ) : ( -
- {diff.files.map((file, fileIndex) => ( + {/* Top area - Code Changes (responsive height) */} + {showDiffs && ( +
+ {diffLoading ? ( +
+
+

+ Loading changes... +

+
+ ) : diffError ? ( +
+

{diffError}

+
+ ) : !diff || diff.files.length === 0 ? ( +
+ +

No changes detected

+

+ The worktree is identical to the base commit +

+
+ ) : ( +
-
-

- {file.path} -

+
+ {diff.files.length} file + {diff.files.length !== 1 ? 's' : ''} changed +
+
+ {diff.files.length > 1 && ( + <> + + + + )}
-
-
- {processFileChunks(file.chunks, fileIndex).map( - (section, sectionIndex) => { - if ( - section.type === 'context' && - section.lines.length === 0 && - section.expandKey - ) { - const lineCount = - parseInt(section.expandKey.split('-')[2]) - - parseInt(section.expandKey.split('-')[1]); - return ( -
- -
- ); +
+ {diff.files.map((file, fileIndex) => ( +
+
+
+ +

+ {file.path} +

+ {collapsedFiles.has(file.path) && ( +
+ + + + {file.chunks + .filter((c) => c.chunk_type === 'Insert') + .reduce( + (acc, c) => + acc + c.content.split('\n').length - 1, + 0 + )} + + + - + {file.chunks + .filter((c) => c.chunk_type === 'Delete') + .reduce( + (acc, c) => + acc + c.content.split('\n').length - 1, + 0 + )} + +
+ )} +
+ +
+ {!collapsedFiles.has(file.path) && ( +
+
+ {processFileChunks(file.chunks, fileIndex).map( + (section, sectionIndex) => { + if ( + section.type === 'context' && + section.lines.length === 0 && + section.expandKey + ) { + const lineCount = + parseInt(section.expandKey.split('-')[2]) - + parseInt(section.expandKey.split('-')[1]); + return ( +
- )} - {section.lines.map((line, lineIndex) => ( -
-
- - {line.oldLineNumber || ''} - - - {line.newLineNumber || ''} - -
-
- - {getChunkPrefix(line.chunkType)} - - {line.content} -
-
- ))} -
- ); - } - )} -
-
-
- ))} -
- )} -
+ ); + } - {/* Bottom 1/3 - Agent Logs */} -
+ return ( +
+ {section.type === 'expanded' && + section.expandKey && ( +
+ +
+ )} + {section.lines.map((line, lineIndex) => ( +
+
+ + {line.oldLineNumber || ''} + + + {line.newLineNumber || ''} + +
+
+ + {getChunkPrefix(line.chunkType)} + + {line.content} +
+
+ ))} +
+ ); + } + )} +
+
+ )} +
+ ))} +
+ )} +
+ )} + + {/* Show Diffs button when diffs are hidden */} + {!showDiffs && hasChanges && ( +
+
+ +
+
+ )} + + {/* Bottom area - Agent Logs (responsive height) */} +
+ ( + + {children} + + ), + strong: ({ children, ...props }) => ( + + {children} + + ), + em: ({ children, ...props }) => ( + + {children} + + ), + p: ({ children, ...props }) => ( +

+ {children} +

+ ), + h1: ({ children, ...props }) => ( +

+ {children} +

+ ), + h2: ({ children, ...props }) => ( +

+ {children} +

+ ), + h3: ({ children, ...props }) => ( +

+ {children} +

+ ), + ul: ({ children, ...props }) => ( +
    + {children} +
+ ), + ol: ({ children, ...props }) => ( +
    + {children} +
+ ), + li: ({ children, ...props }) => ( +
  • + {children} +
  • + ), + }} + > + {content} +
    +
    + ); +} diff --git a/package-lock.json b/package-lock.json index 9cde8baf..1a204a70 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "vibe-kanban", - "version": "0.0.37-ersion.3", + "version": "0.0.38", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "vibe-kanban", - "version": "0.0.37-ersion.3", + "version": "0.0.38", "devDependencies": { "concurrently": "^8.2.2", "vite": "^6.3.5"