From ad34b238da1535d467131401c11cf0587a2d6ba8 Mon Sep 17 00:00:00 2001 From: YIFAN LIU Date: Mon, 30 Jun 2025 16:55:50 -0700 Subject: [PATCH 1/9] batch fs read feat --- crates/chat-cli/src/cli/chat/mod.rs | 2 + crates/chat-cli/src/cli/chat/tools/fs_read.rs | 161 +++++++++++-- crates/chat-cli/src/cli/chat/tools/mod.rs | 65 ++++++ .../src/cli/chat/tools/tool_index.json | 219 +++++++++++------- 4 files changed, 349 insertions(+), 98 deletions(-) diff --git a/crates/chat-cli/src/cli/chat/mod.rs b/crates/chat-cli/src/cli/chat/mod.rs index e0f00224b0..2257b615cc 100644 --- a/crates/chat-cli/src/cli/chat/mod.rs +++ b/crates/chat-cli/src/cli/chat/mod.rs @@ -370,6 +370,8 @@ const TRUST_ALL_TEXT: &str = color_print::cstr! {"All tools are now trus const TOOL_BULLET: &str = " ● "; const CONTINUATION_LINE: &str = " ⋮ "; const PURPOSE_ARROW: &str = " ↳ "; +const SUCCESS_TICK: &str = " ✓ "; +const ERROR_EXCLAMATION: &str = " ❗ "; /// Enum used to denote the origin of a tool use event enum ToolUseStatus { diff --git a/crates/chat-cli/src/cli/chat/tools/fs_read.rs b/crates/chat-cli/src/cli/chat/tools/fs_read.rs index 00ad936b83..57058b51f6 100644 --- a/crates/chat-cli/src/cli/chat/tools/fs_read.rs +++ b/crates/chat-cli/src/cli/chat/tools/fs_read.rs @@ -30,6 +30,7 @@ use super::{ sanitize_path_tool_arg, }; use crate::cli::chat::CONTINUATION_LINE; +use crate::cli::chat::tools::display_purpose; use crate::cli::chat::util::images::{ handle_images_from_paths, is_supported_image_type, @@ -40,9 +41,16 @@ use crate::os::Os; const CHECKMARK: &str = "✔"; const CROSS: &str = "✘"; +#[derive(Debug, Clone, Deserialize)] +pub struct FsRead { + // For batch operations + pub operations: Option>, + pub summary: Option, +} + #[derive(Debug, Clone, Deserialize)] #[serde(tag = "mode")] -pub enum FsRead { +pub enum FsReadOperation { Line(FsLine), Directory(FsDirectory), Search(FsSearch), @@ -50,30 +58,157 @@ pub enum FsRead { } impl FsRead { + pub async fn validate(&mut self, os: &Os) -> Result<()> { + if let Some(operations) = &mut self.operations { + if operations.is_empty() { + bail!("At least one operation must be provided"); + } + + for op in operations { + op.validate(os).await?; + } + Ok(()) + } else { + bail!("'operations' field must be provided") + } + } + + pub async fn queue_description(&self, os: &Os, updates: &mut impl Write) -> Result<()> { + if let Some(operations) = &self.operations { + if operations.len() == 1 { + // Single operation - display without batch prefix + operations[0].queue_description(os, updates).await + } else { + // Multiple operations - display as batch + queue!( + updates, + style::Print("Batch fs_read operation with "), + style::SetForegroundColor(Color::Green), + style::Print(operations.len()), + style::ResetColor, + style::Print(" operations:\n") + )?; + + // Display purpose if available for batch operations + let _ = display_purpose(self.summary.as_ref(), updates); + + for (i, op) in operations.iter().enumerate() { + queue!(updates, style::Print(format!("\n↱ Operation {}: ", i + 1)))?; + op.queue_description(os, updates).await?; + } + Ok(()) + } + } else { + bail!("'operations' field must be provided") + } + } + + pub async fn invoke(&self, os: &Os, updates: &mut impl Write) -> Result { + if let Some(operations) = &self.operations { + if operations.len() == 1 { + // Single operation - return result directly + operations[0].invoke(os, updates).await + } else { + // Multiple operations - combine results + let mut combined_results = Vec::new(); + let mut all_images = Vec::new(); + + let mut success_ops = 0usize; + let mut failed_ops = 0usize; + + for (i, op) in operations.iter().enumerate() { + match op.invoke(os, updates).await { + Ok(result) => { + success_ops += 1; + + match &result.output { + OutputKind::Text(text) => { + combined_results.push(format!( + "=== Operation {} Result (Text) ===\n{}", + i + 1, + text + )); + }, + OutputKind::Json(json) => { + combined_results.push(format!( + "=== Operation {} Result (Json) ===\n{}", + i + 1, + serde_json::to_string_pretty(json)? + )); + }, + OutputKind::Images(images) => { + all_images.extend(images.clone()); + combined_results.push(format!( + "=== Operation {} Result (Images) ===\n[{} images processed]", + i + 1, + images.len() + )); + }, + } + }, + + Err(err) => { + failed_ops += 1; + combined_results.push(format!("=== Operation {} Error ===\n{}", i + 1, err)); + }, + } + } + + super::queue_function_result( + &format!( + "Summary: {} operations processed, {} successful, {} failed", + operations.len(), + success_ops, + failed_ops + ), + updates, + false, + true, + )?; + + // If we have images, return them as the primary output + if !all_images.is_empty() { + Ok(InvokeOutput { + output: OutputKind::Images(all_images), + }) + } else { + // Otherwise, return the combined text results + Ok(InvokeOutput { + output: OutputKind::Text(combined_results.join("\n\n")), + }) + } + } + } else { + bail!("'operations' field must be provided") + } + } +} + +impl FsReadOperation { pub async fn validate(&mut self, os: &Os) -> Result<()> { match self { - FsRead::Line(fs_line) => fs_line.validate(os).await, - FsRead::Directory(fs_directory) => fs_directory.validate(os).await, - FsRead::Search(fs_search) => fs_search.validate(os).await, - FsRead::Image(fs_image) => fs_image.validate(os).await, + FsReadOperation::Line(fs_line) => fs_line.validate(os).await, + FsReadOperation::Directory(fs_directory) => fs_directory.validate(os).await, + FsReadOperation::Search(fs_search) => fs_search.validate(os).await, + FsReadOperation::Image(fs_image) => fs_image.validate(os).await, } } pub async fn queue_description(&self, os: &Os, updates: &mut impl Write) -> Result<()> { match self { - FsRead::Line(fs_line) => fs_line.queue_description(os, updates).await, - FsRead::Directory(fs_directory) => fs_directory.queue_description(updates), - FsRead::Search(fs_search) => fs_search.queue_description(updates), - FsRead::Image(fs_image) => fs_image.queue_description(updates), + FsReadOperation::Line(fs_line) => fs_line.queue_description(os, updates).await, + FsReadOperation::Directory(fs_directory) => fs_directory.queue_description(updates), + FsReadOperation::Search(fs_search) => fs_search.queue_description(updates), + FsReadOperation::Image(fs_image) => fs_image.queue_description(updates), } } pub async fn invoke(&self, os: &Os, updates: &mut impl Write) -> Result { match self { - FsRead::Line(fs_line) => fs_line.invoke(os, updates).await, - FsRead::Directory(fs_directory) => fs_directory.invoke(os, updates).await, - FsRead::Search(fs_search) => fs_search.invoke(os, updates).await, - FsRead::Image(fs_image) => fs_image.invoke(updates).await, + FsReadOperation::Line(fs_line) => fs_line.invoke(os, updates).await, + FsReadOperation::Directory(fs_directory) => fs_directory.invoke(os, updates).await, + FsReadOperation::Search(fs_search) => fs_search.invoke(os, updates).await, + FsReadOperation::Image(fs_image) => fs_image.invoke(updates).await, } } } diff --git a/crates/chat-cli/src/cli/chat/tools/mod.rs b/crates/chat-cli/src/cli/chat/tools/mod.rs index f45bcaba47..8de4241f3c 100644 --- a/crates/chat-cli/src/cli/chat/tools/mod.rs +++ b/crates/chat-cli/src/cli/chat/tools/mod.rs @@ -438,6 +438,71 @@ pub fn display_purpose(purpose: Option<&String>, updates: &mut impl Write) -> Re Ok(()) } +/// Helper function to format function results with consistent styling +/// +/// # Parameters +/// * `result` - The result text to display +/// * `updates` - The output to write to +/// * `is_error` - Whether this is an error message (changes formatting) +/// * `use_bullet` - Whether to use a bullet point instead of a tick/exclamation +pub fn queue_function_result(result: &str, updates: &mut impl Write, is_error: bool, use_bullet: bool) -> Result<()> { + use crossterm::queue; + use crossterm::style::{ + self, + Color, + }; + + // Split the result into lines for proper formatting + let lines = result.lines().collect::>(); + let color = if is_error { Color::Red } else { Color::Reset }; + + queue!(updates, style::Print("\n"))?; + + // Use appropriate symbol based on parameters + if let Some(first_line) = lines.first() { + // Select symbol: bullet for summaries, tick/exclamation for operations + let symbol = if is_error { + super::ERROR_EXCLAMATION + } else if use_bullet { + super::TOOL_BULLET + } else { + super::SUCCESS_TICK + }; + + // Set color to green for success ticks + let text_color = if is_error { + Color::Red + } else if !use_bullet { + Color::Green + } else { + Color::Reset + }; + + queue!( + updates, + style::SetForegroundColor(text_color), + style::Print(symbol), + style::ResetColor, + style::Print(first_line), + style::Print("\n"), + )?; + } + + // For any additional lines, indent them properly + for line in lines.iter().skip(1) { + queue!( + updates, + style::Print(" "), // Same indentation as the bullet + style::SetForegroundColor(color), + style::Print(line), + style::ResetColor, + style::Print("\n"), + )?; + } + + Ok(()) +} + #[cfg(test)] mod tests { use std::path::MAIN_SEPARATOR; diff --git a/crates/chat-cli/src/cli/chat/tools/tool_index.json b/crates/chat-cli/src/cli/chat/tools/tool_index.json index 448e4c8929..eb45eea930 100644 --- a/crates/chat-cli/src/cli/chat/tools/tool_index.json +++ b/crates/chat-cli/src/cli/chat/tools/tool_index.json @@ -23,62 +23,83 @@ "description": "A brief explanation of what the command does" } }, - "required": ["command"] + "required": [ + "command" + ] } }, "fs_read": { "name": "fs_read", - "description": "Tool for reading files (for example, `cat -n`), directories (for example, `ls -la`) and images. If user has supplied paths that appear to be leading to images, you should use this tool right away using Image mode. The behavior of this tool is determined by the `mode` parameter. The available modes are:\n- line: Show lines in a file, given by an optional `start_line` and optional `end_line`.\n- directory: List directory contents. Content is returned in the \"long format\" of ls (that is, `ls -la`).\n- search: Search for a pattern in a file. The pattern is a string. The matching is case insensitive.\n\nExample Usage:\n1. Read all lines from a file: command=\"line\", path=\"/path/to/file.txt\"\n2. Read the last 5 lines from a file: command=\"line\", path=\"/path/to/file.txt\", start_line=-5\n3. List the files in the home directory: command=\"line\", path=\"~\"\n4. Recursively list files in a directory to a max depth of 2: command=\"line\", path=\"/path/to/directory\", depth=2\n5. Search for all instances of \"test\" in a file: command=\"search\", path=\"/path/to/file.txt\", pattern=\"test\"\n", + "description": "Tool for reading files, directories and images. Always provide an 'operations' array.\n\nFor single operation: provide array with one element.\nFor batch operations: provide array with multiple elements.\n\nAvailable modes:\n- Line: Read lines from a file\n- Directory: List directory contents\n- Search: Search for patterns in files\n- Image: Read and process images\n\nExamples:\n1. Single: {\"operations\": [{\"mode\": \"Line\", \"path\": \"/file.txt\"}]}\n2. Batch: {\"operations\": [{\"mode\": \"Line\", \"path\": \"/file1.txt\"}, {\"mode\": \"Search\", \"path\": \"/file2.txt\", \"pattern\": \"test\"}]}", "input_schema": { "type": "object", "properties": { - "path": { - "description": "Path to the file or directory. The path should be absolute, or otherwise start with ~ for the user's home.", - "type": "string" - }, - "image_paths": { - "description": "List of paths to the images. This is currently supported by the Image mode.", + "operations": { "type": "array", + "description": "Array of operations to execute. Provide one element for single operation, multiple for batch.", "items": { - "type": "string" - } - }, - "mode": { - "type": "string", - "enum": [ - "Line", - "Directory", - "Search", - "Image" - ], - "description": "The mode to run in: `Line`, `Directory`, `Search`. `Line` and `Search` are only for text files, and `Directory` is only for directories. `Image` is for image files, in this mode `image_paths` is required." - }, - "start_line": { - "type": "integer", - "description": "Starting line number (optional, for Line mode). A negative index represents a line number starting from the end of the file.", - "default": 1 - }, - "end_line": { - "type": "integer", - "description": "Ending line number (optional, for Line mode). A negative index represents a line number starting from the end of the file.", - "default": -1 + "type": "object", + "properties": { + "mode": { + "type": "string", + "enum": [ + "Line", + "Directory", + "Search", + "Image" + ], + "description": "The operation mode" + }, + "path": { + "type": "string", + "description": "Path to file or directory (required for Line, Directory, Search modes)" + }, + "image_paths": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Paths to images (required for Image mode)" + }, + "start_line": { + "type": "integer", + "description": "Starting line number (optional for Line mode)", + "default": 1 + }, + "end_line": { + "type": "integer", + "description": "Ending line number (optional for Line mode)", + "default": -1 + }, + "pattern": { + "type": "string", + "description": "Search pattern (required for Search mode)" + }, + "context_lines": { + "type": "integer", + "description": "Context lines around matches (optional for Search mode)", + "default": 2 + }, + "depth": { + "type": "integer", + "description": "Directory traversal depth (optional for Directory mode)", + "default": 0 + } + }, + "required": [ + "mode" + ] + }, + "minItems": 1 }, - "pattern": { + "summary": { "type": "string", - "description": "Pattern to search for (required, for Search mode). Case insensitive. The pattern matching is performed per line." - }, - "context_lines": { - "type": "integer", - "description": "Number of context lines around search results (optional, for Search mode)", - "default": 2 - }, - "depth": { - "type": "integer", - "description": "Depth of a recursive directory listing (optional, for Directory mode)", - "default": 0 + "description": "Optional description of the purpose of this batch operation (mainly useful for multiple operations)" } }, - "required": ["path", "mode"] + "required": [ + "operations" + ] } }, "fs_write": { @@ -89,7 +110,12 @@ "properties": { "command": { "type": "string", - "enum": ["create", "str_replace", "insert", "append"], + "enum": [ + "create", + "str_replace", + "insert", + "append" + ], "description": "The commands to run. Allowed options are: `create`, `str_replace`, `insert`, `append`." }, "file_text": { @@ -117,7 +143,10 @@ "type": "string" } }, - "required": ["command", "path"] + "required": [ + "command", + "path" + ] } }, "use_aws": { @@ -151,7 +180,12 @@ "description": "Human readable description of the api that is being called." } }, - "required": ["region", "service_name", "operation_name", "label"] + "required": [ + "region", + "service_name", + "operation_name", + "label" + ] } }, "gh_issue": { @@ -177,7 +211,9 @@ "description": "Optional: Previous user chat requests or steps that were taken that may have resulted in the issue or error response." } }, - "required": ["title"] + "required": [ + "title" + ] } }, "thinking": { @@ -191,46 +227,59 @@ "description": "A reflective note or intermediate reasoning step such as \"The user needs to prepare their application for production. I need to complete three major asks including 1: building their code from source, 2: bundling their release artifacts together, and 3: signing the application bundle." } }, - "required": ["thought"] + "required": [ + "thought" + ] } }, "knowledge": { - "name": "knowledge", - "description": "Store and retrieve information in knowledge base across chat sessions. Provides semantic search capabilities for files, directories, and text content.", - "input_schema": { - "type": "object", - "properties": { - "command": { - "type": "string", - "enum": ["show", "add", "remove", "clear", "search", "update", "status", "cancel"], - "description": "The knowledge operation to perform:\n- 'show': List all knowledge contexts (no additional parameters required)\n- 'add': Add content to knowledge base (requires 'name' and 'value')\n- 'remove': Remove content from knowledge base (requires one of: 'name', 'context_id', or 'path')\n- 'clear': Remove all knowledge contexts.\n- 'search': Search across knowledge contexts (requires 'query', optional 'context_id')\n- 'update': Update existing context with new content (requires 'path' and one of: 'name', 'context_id')\n- 'status': Show background operation status and progress\n- 'cancel': Cancel background operations (optional 'operation_id' to cancel specific operation, or cancel all if not provided)" - }, - "name": { - "type": "string", - "description": "A descriptive name for the knowledge context. Required for 'add' operations. Can be used for 'remove' and 'update' operations to identify the context." - }, - "value": { - "type": "string", - "description": "The content to store in knowledge base. Required for 'add' operations. Can be either text content or a file/directory path. If it's a valid file or directory path, the content will be indexed; otherwise it's treated as text." - }, - "context_id": { - "type": "string", - "description": "The unique context identifier for targeted operations. Can be obtained from 'show' command. Used for 'remove', 'update', and 'search' operations to specify which context to operate on." - }, - "path": { - "type": "string", - "description": "File or directory path. Used in 'remove' operations to remove contexts by their source path, and required for 'update' operations to specify the new content location." - }, - "query": { - "type": "string", - "description": "The search query string. Required for 'search' operations. Performs semantic search across knowledge contexts to find relevant content." - }, - "operation_id": { - "type": "string", - "description": "Optional operation ID to cancel a specific operation. Used with 'cancel' command. If not provided, all active operations will be cancelled. Can be either the full operation ID or the short 8-character ID." - } + "name": "knowledge", + "description": "Store and retrieve information in knowledge base across chat sessions. Provides semantic search capabilities for files, directories, and text content.", + "input_schema": { + "type": "object", + "properties": { + "command": { + "type": "string", + "enum": [ + "show", + "add", + "remove", + "clear", + "search", + "update", + "status", + "cancel" + ], + "description": "The knowledge operation to perform:\n- 'show': List all knowledge contexts (no additional parameters required)\n- 'add': Add content to knowledge base (requires 'name' and 'value')\n- 'remove': Remove content from knowledge base (requires one of: 'name', 'context_id', or 'path')\n- 'clear': Remove all knowledge contexts.\n- 'search': Search across knowledge contexts (requires 'query', optional 'context_id')\n- 'update': Update existing context with new content (requires 'path' and one of: 'name', 'context_id')\n- 'status': Show background operation status and progress\n- 'cancel': Cancel background operations (optional 'operation_id' to cancel specific operation, or cancel all if not provided)" }, - "required": ["command"] - } + "name": { + "type": "string", + "description": "A descriptive name for the knowledge context. Required for 'add' operations. Can be used for 'remove' and 'update' operations to identify the context." + }, + "value": { + "type": "string", + "description": "The content to store in knowledge base. Required for 'add' operations. Can be either text content or a file/directory path. If it's a valid file or directory path, the content will be indexed; otherwise it's treated as text." + }, + "context_id": { + "type": "string", + "description": "The unique context identifier for targeted operations. Can be obtained from 'show' command. Used for 'remove', 'update', and 'search' operations to specify which context to operate on." + }, + "path": { + "type": "string", + "description": "File or directory path. Used in 'remove' operations to remove contexts by their source path, and required for 'update' operations to specify the new content location." + }, + "query": { + "type": "string", + "description": "The search query string. Required for 'search' operations. Performs semantic search across knowledge contexts to find relevant content." + }, + "operation_id": { + "type": "string", + "description": "Optional operation ID to cancel a specific operation. Used with 'cancel' command. If not provided, all active operations will be cancelled. Can be either the full operation ID or the short 8-character ID." + } + }, + "required": [ + "command" + ] + } } -} +} \ No newline at end of file From bfa5016d8ac795de9c84c8362fd0d313722b8d60 Mon Sep 17 00:00:00 2001 From: YIFAN LIU Date: Mon, 30 Jun 2025 18:03:30 -0700 Subject: [PATCH 2/9] print exectuetion res in every invoke --- crates/chat-cli/src/cli/chat/tools/fs_read.rs | 76 +++++++++++-------- 1 file changed, 43 insertions(+), 33 deletions(-) diff --git a/crates/chat-cli/src/cli/chat/tools/fs_read.rs b/crates/chat-cli/src/cli/chat/tools/fs_read.rs index 57058b51f6..240983ceee 100644 --- a/crates/chat-cli/src/cli/chat/tools/fs_read.rs +++ b/crates/chat-cli/src/cli/chat/tools/fs_read.rs @@ -154,6 +154,12 @@ impl FsRead { } } + queue!( + updates, + style::Print("\n"), + style::Print(CONTINUATION_LINE), + style::Print("\n") + )?; super::queue_function_result( &format!( "Summary: {} operations processed, {} successful, {} failed", @@ -242,6 +248,7 @@ impl FsImage { pub async fn invoke(&self, updates: &mut impl Write) -> Result { let pre_processed_paths: Vec = self.image_paths.iter().map(|path| pre_process(path)).collect(); let valid_images = handle_images_from_paths(updates, &pre_processed_paths); + super::queue_function_result(&format!("Successfully read image"), updates, false, false)?; Ok(InvokeOutput { output: OutputKind::Images(valid_images), }) @@ -250,9 +257,10 @@ impl FsImage { pub fn queue_description(&self, updates: &mut impl Write) -> Result<()> { queue!( updates, - style::Print("Reading images: \n"), + style::Print("Reading images: "), style::SetForegroundColor(Color::Green), style::Print(&self.image_paths.join("\n")), + style::Print("\n"), style::ResetColor, )?; Ok(()) @@ -323,7 +331,7 @@ impl FsLine { } } - pub async fn invoke(&self, os: &Os, _updates: &mut impl Write) -> Result { + pub async fn invoke(&self, os: &Os, updates: &mut impl Write) -> Result { let path = sanitize_path_tool_arg(os, &self.path); debug!(?path, "Reading"); let file_bytes = os.fs.read(&path).await?; @@ -362,6 +370,17 @@ time. You tried to read {byte_count} bytes. Try executing with fewer lines speci ); } + super::queue_function_result( + &format!( + "Successfully read {} bytes from {}", + file_contents.len(), + &path.display() + ), + updates, + false, + false, + )?; + Ok(InvokeOutput { output: OutputKind::Text(file_contents), }) @@ -415,7 +434,6 @@ impl FsSearch { style::SetForegroundColor(Color::Green), style::Print(&self.pattern.to_lowercase()), style::ResetColor, - style::Print("\n"), )?; Ok(()) } @@ -455,36 +473,17 @@ impl FsSearch { }); } } - let match_text = if total_matches == 1 { - "1 match".to_string() - } else { - format!("{} matches", total_matches) - }; - - let color = if total_matches == 0 { - Color::Yellow - } else { - Color::Green - }; - let result = if total_matches == 0 { - CROSS.yellow() - } else { - CHECKMARK.green() - }; - - queue!( + super::queue_function_result( + &format!( + "Found {} matches for pattern '{}' in {}", + total_matches, + pattern, + &file_path.display() + ), updates, - style::SetForegroundColor(Color::Yellow), - style::ResetColor, - style::Print(CONTINUATION_LINE), - style::Print("\n"), - style::Print(" "), - style::Print(result), - style::Print(" Found: "), - style::SetForegroundColor(color), - style::Print(match_text), - style::ResetColor, + false, + false, )?; Ok(InvokeOutput { @@ -535,13 +534,13 @@ impl FsDirectory { )?) } - pub async fn invoke(&self, os: &Os, _updates: &mut impl Write) -> Result { + pub async fn invoke(&self, os: &Os, updates: &mut impl Write) -> Result { let path = sanitize_path_tool_arg(os, &self.path); let max_depth = self.depth(); debug!(?path, max_depth, "Reading directory at path with depth"); let mut result = Vec::new(); let mut dir_queue = VecDeque::new(); - dir_queue.push_back((path, 0)); + dir_queue.push_back((path.clone(), 0)); while let Some((path, depth)) = dir_queue.pop_front() { if depth > max_depth { break; @@ -619,6 +618,17 @@ impl FsDirectory { ); } + super::queue_function_result( + &format!( + "Successfully read directory {} ({} entries)", + &path.display(), + file_count + ), + updates, + false, + false, + )?; + Ok(InvokeOutput { output: OutputKind::Text(result), }) From 22e5738f5511f6ecdb078e66f8770f496d88bd52 Mon Sep 17 00:00:00 2001 From: YIFAN LIU Date: Tue, 1 Jul 2025 15:40:25 -0700 Subject: [PATCH 3/9] add a mix output type --- crates/chat-cli/src/cli/chat/message.rs | 1 + crates/chat-cli/src/cli/chat/mod.rs | 4 +++ crates/chat-cli/src/cli/chat/tools/fs_read.rs | 29 ++++++++++++------- crates/chat-cli/src/cli/chat/tools/mod.rs | 2 ++ crates/q_cli/src/cli/mod.rs | 9 ++++-- 5 files changed, 32 insertions(+), 13 deletions(-) diff --git a/crates/chat-cli/src/cli/chat/message.rs b/crates/chat-cli/src/cli/chat/message.rs index be92bef619..2ddd1b1e3e 100644 --- a/crates/chat-cli/src/cli/chat/message.rs +++ b/crates/chat-cli/src/cli/chat/message.rs @@ -255,6 +255,7 @@ impl From for ToolUseResultBlock { OutputKind::Text(text) => Self::Text(text), OutputKind::Json(value) => Self::Json(value), OutputKind::Images(_) => Self::Text("See images data supplied".to_string()), + OutputKind::Mixed { text, .. } => ToolUseResultBlock::Text(text), } } } diff --git a/crates/chat-cli/src/cli/chat/mod.rs b/crates/chat-cli/src/cli/chat/mod.rs index 2257b615cc..35db6e0257 100644 --- a/crates/chat-cli/src/cli/chat/mod.rs +++ b/crates/chat-cli/src/cli/chat/mod.rs @@ -1523,6 +1523,10 @@ impl ChatSession { OutputKind::Images(ref image) => { image_blocks.extend(image.clone()); }, + OutputKind::Mixed { ref text, ref images } => { + debug!("Output is Mixed: text = {:?}, images = {}", text, images.len()); + image_blocks.extend(images.clone()); + }, } debug!("tool result output: {:#?}", result); diff --git a/crates/chat-cli/src/cli/chat/tools/fs_read.rs b/crates/chat-cli/src/cli/chat/tools/fs_read.rs index 240983ceee..5d01bc94a4 100644 --- a/crates/chat-cli/src/cli/chat/tools/fs_read.rs +++ b/crates/chat-cli/src/cli/chat/tools/fs_read.rs @@ -6,7 +6,6 @@ use crossterm::queue; use crossterm::style::{ self, Color, - Stylize, }; use eyre::{ Result, @@ -38,9 +37,6 @@ use crate::cli::chat::util::images::{ }; use crate::os::Os; -const CHECKMARK: &str = "✔"; -const CROSS: &str = "✘"; - #[derive(Debug, Clone, Deserialize)] pub struct FsRead { // For batch operations @@ -112,7 +108,7 @@ impl FsRead { // Multiple operations - combine results let mut combined_results = Vec::new(); let mut all_images = Vec::new(); - + let mut has_non_image_ops = false; let mut success_ops = 0usize; let mut failed_ops = 0usize; @@ -128,6 +124,7 @@ impl FsRead { i + 1, text )); + has_non_image_ops = true; }, OutputKind::Json(json) => { combined_results.push(format!( @@ -135,6 +132,7 @@ impl FsRead { i + 1, serde_json::to_string_pretty(json)? )); + has_non_image_ops = true; }, OutputKind::Images(images) => { all_images.extend(images.clone()); @@ -144,6 +142,9 @@ impl FsRead { images.len() )); }, + // This branch won't be reached because single operation execution never returns a Mixed + // result + OutputKind::Mixed { text: _, images: _ } => {}, } }, @@ -172,15 +173,23 @@ impl FsRead { true, )?; - // If we have images, return them as the primary output - if !all_images.is_empty() { + let combined_text = combined_results.join("\n\n"); + + if !all_images.is_empty() && has_non_image_ops { + queue!(updates, style::Print("\nherherherherherh"),)?; + Ok(InvokeOutput { + output: OutputKind::Mixed { + text: combined_text, + images: all_images, + }, + }) + } else if !all_images.is_empty() { Ok(InvokeOutput { output: OutputKind::Images(all_images), }) } else { - // Otherwise, return the combined text results Ok(InvokeOutput { - output: OutputKind::Text(combined_results.join("\n\n")), + output: OutputKind::Text(combined_text), }) } } @@ -248,7 +257,7 @@ impl FsImage { pub async fn invoke(&self, updates: &mut impl Write) -> Result { let pre_processed_paths: Vec = self.image_paths.iter().map(|path| pre_process(path)).collect(); let valid_images = handle_images_from_paths(updates, &pre_processed_paths); - super::queue_function_result(&format!("Successfully read image"), updates, false, false)?; + super::queue_function_result("Successfully read image", updates, false, false)?; Ok(InvokeOutput { output: OutputKind::Images(valid_images), }) diff --git a/crates/chat-cli/src/cli/chat/tools/mod.rs b/crates/chat-cli/src/cli/chat/tools/mod.rs index 8de4241f3c..4983c8959a 100644 --- a/crates/chat-cli/src/cli/chat/tools/mod.rs +++ b/crates/chat-cli/src/cli/chat/tools/mod.rs @@ -327,6 +327,7 @@ impl InvokeOutput { OutputKind::Text(s) => s.as_str(), OutputKind::Json(j) => j.as_str().unwrap_or_default(), OutputKind::Images(_) => "", + OutputKind::Mixed { text, .. } => text.as_str(), // Return the text part } } } @@ -337,6 +338,7 @@ pub enum OutputKind { Text(String), Json(serde_json::Value), Images(RichImageBlocks), + Mixed { text: String, images: RichImageBlocks }, } impl Default for OutputKind { diff --git a/crates/q_cli/src/cli/mod.rs b/crates/q_cli/src/cli/mod.rs index 4b802f36c4..6f000cce66 100644 --- a/crates/q_cli/src/cli/mod.rs +++ b/crates/q_cli/src/cli/mod.rs @@ -608,10 +608,13 @@ fn qchat_path() -> Result { #[cfg(target_os = "macos")] fn qchat_path() -> Result { - use fig_util::consts::CHAT_BINARY_NAME; - use macos_utils::bundle::get_bundle_path_for_executable; + Ok(PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../target/debug/chat_cli")) + + // use fig_util::consts::CHAT_BINARY_NAME; + // use macos_utils::bundle::get_bundle_path_for_executable; - Ok(get_bundle_path_for_executable(CHAT_BINARY_NAME).unwrap_or(home_local_bin()?.join(CHAT_BINARY_NAME))) + // Ok(get_bundle_path_for_executable(CHAT_BINARY_NAME).unwrap_or(home_local_bin()?. + // join(CHAT_BINARY_NAME))) } #[cfg(test)] From 8e861458c1a16c1e73bea8a0235b32cb7c42c7a4 Mon Sep 17 00:00:00 2001 From: YIFAN LIU Date: Tue, 1 Jul 2025 15:44:25 -0700 Subject: [PATCH 4/9] recover remote bin --- crates/q_cli/src/cli/mod.rs | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/crates/q_cli/src/cli/mod.rs b/crates/q_cli/src/cli/mod.rs index 6f000cce66..4b802f36c4 100644 --- a/crates/q_cli/src/cli/mod.rs +++ b/crates/q_cli/src/cli/mod.rs @@ -608,13 +608,10 @@ fn qchat_path() -> Result { #[cfg(target_os = "macos")] fn qchat_path() -> Result { - Ok(PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../target/debug/chat_cli")) - - // use fig_util::consts::CHAT_BINARY_NAME; - // use macos_utils::bundle::get_bundle_path_for_executable; + use fig_util::consts::CHAT_BINARY_NAME; + use macos_utils::bundle::get_bundle_path_for_executable; - // Ok(get_bundle_path_for_executable(CHAT_BINARY_NAME).unwrap_or(home_local_bin()?. - // join(CHAT_BINARY_NAME))) + Ok(get_bundle_path_for_executable(CHAT_BINARY_NAME).unwrap_or(home_local_bin()?.join(CHAT_BINARY_NAME))) } #[cfg(test)] From 35a1290bcefd9136c4cfaac677dfdd30407c6386 Mon Sep 17 00:00:00 2001 From: YIFAN LIU Date: Tue, 1 Jul 2025 15:50:53 -0700 Subject: [PATCH 5/9] clear print res function --- crates/chat-cli/src/cli/chat/tools/mod.rs | 43 ++++++----------------- 1 file changed, 11 insertions(+), 32 deletions(-) diff --git a/crates/chat-cli/src/cli/chat/tools/mod.rs b/crates/chat-cli/src/cli/chat/tools/mod.rs index 4983c8959a..7ec3602dea 100644 --- a/crates/chat-cli/src/cli/chat/tools/mod.rs +++ b/crates/chat-cli/src/cli/chat/tools/mod.rs @@ -448,41 +448,22 @@ pub fn display_purpose(purpose: Option<&String>, updates: &mut impl Write) -> Re /// * `is_error` - Whether this is an error message (changes formatting) /// * `use_bullet` - Whether to use a bullet point instead of a tick/exclamation pub fn queue_function_result(result: &str, updates: &mut impl Write, is_error: bool, use_bullet: bool) -> Result<()> { - use crossterm::queue; - use crossterm::style::{ - self, - Color, - }; - - // Split the result into lines for proper formatting let lines = result.lines().collect::>(); - let color = if is_error { Color::Red } else { Color::Reset }; + + // Determine symbol and color + let (symbol, color) = match (is_error, use_bullet) { + (true, _) => (super::ERROR_EXCLAMATION, Color::Red), + (false, true) => (super::TOOL_BULLET, Color::Reset), + (false, false) => (super::SUCCESS_TICK, Color::Green), + }; queue!(updates, style::Print("\n"))?; - // Use appropriate symbol based on parameters + // Print first line with symbol if let Some(first_line) = lines.first() { - // Select symbol: bullet for summaries, tick/exclamation for operations - let symbol = if is_error { - super::ERROR_EXCLAMATION - } else if use_bullet { - super::TOOL_BULLET - } else { - super::SUCCESS_TICK - }; - - // Set color to green for success ticks - let text_color = if is_error { - Color::Red - } else if !use_bullet { - Color::Green - } else { - Color::Reset - }; - queue!( updates, - style::SetForegroundColor(text_color), + style::SetForegroundColor(color), style::Print(symbol), style::ResetColor, style::Print(first_line), @@ -490,14 +471,12 @@ pub fn queue_function_result(result: &str, updates: &mut impl Write, is_error: b )?; } - // For any additional lines, indent them properly + // Print remaining lines with indentation for line in lines.iter().skip(1) { queue!( updates, - style::Print(" "), // Same indentation as the bullet - style::SetForegroundColor(color), + style::Print(" "), // 3 spaces for alignment style::Print(line), - style::ResetColor, style::Print("\n"), )?; } From 6efe29aa37015e47f07890db1d2ad572a014ecf3 Mon Sep 17 00:00:00 2001 From: YIFAN LIU Date: Tue, 1 Jul 2025 16:36:37 -0700 Subject: [PATCH 6/9] fix ut --- crates/chat-cli/src/cli/chat/tools/fs_read.rs | 136 +++++++++++++++--- 1 file changed, 115 insertions(+), 21 deletions(-) diff --git a/crates/chat-cli/src/cli/chat/tools/fs_read.rs b/crates/chat-cli/src/cli/chat/tools/fs_read.rs index 5d01bc94a4..682f174238 100644 --- a/crates/chat-cli/src/cli/chat/tools/fs_read.rs +++ b/crates/chat-cli/src/cli/chat/tools/fs_read.rs @@ -717,28 +717,49 @@ mod tests { #[test] fn test_fs_read_deser() { - serde_json::from_value::(serde_json::json!({ "path": "/test_file.txt", "mode": "Line" })).unwrap(); + // Test single operations (wrapped in operations array) serde_json::from_value::( - serde_json::json!({ "path": "/test_file.txt", "mode": "Line", "end_line": 5 }), + serde_json::json!({ "operations": [{ "path": "/test_file.txt", "mode": "Line" }] }), ) .unwrap(); serde_json::from_value::( - serde_json::json!({ "path": "/test_file.txt", "mode": "Line", "start_line": -1 }), + serde_json::json!({ "operations": [{ "path": "/test_file.txt", "mode": "Line", "end_line": 5 }] }), ) .unwrap(); serde_json::from_value::( - serde_json::json!({ "path": "/test_file.txt", "mode": "Line", "start_line": None:: }), + serde_json::json!({ "operations": [{ "path": "/test_file.txt", "mode": "Line", "start_line": -1 }] }), ) .unwrap(); - serde_json::from_value::(serde_json::json!({ "path": "/", "mode": "Directory" })).unwrap(); serde_json::from_value::( - serde_json::json!({ "path": "/test_file.txt", "mode": "Directory", "depth": 2 }), + serde_json::json!({ "operations": [{ "path": "/test_file.txt", "mode": "Line", "start_line": None:: }] }), ) .unwrap(); + serde_json::from_value::(serde_json::json!({ "operations": [{ "path": "/", "mode": "Directory" }] })) + .unwrap(); serde_json::from_value::( - serde_json::json!({ "path": "/test_file.txt", "mode": "Search", "pattern": "hello" }), + serde_json::json!({ "operations": [{ "path": "/test_file.txt", "mode": "Directory", "depth": 2 }] }), ) .unwrap(); + serde_json::from_value::( + serde_json::json!({ "operations": [{ "path": "/test_file.txt", "mode": "Search", "pattern": "hello" }] }), + ) + .unwrap(); + serde_json::from_value::(serde_json::json!({ + "operations": [{ "image_paths": ["/img1.png", "/img2.jpg"], "mode": "Image" }] + })) + .unwrap(); + + // Test mixed batch operations + serde_json::from_value::(serde_json::json!({ + "operations": [ + { "path": "/file.txt", "mode": "Line" }, + { "path": "/dir", "mode": "Directory", "depth": 1 }, + { "path": "/log.txt", "mode": "Search", "pattern": "warning" }, + { "image_paths": ["/photo.jpg"], "mode": "Image" } + ], + "purpose": "Comprehensive file analysis" + })) + .unwrap(); } #[tokio::test] @@ -750,10 +771,11 @@ mod tests { macro_rules! assert_lines { ($start_line:expr, $end_line:expr, $expected:expr) => { let v = serde_json::json!({ + "operations": [{ "path": TEST_FILE_PATH, "mode": "Line", "start_line": $start_line, - "end_line": $end_line, + "end_line": $end_line,}] }); let output = serde_json::from_value::(v) .unwrap() @@ -783,11 +805,11 @@ mod tests { let os = setup_test_directory().await; let mut stdout = std::io::stdout(); let v = serde_json::json!({ + "operations": [{ "path": TEST_FILE_PATH, "mode": "Line", "start_line": 100, - "end_line": None::, - }); + "end_line": None::,}]}); assert!( serde_json::from_value::(v) .unwrap() @@ -818,9 +840,10 @@ mod tests { // Testing without depth let v = serde_json::json!({ + "operations": [{ "mode": "Directory", "path": "/", - }); + }]}); let output = serde_json::from_value::(v) .unwrap() .invoke(&os, &mut stdout) @@ -835,9 +858,10 @@ mod tests { // Testing with depth level 1 let v = serde_json::json!({ + "operations": [{ "mode": "Directory", "path": "/", - "depth": 1, + "depth": 1,}] }); let output = serde_json::from_value::(v) .unwrap() @@ -880,9 +904,10 @@ mod tests { } let matches = invoke_search!({ + "operations": [{ "mode": "Search", "path": TEST_FILE_PATH, - "pattern": "hello", + "pattern": "hello",}] }); assert_eq!(matches.len(), 2); assert_eq!(matches[0].line_number, 1); @@ -907,8 +932,9 @@ mod tests { os.fs.write(binary_file_path, &binary_data).await.unwrap(); let v = serde_json::json!({ + "operations": [{ "path": binary_file_path, - "mode": "Line" + "mode": "Line"}] }); let output = serde_json::from_value::(v) .unwrap() @@ -938,8 +964,9 @@ mod tests { os.fs.write(latin1_file_path, &latin1_data).await.unwrap(); let v = serde_json::json!({ + "operations": [{ "path": latin1_file_path, - "mode": "Line" + "mode": "Line"}] }); let output = serde_json::from_value::(v) .unwrap() @@ -973,9 +1000,10 @@ mod tests { os.fs.write(mixed_file_path, &mixed_data).await.unwrap(); let v = serde_json::json!({ + "operations": [{ "mode": "Search", "path": mixed_file_path, - "pattern": "hello" + "pattern": "hello"}] }); let output = serde_json::from_value::(v) .unwrap() @@ -996,9 +1024,10 @@ mod tests { } let v = serde_json::json!({ + "operations": [{ "mode": "Search", "path": mixed_file_path, - "pattern": "goodbye" + "pattern": "goodbye"}] }); let output = serde_json::from_value::(v) .unwrap() @@ -1033,8 +1062,9 @@ mod tests { os.fs.write(windows1252_file_path, &windows1252_data).await.unwrap(); let v = serde_json::json!({ + "operations": [{ "path": windows1252_file_path, - "mode": "Line" + "mode": "Line"}] }); let output = serde_json::from_value::(v) .unwrap() @@ -1071,9 +1101,10 @@ mod tests { .unwrap(); let v = serde_json::json!({ + "operations": [{ "mode": "Search", "path": invalid_utf8_file_path, - "pattern": "caf" + "pattern": "caf"}] }); let output = serde_json::from_value::(v) .unwrap() @@ -1101,8 +1132,9 @@ mod tests { os.fs.write(invalid_only_file_path, &invalid_only_data).await.unwrap(); let v = serde_json::json!({ + "operations": [{ "path": invalid_only_file_path, - "mode": "Line" + "mode": "Line"}] }); let output = serde_json::from_value::(v) .unwrap() @@ -1118,9 +1150,10 @@ mod tests { } let v = serde_json::json!({ + "operations": [{ "mode": "Search", "path": invalid_only_file_path, - "pattern": "test" + "pattern": "test"}] }); let output = serde_json::from_value::(v) .unwrap() @@ -1139,4 +1172,65 @@ mod tests { panic!("expected Text output"); } } + + #[tokio::test] + async fn test_fs_read_batch_mixed_operations() { + let os = setup_test_directory().await; + let mut stdout = Vec::new(); + + let v = serde_json::json!({ + "operations": [ + { "path": TEST_FILE_PATH, "mode": "Line", "start_line": 1, "end_line": 2 }, + { "path": "/", "mode": "Directory" }, + { "path": TEST_FILE_PATH, "mode": "Search", "pattern": "hello" } + ], + "purpose": "Test mixed text operations" + }); + + let output = serde_json::from_value::(v) + .unwrap() + .invoke(&os, &mut stdout) + .await + .unwrap(); + print!("output {:?}", output); + // All text operations should return combined text + if let OutputKind::Text(text) = output.output { + // Check all operations are included + assert!(text.contains("=== Operation 1 Result (Text) ===")); + assert!(text.contains("=== Operation 2 Result (Text) ===")); + assert!(text.contains("=== Operation 3 Result (Text) ===")); + + // Check operation 1 (Line mode) + assert!(text.contains("Hello world!")); + assert!(text.contains("This is line 2")); + + // Check operation 2 (Directory mode) + assert!(text.contains("test_file.txt")); + + // Check operation 3 (Search mode) + assert!(text.contains("\"line_number\":1")); + } else { + panic!("expected text output for batch operations"); + } + } + #[tokio::test] + async fn test_fs_read_empty_operations() { + let os = Os::new().await.unwrap(); + + // Test empty operations array + let v = serde_json::json!({ + "operations": [] + }); + + let mut fs_read = serde_json::from_value::(v).unwrap(); + let result = fs_read.validate(&os).await; + + assert!(result.is_err()); + assert!( + result + .unwrap_err() + .to_string() + .contains("At least one operation must be provided") + ); + } } From 01d55f0511920e41f64bb18b8793c19f8f0f6116 Mon Sep 17 00:00:00 2001 From: YIFAN LIU Date: Tue, 1 Jul 2025 16:37:01 -0700 Subject: [PATCH 7/9] remove print --- crates/chat-cli/src/cli/chat/tools/fs_read.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/crates/chat-cli/src/cli/chat/tools/fs_read.rs b/crates/chat-cli/src/cli/chat/tools/fs_read.rs index 682f174238..1b765c6abb 100644 --- a/crates/chat-cli/src/cli/chat/tools/fs_read.rs +++ b/crates/chat-cli/src/cli/chat/tools/fs_read.rs @@ -1192,7 +1192,6 @@ mod tests { .invoke(&os, &mut stdout) .await .unwrap(); - print!("output {:?}", output); // All text operations should return combined text if let OutputKind::Text(text) = output.output { // Check all operations are included From fd53a1cdd125a9f6f9f8ee3f44b260da99ea5609 Mon Sep 17 00:00:00 2001 From: YIFAN LIU Date: Tue, 1 Jul 2025 16:49:21 -0700 Subject: [PATCH 8/9] polish tool spec --- .../chat-cli/src/cli/chat/tools/tool_index.json | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/crates/chat-cli/src/cli/chat/tools/tool_index.json b/crates/chat-cli/src/cli/chat/tools/tool_index.json index eb45eea930..067e5df1ec 100644 --- a/crates/chat-cli/src/cli/chat/tools/tool_index.json +++ b/crates/chat-cli/src/cli/chat/tools/tool_index.json @@ -48,41 +48,41 @@ "Search", "Image" ], - "description": "The operation mode" + "description": "The operation mode to run in: `Line`, `Directory`, `Search`. `Line` and `Search` are only for text files, and `Directory` is only for directories. `Image` is for image files, in this mode `image_paths` is required." }, "path": { "type": "string", - "description": "Path to file or directory (required for Line, Directory, Search modes)" + "description": "Path to the file or directory. The path should be absolute, or otherwise start with ~ for the user's home (required for Line, Directory, Search modes)." }, "image_paths": { "type": "array", "items": { "type": "string" }, - "description": "Paths to images (required for Image mode)" + "description": "List of paths to the images. This is currently supported by the Image mode." }, "start_line": { "type": "integer", - "description": "Starting line number (optional for Line mode)", + "description": "Starting line number (optional, for Line mode). A negative index represents a line number starting from the end of the file.", "default": 1 }, "end_line": { "type": "integer", - "description": "Ending line number (optional for Line mode)", + "description": "Ending line number (optional, for Line mode). A negative index represents a line number starting from the end of the file.", "default": -1 }, "pattern": { "type": "string", - "description": "Search pattern (required for Search mode)" + "description": "Pattern to search for (required, for Search mode). Case insensitive. The pattern matching is performed per line." }, "context_lines": { "type": "integer", - "description": "Context lines around matches (optional for Search mode)", + "description": "Number of context lines around search results (optional, for Search mode)", "default": 2 }, "depth": { "type": "integer", - "description": "Directory traversal depth (optional for Directory mode)", + "description": "Depth of a recursive directory listing (optional, for Directory mode)", "default": 0 } }, From 20192f890a45ad61f677c17c22f04b5c7449c585 Mon Sep 17 00:00:00 2001 From: YIFAN LIU Date: Tue, 1 Jul 2025 17:21:16 -0700 Subject: [PATCH 9/9] made operations be not optional --- crates/chat-cli/src/cli/chat/tools/fs_read.rs | 241 ++++++++---------- 1 file changed, 112 insertions(+), 129 deletions(-) diff --git a/crates/chat-cli/src/cli/chat/tools/fs_read.rs b/crates/chat-cli/src/cli/chat/tools/fs_read.rs index 1b765c6abb..ea52605a53 100644 --- a/crates/chat-cli/src/cli/chat/tools/fs_read.rs +++ b/crates/chat-cli/src/cli/chat/tools/fs_read.rs @@ -40,7 +40,7 @@ use crate::os::Os; #[derive(Debug, Clone, Deserialize)] pub struct FsRead { // For batch operations - pub operations: Option>, + pub operations: Vec, pub summary: Option, } @@ -55,146 +55,129 @@ pub enum FsReadOperation { impl FsRead { pub async fn validate(&mut self, os: &Os) -> Result<()> { - if let Some(operations) = &mut self.operations { - if operations.is_empty() { - bail!("At least one operation must be provided"); - } - - for op in operations { - op.validate(os).await?; - } - Ok(()) - } else { - bail!("'operations' field must be provided") + if self.operations.is_empty() { + bail!("At least one operation must be provided"); + } + for op in &mut self.operations { + op.validate(os).await?; } + Ok(()) } pub async fn queue_description(&self, os: &Os, updates: &mut impl Write) -> Result<()> { - if let Some(operations) = &self.operations { - if operations.len() == 1 { - // Single operation - display without batch prefix - operations[0].queue_description(os, updates).await - } else { - // Multiple operations - display as batch - queue!( - updates, - style::Print("Batch fs_read operation with "), - style::SetForegroundColor(Color::Green), - style::Print(operations.len()), - style::ResetColor, - style::Print(" operations:\n") - )?; - - // Display purpose if available for batch operations - let _ = display_purpose(self.summary.as_ref(), updates); - - for (i, op) in operations.iter().enumerate() { - queue!(updates, style::Print(format!("\n↱ Operation {}: ", i + 1)))?; - op.queue_description(os, updates).await?; - } - Ok(()) - } + if self.operations.len() == 1 { + // Single operation - display without batch prefix + self.operations[0].queue_description(os, updates).await } else { - bail!("'operations' field must be provided") + // Multiple operations - display as batch + queue!( + updates, + style::Print("Batch fs_read operation with "), + style::SetForegroundColor(Color::Green), + style::Print(self.operations.len()), + style::ResetColor, + style::Print(" operations:\n") + )?; + + // Display purpose if available for batch operations + let _ = display_purpose(self.summary.as_ref(), updates); + + for (i, op) in self.operations.iter().enumerate() { + queue!(updates, style::Print(format!("\n↱ Operation {}: ", i + 1)))?; + op.queue_description(os, updates).await?; + } + Ok(()) } } pub async fn invoke(&self, os: &Os, updates: &mut impl Write) -> Result { - if let Some(operations) = &self.operations { - if operations.len() == 1 { - // Single operation - return result directly - operations[0].invoke(os, updates).await - } else { - // Multiple operations - combine results - let mut combined_results = Vec::new(); - let mut all_images = Vec::new(); - let mut has_non_image_ops = false; - let mut success_ops = 0usize; - let mut failed_ops = 0usize; - - for (i, op) in operations.iter().enumerate() { - match op.invoke(os, updates).await { - Ok(result) => { - success_ops += 1; - - match &result.output { - OutputKind::Text(text) => { - combined_results.push(format!( - "=== Operation {} Result (Text) ===\n{}", - i + 1, - text - )); - has_non_image_ops = true; - }, - OutputKind::Json(json) => { - combined_results.push(format!( - "=== Operation {} Result (Json) ===\n{}", - i + 1, - serde_json::to_string_pretty(json)? - )); - has_non_image_ops = true; - }, - OutputKind::Images(images) => { - all_images.extend(images.clone()); - combined_results.push(format!( - "=== Operation {} Result (Images) ===\n[{} images processed]", - i + 1, - images.len() - )); - }, - // This branch won't be reached because single operation execution never returns a Mixed - // result - OutputKind::Mixed { text: _, images: _ } => {}, - } - }, - - Err(err) => { - failed_ops += 1; - combined_results.push(format!("=== Operation {} Error ===\n{}", i + 1, err)); - }, - } + if self.operations.len() == 1 { + // Single operation - return result directly + self.operations[0].invoke(os, updates).await + } else { + // Multiple operations - combine results + let mut combined_results = Vec::new(); + let mut all_images = Vec::new(); + let mut has_non_image_ops = false; + let mut success_ops = 0usize; + let mut failed_ops = 0usize; + + for (i, op) in self.operations.iter().enumerate() { + match op.invoke(os, updates).await { + Ok(result) => { + success_ops += 1; + + match &result.output { + OutputKind::Text(text) => { + combined_results.push(format!("=== Operation {} Result (Text) ===\n{}", i + 1, text)); + has_non_image_ops = true; + }, + OutputKind::Json(json) => { + combined_results.push(format!( + "=== Operation {} Result (Json) ===\n{}", + i + 1, + serde_json::to_string_pretty(json)? + )); + has_non_image_ops = true; + }, + OutputKind::Images(images) => { + all_images.extend(images.clone()); + combined_results.push(format!( + "=== Operation {} Result (Images) ===\n[{} images processed]", + i + 1, + images.len() + )); + }, + // This branch won't be reached because single operation execution never returns a Mixed + // result + OutputKind::Mixed { text: _, images: _ } => {}, + } + }, + + Err(err) => { + failed_ops += 1; + combined_results.push(format!("=== Operation {} Error ===\n{}", i + 1, err)); + }, } + } - queue!( - updates, - style::Print("\n"), - style::Print(CONTINUATION_LINE), - style::Print("\n") - )?; - super::queue_function_result( - &format!( - "Summary: {} operations processed, {} successful, {} failed", - operations.len(), - success_ops, - failed_ops - ), - updates, - false, - true, - )?; - - let combined_text = combined_results.join("\n\n"); - - if !all_images.is_empty() && has_non_image_ops { - queue!(updates, style::Print("\nherherherherherh"),)?; - Ok(InvokeOutput { - output: OutputKind::Mixed { - text: combined_text, - images: all_images, - }, - }) - } else if !all_images.is_empty() { - Ok(InvokeOutput { - output: OutputKind::Images(all_images), - }) - } else { - Ok(InvokeOutput { - output: OutputKind::Text(combined_text), - }) - } + queue!( + updates, + style::Print("\n"), + style::Print(CONTINUATION_LINE), + style::Print("\n") + )?; + super::queue_function_result( + &format!( + "Summary: {} operations processed, {} successful, {} failed", + self.operations.len(), + success_ops, + failed_ops + ), + updates, + false, + true, + )?; + + let combined_text = combined_results.join("\n\n"); + + if !all_images.is_empty() && has_non_image_ops { + queue!(updates, style::Print("\nherherherherherh"),)?; + Ok(InvokeOutput { + output: OutputKind::Mixed { + text: combined_text, + images: all_images, + }, + }) + } else if !all_images.is_empty() { + Ok(InvokeOutput { + output: OutputKind::Images(all_images), + }) + } else { + Ok(InvokeOutput { + output: OutputKind::Text(combined_text), + }) } - } else { - bail!("'operations' field must be provided") } } }