diff --git a/src/apps/desktop/src/api/tool_api.rs b/src/apps/desktop/src/api/tool_api.rs index 3268ae3a..9d27ea37 100644 --- a/src/apps/desktop/src/api/tool_api.rs +++ b/src/apps/desktop/src/api/tool_api.rs @@ -84,15 +84,19 @@ pub struct ToolConfirmationResponse { } fn build_tool_context(workspace_path: Option<&str>) -> ToolUseContext { + let normalized_workspace_path = workspace_path + .map(str::trim) + .filter(|path| !path.is_empty()); + ToolUseContext { tool_call_id: None, message_id: None, agent_type: None, session_id: None, dialog_turn_id: None, - workspace: workspace_path - .filter(|path| !path.is_empty()) + workspace: normalized_workspace_path .map(|path| WorkspaceBinding::new(None, PathBuf::from(path))), + current_working_directory: normalized_workspace_path.map(str::to_string), safe_mode: Some(false), abort_controller: None, read_file_timestamps: HashMap::new(), diff --git a/src/crates/core/src/agentic/agents/agentic_mode.rs b/src/crates/core/src/agentic/agents/agentic_mode.rs index 59e00bf2..8efe9baf 100644 --- a/src/crates/core/src/agentic/agents/agentic_mode.rs +++ b/src/crates/core/src/agentic/agents/agentic_mode.rs @@ -48,16 +48,8 @@ impl Agent for AgenticMode { "Full-featured AI assistant with access to all tools for comprehensive software development tasks" } - fn prompt_template_name(&self, model_name: Option<&str>) -> &str { - let Some(model_name) = model_name else { - return "agentic_mode"; - }; - let model_name = model_name.trim().to_ascii_lowercase(); - if model_name.contains("gpt-5") { - "agentic_mode_gpt5" - } else { - "agentic_mode" - } + fn prompt_template_name(&self, _model_name: Option<&str>) -> &str { + "agentic_mode" } fn default_tools(&self) -> Vec { @@ -74,21 +66,16 @@ mod tests { use super::{Agent, AgenticMode}; #[test] - fn selects_gpt5_prompt_template() { + fn always_uses_default_prompt_template() { let agent = AgenticMode::new(); assert_eq!( agent.prompt_template_name(Some("gpt-5.1")), - "agentic_mode_gpt5" + "agentic_mode" ); assert_eq!( agent.prompt_template_name(Some("GPT-5-CODEX")), - "agentic_mode_gpt5" + "agentic_mode" ); - } - - #[test] - fn keeps_default_template_for_non_gpt5_models() { - let agent = AgenticMode::new(); assert_eq!( agent.prompt_template_name(Some("claude-sonnet-4")), "agentic_mode" diff --git a/src/crates/core/src/agentic/agents/prompts/agentic_mode_gpt5.md b/src/crates/core/src/agentic/agents/prompts/agentic_mode_gpt5.md deleted file mode 100644 index cae65d1b..00000000 --- a/src/crates/core/src/agentic/agents/prompts/agentic_mode_gpt5.md +++ /dev/null @@ -1,71 +0,0 @@ -You are BitFun, an ADE (AI IDE) that helps users with software engineering tasks. - -You are pair programming with a USER. Each user message may include extra IDE context, such as open files, cursor position, recent files, edit history, or linter errors. Use what is relevant and ignore what is not. - -Follow the USER's instructions in each message, denoted by the tag. - -Tool results and user messages may include tags. Follow them, but do not mention them to the user. - -IMPORTANT: Assist with defensive security tasks only. Refuse to create, modify, or improve code that may be used maliciously. Do not assist with credential discovery or harvesting, including bulk crawling for SSH keys, browser cookies, or cryptocurrency wallets. Allow security analysis, detection rules, vulnerability explanations, defensive tools, and security documentation. - -IMPORTANT: Never generate or guess URLs for the user unless you are confident they directly help with the programming task. You may use URLs provided by the user or found in local files. - -{LANGUAGE_PREFERENCE} -{VISUAL_MODE} - -# Behavior -- Be concise, direct, and action-oriented. -- Default to doing the work instead of discussing it. -- Read relevant code before editing it. -- Prioritize technical accuracy over agreement. -- Never give time estimates. - -# Editing -- Prefer editing existing files over creating new ones. -- Default to ASCII unless the file already uses non-ASCII and there is a clear reason. -- Add comments only when needed for non-obvious logic. -- Avoid unrelated refactors, speculative abstractions, and unnecessary compatibility shims. -- Do not add features or improvements beyond the request unless required to make the requested change work. -- Do not introduce security issues such as command injection, XSS, SQL injection, path traversal, or unsafe shell handling. - -# Tools -- Use TodoWrite for non-trivial or multi-step tasks, and keep it updated. -- Use AskUserQuestion only when a decision materially changes the result and cannot be inferred safely. -- Use Read, Grep, and Glob by default for codebase lookups; they are faster for narrow or single-location questions. -- Use Task with Explore or FileFinder only for genuinely open-ended or multi-area exploration (many modules, unclear ownership, architectural surveys). -- Prefer specialized file tools over Bash for reading and editing files. -- Use Bash for builds, tests, git, and scripts. -- Run independent tool calls in parallel when possible. -- Do not use tools to communicate with the user. - -# Questions -- Ask only when you are truly blocked and cannot safely choose a reasonable default. -- If you must ask, do all non-blocked work first, then ask exactly one targeted question with a recommended default. - -# Workspace -- Never revert user changes unless explicitly requested. -- Work with existing changes in touched files instead of discarding them. -- Do not amend commits unless explicitly requested. -- Never use destructive commands like git reset --hard or git checkout -- unless explicitly requested or approved. - -# Responses -- Keep responses short, useful, and technically precise. -- Avoid unnecessary praise, emotional validation, or emojis. -- Summarize meaningful command results instead of pasting raw output. -- Do not tell the user to save or copy files. - -# Code references -- Use clickable markdown links for files and code locations. -- Use bare filenames as link text. -- Use workspace-relative paths for workspace files and absolute paths otherwise. - -Examples: -- [filename.ts](src/filename.ts) -- [filename.ts:42](src/filename.ts#L42) -- [filename.ts:42-51](src/filename.ts#L42-L51) - -{ENV_INFO} -{PROJECT_LAYOUT} -{RULES} -{MEMORIES} -{PROJECT_CONTEXT_FILES:exclude=review} \ No newline at end of file diff --git a/src/crates/core/src/agentic/execution/execution_engine.rs b/src/crates/core/src/agentic/execution/execution_engine.rs index 82e33c03..10c8e245 100644 --- a/src/crates/core/src/agentic/execution/execution_engine.rs +++ b/src/crates/core/src/agentic/execution/execution_engine.rs @@ -1409,6 +1409,7 @@ impl ExecutionEngine { session_id: None, dialog_turn_id: None, workspace: workspace.cloned(), + current_working_directory: None, safe_mode: None, abort_controller: None, read_file_timestamps: Default::default(), diff --git a/src/crates/core/src/agentic/tools/framework.rs b/src/crates/core/src/agentic/tools/framework.rs index 93ba8e43..810be01c 100644 --- a/src/crates/core/src/agentic/tools/framework.rs +++ b/src/crates/core/src/agentic/tools/framework.rs @@ -21,6 +21,7 @@ pub struct ToolUseContext { pub session_id: Option, pub dialog_turn_id: Option, pub workspace: Option, + pub current_working_directory: Option, pub safe_mode: Option, pub abort_controller: Option, pub read_file_timestamps: HashMap, @@ -43,6 +44,15 @@ impl ToolUseContext { self.workspace.as_ref().map(|binding| binding.root_path()) } + pub fn current_working_directory(&self) -> Option<&Path> { + self.current_working_directory.as_deref().map(Path::new) + } + + pub fn path_resolution_base(&self) -> Option<&Path> { + self.current_working_directory() + .or_else(|| self.workspace_root()) + } + pub fn is_remote(&self) -> bool { self.workspace .as_ref() diff --git a/src/crates/core/src/agentic/tools/implementations/file_edit_tool.rs b/src/crates/core/src/agentic/tools/implementations/file_edit_tool.rs index 207cd475..1dc01714 100644 --- a/src/crates/core/src/agentic/tools/implementations/file_edit_tool.rs +++ b/src/crates/core/src/agentic/tools/implementations/file_edit_tool.rs @@ -97,7 +97,11 @@ Usage: .and_then(|v| v.as_bool()) .unwrap_or(false); - let resolved_path = resolve_path_with_workspace(file_path, context.workspace_root())?; + let resolved_path = resolve_path_with_workspace( + file_path, + context.current_working_directory(), + context.workspace_root(), + )?; // When WorkspaceServices is available (both local and remote), // use the abstract FS to read → edit in memory → write back. diff --git a/src/crates/core/src/agentic/tools/implementations/file_read_tool.rs b/src/crates/core/src/agentic/tools/implementations/file_read_tool.rs index be501d04..c5ea84be 100644 --- a/src/crates/core/src/agentic/tools/implementations/file_read_tool.rs +++ b/src/crates/core/src/agentic/tools/implementations/file_read_tool.rs @@ -13,66 +13,136 @@ use tool_runtime::fs::read_file::read_file; pub struct FileReadTool { default_max_lines_to_read: usize, max_line_chars: usize, + max_total_chars: usize, } impl FileReadTool { pub fn new() -> Self { Self { - default_max_lines_to_read: 2000, - max_line_chars: 2000, + default_max_lines_to_read: 250, + max_line_chars: 300, + max_total_chars: 32_000, } } - pub fn with_config(default_max_lines_to_read: usize, max_line_chars: usize) -> Self { + pub fn with_config( + default_max_lines_to_read: usize, + max_line_chars: usize, + max_total_chars: usize, + ) -> Self { Self { default_max_lines_to_read, max_line_chars, + max_total_chars, } } - fn format_lines(&self, content: &str, start_line: usize, limit: usize) -> tool_runtime::fs::read_file::ReadFileResult { - let lines: Vec<&str> = content.lines().collect(); - let total_lines = lines.len(); + async fn read_remote_window( + &self, + resolved_path: &str, + start_line: usize, + limit: usize, + context: &ToolUseContext, + ) -> BitFunResult { + const TOTAL_LINES_MARKER: &str = "__BITFUN_TOTAL_LINES__="; + const HIT_TOTAL_CHAR_LIMIT_MARKER: &str = "__BITFUN_HIT_TOTAL_CHAR_LIMIT__="; + + let end_line = start_line + .checked_add(limit.saturating_sub(1)) + .ok_or_else(|| BitFunError::tool("Requested line range is too large".to_string()))?; + + let ws_shell = context + .ws_shell() + .ok_or_else(|| BitFunError::tool("Remote workspace shell is unavailable".to_string()))?; + + let escaped_path = shell_escape(resolved_path); + let command = format!( + "if [ ! -f {path} ]; then exit 3; fi; awk -v start={start} -v end={end} -v max={max} -v budget={budget} 'BEGIN {{ total = 0; used = 0; hit = 0; }} {{ total = NR; if (!hit && NR >= start && NR <= end) {{ line = $0; if (length(line) > max) {{ line = substr(line, 1, max) \" [truncated]\"; }} rendered = sprintf(\"%6d\\t%s\", NR, line); extra = (used > 0 ? 1 : 0); next_used = used + extra + length(rendered); if (next_used > budget) {{ hit = 1; next; }} print rendered; used = next_used; }} }} END {{ printf(\"{marker}%d\\n\", total) > \"/dev/stderr\"; printf(\"{hit_marker}%d\\n\", hit) > \"/dev/stderr\"; }}' {path}", + path = escaped_path, + start = start_line, + end = end_line, + max = self.max_line_chars, + budget = self.max_total_chars, + marker = TOTAL_LINES_MARKER, + hit_marker = HIT_TOTAL_CHAR_LIMIT_MARKER, + ); + + let (stdout, stderr, status) = ws_shell + .exec(&command, None) + .await + .map_err(|e| BitFunError::tool(format!("Failed to read file: {}", e)))?; + + let mut total_lines = None; + let mut hit_total_char_limit = false; + let mut stderr_messages = Vec::new(); + for line in stderr.lines() { + if let Some(rest) = line.strip_prefix(TOTAL_LINES_MARKER) { + total_lines = rest.trim().parse::().ok(); + } else if let Some(rest) = line.strip_prefix(HIT_TOTAL_CHAR_LIMIT_MARKER) { + hit_total_char_limit = rest.trim() == "1"; + } else if !line.trim().is_empty() { + stderr_messages.push(line.to_string()); + } + } + + if status != 0 { + let message = if status == 3 { + format!("File not found or not a regular file: {}", resolved_path) + } else if !stderr_messages.is_empty() { + stderr_messages.join("\n") + } else { + format!("Failed to read file: remote command exited with status {}", status) + }; + return Err(BitFunError::tool(message)); + } + + let total_lines = total_lines.ok_or_else(|| { + BitFunError::tool("Failed to read file: remote line count was unavailable".to_string()) + })?; if total_lines == 0 { - return tool_runtime::fs::read_file::ReadFileResult { + return Ok(tool_runtime::fs::read_file::ReadFileResult { start_line: 0, end_line: 0, total_lines: 0, content: String::new(), - }; + hit_total_char_limit, + }); } - let start_index = (start_line - 1).min(total_lines - 1); - let end_index = (start_index + limit).min(total_lines); - let selected_lines = &lines[start_index..end_index]; - - let truncated_lines: Vec = selected_lines - .iter() - .enumerate() - .map(|(idx, line)| { - let line_number = start_index + idx + 1; - let line_content = if line.chars().count() > self.max_line_chars { - format!( - "{} [truncated]", - tool_runtime::util::string::truncate_string_by_chars(line, self.max_line_chars) - ) - } else { - line.to_string() - }; - format!("{:>6}\t{}", line_number, line_content) - }) - .collect(); + if start_line > total_lines { + return Err(BitFunError::tool(format!( + "`start_line` {} is larger than the number of lines in the file: {}", + start_line, total_lines + ))); + } + + let content = stdout.trim_end_matches('\n').to_string(); + let lines_read = if content.is_empty() { + 0 + } else { + content.lines().count() + }; + let end_line = if lines_read == 0 { + start_line + } else { + (start_line + lines_read).saturating_sub(1) + }; - tool_runtime::fs::read_file::ReadFileResult { - start_line: start_index + 1, - end_line: end_index, + Ok(tool_runtime::fs::read_file::ReadFileResult { + start_line, + end_line, total_lines, - content: truncated_lines.join("\n"), - } + content, + hit_total_char_limit, + }) } } +fn shell_escape(s: &str) -> String { + format!("'{}'", s.replace('\'', "'\\''")) +} + #[async_trait] impl Tool for FileReadTool { fn name(&self) -> &str { @@ -87,13 +157,14 @@ Assume this tool is able to read all files on the machine. If the User provides Usage: - The file_path parameter must be an absolute path, not a relative path. - By default, it reads up to {} lines starting from the beginning of the file. -- You can optionally specify a start_line and limit (especially handy for long files), but it's recommended to read the whole file by not providing these parameters. +- You can optionally specify a start_line and limit. For large files, prefer reading targeted ranges instead of starting over from the beginning every time. - Any lines longer than {} characters will be truncated. +- Total output is capped at {} characters. If that limit is hit, narrow the range with start_line and limit. - Results are returned using cat -n format, with line numbers starting at 1 - This tool can only read files, not directories. To read a directory, use an ls command via the Bash tool. - You can call multiple tools in a single response. It is always better to speculatively read multiple potentially useful files in parallel. "#, - self.default_max_lines_to_read, self.max_line_chars + self.default_max_lines_to_read, self.max_line_chars, self.max_total_chars )) } @@ -158,6 +229,7 @@ Usage: let resolved_path = match resolve_path_with_workspace( file_path, + context.and_then(|ctx| ctx.current_working_directory()), context.and_then(|ctx| ctx.workspace_root()), ) { Ok(path) => path, @@ -229,17 +301,24 @@ Usage: .and_then(|v| v.as_u64()) .unwrap_or(self.default_max_lines_to_read as u64) as usize; - let resolved_path = resolve_path_with_workspace(file_path, context.workspace_root())?; + let resolved_path = resolve_path_with_workspace( + file_path, + context.current_working_directory(), + context.workspace_root(), + )?; // Use the workspace file system from context — works for both local and remote. - let read_file_result = if let Some(ws_fs) = context.ws_fs() { - let content = ws_fs - .read_file_text(&resolved_path) - .await - .map_err(|e| BitFunError::tool(format!("Failed to read file: {}", e)))?; - self.format_lines(&content, start_line, limit) + let read_file_result = if context.is_remote() { + self.read_remote_window(&resolved_path, start_line, limit, context) + .await? } else { - read_file(&resolved_path, start_line, limit, self.max_line_chars) + read_file( + &resolved_path, + start_line, + limit, + self.max_line_chars, + self.max_total_chars, + ) .map_err(|e| BitFunError::tool(e))? }; @@ -272,7 +351,17 @@ Usage: result_for_assistant.push_str(rules_content); } - let lines_read = read_file_result.end_line - read_file_result.start_line + 1; + if read_file_result.hit_total_char_limit { + result_for_assistant.push_str("\n\n[Output truncated after reaching the Read tool size limit. Request a narrower range with start_line and limit.]"); + } + + let lines_read = if read_file_result.total_lines == 0 + || read_file_result.end_line < read_file_result.start_line + { + 0 + } else { + read_file_result.end_line - read_file_result.start_line + 1 + }; let result = ToolResult::Result { data: json!({ @@ -282,6 +371,7 @@ Usage: "lines_read": lines_read, "start_line": read_file_result.start_line, "size": read_file_result.content.len(), + "hit_total_char_limit": read_file_result.hit_total_char_limit, "matched_rules_count": file_rules.matched_count }), result_for_assistant: Some(result_for_assistant), diff --git a/src/crates/core/src/agentic/tools/implementations/file_write_tool.rs b/src/crates/core/src/agentic/tools/implementations/file_write_tool.rs index 2c28f952..61eca34c 100644 --- a/src/crates/core/src/agentic/tools/implementations/file_write_tool.rs +++ b/src/crates/core/src/agentic/tools/implementations/file_write_tool.rs @@ -89,9 +89,11 @@ Usage: }; } - if let Err(err) = - resolve_path_with_workspace(file_path, context.and_then(|ctx| ctx.workspace_root())) - { + if let Err(err) = resolve_path_with_workspace( + file_path, + context.and_then(|ctx| ctx.current_working_directory()), + context.and_then(|ctx| ctx.workspace_root()), + ) { return ValidationResult { result: false, message: Some(err.to_string()), @@ -130,7 +132,11 @@ Usage: .and_then(|v| v.as_str()) .ok_or_else(|| BitFunError::tool("file_path is required".to_string()))?; - let resolved_path = resolve_path_with_workspace(file_path, context.workspace_root())?; + let resolved_path = resolve_path_with_workspace( + file_path, + context.current_working_directory(), + context.workspace_root(), + )?; let content = input .get("content") diff --git a/src/crates/core/src/agentic/tools/implementations/get_file_diff_tool.rs b/src/crates/core/src/agentic/tools/implementations/get_file_diff_tool.rs index 9c17fb49..fd37c1aa 100644 --- a/src/crates/core/src/agentic/tools/implementations/get_file_diff_tool.rs +++ b/src/crates/core/src/agentic/tools/implementations/get_file_diff_tool.rs @@ -409,7 +409,11 @@ Usage: .and_then(|v| v.as_str()) .ok_or_else(|| BitFunError::tool("file_path is required".to_string()))?; - let resolved_path = resolve_path_with_workspace(file_path, context.workspace_root())?; + let resolved_path = resolve_path_with_workspace( + file_path, + context.current_working_directory(), + context.workspace_root(), + )?; debug!( "GetFileDiff tool starting diff retrieval for file: {:?}", diff --git a/src/crates/core/src/agentic/tools/implementations/grep_tool.rs b/src/crates/core/src/agentic/tools/implementations/grep_tool.rs index 26c0f191..8244b410 100644 --- a/src/crates/core/src/agentic/tools/implementations/grep_tool.rs +++ b/src/crates/core/src/agentic/tools/implementations/grep_tool.rs @@ -28,7 +28,11 @@ impl GrepTool { .ok_or_else(|| BitFunError::tool("pattern is required".to_string()))?; let search_path = input.get("path").and_then(|v| v.as_str()).unwrap_or("."); - let resolved_path = resolve_path_with_workspace(search_path, context.workspace_root())?; + let resolved_path = resolve_path_with_workspace( + search_path, + context.current_working_directory(), + context.workspace_root(), + )?; let case_insensitive = input.get("-i").and_then(|v| v.as_bool()).unwrap_or(false); let head_limit = input @@ -103,7 +107,11 @@ impl GrepTool { .ok_or_else(|| BitFunError::tool("pattern is required".to_string()))?; let search_path = input.get("path").and_then(|v| v.as_str()).unwrap_or("."); - let resolved_path = resolve_path_with_workspace(search_path, context.workspace_root())?; + let resolved_path = resolve_path_with_workspace( + search_path, + context.current_working_directory(), + context.workspace_root(), + )?; let case_insensitive = input.get("-i").and_then(|v| v.as_bool()).unwrap_or(false); let multiline = input diff --git a/src/crates/core/src/agentic/tools/implementations/tool-runtime/src/fs/read_file.rs b/src/crates/core/src/agentic/tools/implementations/tool-runtime/src/fs/read_file.rs index 6b6fedf9..f66a0b19 100644 --- a/src/crates/core/src/agentic/tools/implementations/tool-runtime/src/fs/read_file.rs +++ b/src/crates/core/src/agentic/tools/implementations/tool-runtime/src/fs/read_file.rs @@ -1,5 +1,7 @@ use crate::util::string::truncate_string_by_chars; -use std::fs; +use std::fs::File; +use std::io::BufRead; +use std::io::BufReader; #[derive(Debug)] pub struct ReadFileResult { @@ -7,6 +9,7 @@ pub struct ReadFileResult { pub end_line: usize, pub total_lines: usize, pub content: String, + pub hit_total_char_limit: bool, } /// start_line: starts from 1 @@ -15,6 +18,7 @@ pub fn read_file( start_line: usize, limit: usize, max_line_chars: usize, + max_total_chars: usize, ) -> Result { if start_line == 0 { return Err(format!("`start_line` should start from 1",)); @@ -22,53 +26,130 @@ pub fn read_file( if limit == 0 { return Err(format!("`limit` can't be 0")); } - let start_index = start_line - 1; + if max_total_chars == 0 { + return Err("`max_total_chars` can't be 0".to_string()); + } + let end_line_inclusive = start_line + .checked_add(limit.saturating_sub(1)) + .ok_or_else(|| "Requested line range is too large".to_string())?; - let full_content = fs::read_to_string(file_path) + let file = File::open(file_path) .map_err(|e| format!("Failed to read file {}: {}", file_path, e))?; + let reader = BufReader::new(file); + + let mut total_lines = 0usize; + let mut selected_lines = Vec::new(); + let mut selected_chars = 0usize; + let mut hit_total_char_limit = false; + + for line_result in reader.lines() { + let line = line_result + .map_err(|e| format!("Failed to read file {}: {}", file_path, e))?; + total_lines += 1; + + if total_lines < start_line || total_lines > end_line_inclusive || hit_total_char_limit { + continue; + } + + let line_content = if line.chars().count() > max_line_chars { + format!( + "{} [truncated]", + truncate_string_by_chars(&line, max_line_chars) + ) + } else { + line + }; + + let rendered_line = format!("{:>6}\t{}", total_lines, line_content); + let separator_chars = usize::from(!selected_lines.is_empty()); + let next_total_chars = selected_chars + .saturating_add(separator_chars) + .saturating_add(rendered_line.chars().count()); + + if next_total_chars > max_total_chars { + hit_total_char_limit = true; + continue; + } + + selected_chars = next_total_chars; + selected_lines.push(rendered_line); + } - let lines: Vec<&str> = full_content.lines().collect(); - let total_lines = lines.len(); if total_lines == 0 { return Ok(ReadFileResult { start_line: 0, end_line: 0, total_lines: 0, content: String::new(), + hit_total_char_limit, }); } - if start_index >= total_lines { + if start_line > total_lines { return Err(format!( "`start_line` {} is larger than the number of lines in the file: {}", start_line, total_lines )); } - let end_index = (start_index + limit).min(total_lines); - let selected_lines = &lines[start_index..end_index]; - - // Truncate long lines and format with line numbers (cat -n format) - let truncated_lines: Vec = selected_lines - .iter() - .enumerate() - .map(|(idx, line)| { - let line_number = start_index + idx + 1; - let line_content = if line.chars().count() > max_line_chars { - format!( - "{} [truncated]", - truncate_string_by_chars(line, max_line_chars) - ) - } else { - line.to_string() - }; - format!("{:>6}\t{}", line_number, line_content) - }) - .collect(); - let final_content = truncated_lines.join("\n"); + + let end_line = if selected_lines.is_empty() { + start_line + } else { + (start_line + selected_lines.len()).saturating_sub(1) + }; + Ok(ReadFileResult { - start_line: start_index + 1, - end_line: end_index, + start_line, + end_line, total_lines, - content: final_content, + content: selected_lines.join("\n"), + hit_total_char_limit, }) } + +#[cfg(test)] +mod tests { + use super::read_file; + use std::fs; + use std::path::PathBuf; + use std::time::{SystemTime, UNIX_EPOCH}; + + fn write_temp_file(contents: &str) -> PathBuf { + let unique = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("time went backwards") + .as_nanos(); + let path = std::env::temp_dir().join(format!("bitfun-read-file-test-{unique}.txt")); + fs::write(&path, contents).expect("temp file should be written"); + path + } + + #[test] + fn truncates_when_total_char_budget_is_hit() { + let path = write_temp_file("abcdefghijklmnopqrstuvwxyz\nsecond line\nthird line\n"); + + let result = read_file(path.to_str().expect("utf-8 path"), 1, 10, 10, 30) + .expect("read should succeed"); + + fs::remove_file(&path).expect("temp file should be deleted"); + + assert_eq!(result.start_line, 1); + assert_eq!(result.end_line, 1); + assert!(result.hit_total_char_limit); + assert_eq!(result.content, " 1\tabcdefghij [truncated]"); + } + + #[test] + fn reads_multiple_lines_when_budget_allows() { + let path = write_temp_file("one\ntwo\nthree\n"); + + let result = read_file(path.to_str().expect("utf-8 path"), 1, 10, 50, 100) + .expect("read should succeed"); + + fs::remove_file(&path).expect("temp file should be deleted"); + + assert_eq!(result.end_line, 3); + assert!(!result.hit_total_char_limit); + assert_eq!(result.content, " 1\tone\n 2\ttwo\n 3\tthree"); + } +} diff --git a/src/crates/core/src/agentic/tools/implementations/util.rs b/src/crates/core/src/agentic/tools/implementations/util.rs index 1b9dcccb..aaa50e07 100644 --- a/src/crates/core/src/agentic/tools/implementations/util.rs +++ b/src/crates/core/src/agentic/tools/implementations/util.rs @@ -26,24 +26,50 @@ pub fn normalize_path(path: &str) -> String { pub fn resolve_path_with_workspace( path: &str, + current_working_directory: Option<&Path>, workspace_root: Option<&Path>, ) -> BitFunResult { if Path::new(path).is_absolute() { Ok(normalize_path(path)) } else { - let workspace_path = workspace_root.ok_or_else(|| { + let base_path = current_working_directory.or(workspace_root).ok_or_else(|| { BitFunError::tool(format!( - "workspace_path is required to resolve relative path: {}", + "A current working directory or workspace path is required to resolve relative path: {}", path )) })?; - Ok(normalize_path( - &workspace_path.join(path).to_string_lossy().to_string(), - )) + Ok(normalize_path(&base_path.join(path).to_string_lossy().to_string())) } } pub fn resolve_path(path: &str) -> BitFunResult { - resolve_path_with_workspace(path, None) + resolve_path_with_workspace(path, None, None) +} + +#[cfg(test)] +mod tests { + use super::resolve_path_with_workspace; + use std::path::Path; + + #[test] + fn resolves_relative_paths_from_current_working_directory_first() { + let resolved = resolve_path_with_workspace( + "src/main.rs", + Some(Path::new("/repo/crates/core")), + Some(Path::new("/repo")), + ) + .expect("path should resolve"); + + assert_eq!(resolved, "/repo/crates/core/src/main.rs"); + } + + #[test] + fn falls_back_to_workspace_root_when_current_working_directory_missing() { + let resolved = + resolve_path_with_workspace("src/main.rs", None, Some(Path::new("/repo"))) + .expect("path should resolve"); + + assert_eq!(resolved, "/repo/src/main.rs"); + } } diff --git a/src/crates/core/src/agentic/tools/implementations/web_tools.rs b/src/crates/core/src/agentic/tools/implementations/web_tools.rs index b30551d7..ce04302f 100644 --- a/src/crates/core/src/agentic/tools/implementations/web_tools.rs +++ b/src/crates/core/src/agentic/tools/implementations/web_tools.rs @@ -556,6 +556,7 @@ mod tests { session_id: None, dialog_turn_id: None, workspace: None, + current_working_directory: None, safe_mode: None, abort_controller: None, read_file_timestamps: HashMap::new(), diff --git a/src/crates/core/src/agentic/tools/pipeline/tool_pipeline.rs b/src/crates/core/src/agentic/tools/pipeline/tool_pipeline.rs index ad82eda6..70cab66c 100644 --- a/src/crates/core/src/agentic/tools/pipeline/tool_pipeline.rs +++ b/src/crates/core/src/agentic/tools/pipeline/tool_pipeline.rs @@ -725,6 +725,11 @@ impl ToolPipeline { session_id: Some(task.context.session_id.clone()), dialog_turn_id: Some(task.context.dialog_turn_id.clone()), workspace: task.context.workspace.clone(), + current_working_directory: task + .context + .context_vars + .get("current_working_directory") + .cloned(), safe_mode: None, abort_controller: None, read_file_timestamps: Default::default(), diff --git a/src/web-ui/src/flow_chat/components/ChatInput.tsx b/src/web-ui/src/flow_chat/components/ChatInput.tsx index 02ad9f80..3b93349e 100644 --- a/src/web-ui/src/flow_chat/components/ChatInput.tsx +++ b/src/web-ui/src/flow_chat/components/ChatInput.tsx @@ -63,6 +63,11 @@ type SlashModeItem = { type SlashPickerItem = SlashActionItem | SlashModeItem; type ChatInputTarget = 'main' | 'btw'; +type PendingLargePasteMap = Record; + +function getCharacterCount(text: string): number { + return Array.from(text).length; +} export const ChatInput: React.FC = ({ className = '', @@ -79,6 +84,8 @@ export const ChatInput: React.FC = ({ const lastImeCompositionEndAtRef = useRef(0); // Ref so the queuedInput sync effect can read the latest value without it being a dep const inputValueRef = useRef(''); + const pendingLargePastesRef = useRef({}); + const largePasteCountersRef = useRef>({}); // History navigation state const [historyIndex, setHistoryIndex] = useState(-1); @@ -246,6 +253,61 @@ export const ChatInput: React.FC = ({ query: '', selectedIndex: 0, }); + + const clearPendingLargePastes = useCallback(() => { + pendingLargePastesRef.current = {}; + }, []); + + const createLargePastePlaceholder = useCallback((text: string): string | null => { + const charCount = getCharacterCount(text); + if (charCount <= CHAT_INPUT_CONFIG.largePaste.thresholdChars) { + return null; + } + + const nextCounters = largePasteCountersRef.current; + const nextSuffix = (nextCounters[charCount] ?? 0) + 1; + nextCounters[charCount] = nextSuffix; + + const base = t('input.largePastePlaceholder', { + count: charCount, + defaultValue: '[Pasted Content {{count}} chars]', + }); + const placeholder = nextSuffix === 1 ? base : `${base} #${nextSuffix}`; + + pendingLargePastesRef.current = { + ...pendingLargePastesRef.current, + [placeholder]: text, + }; + + return placeholder; + }, [t]); + + const prunePendingLargePastes = useCallback((text: string) => { + const entries = Object.entries(pendingLargePastesRef.current); + if (entries.length === 0) { + return; + } + + pendingLargePastesRef.current = Object.fromEntries( + entries.filter(([placeholder]) => text.includes(placeholder)) + ); + }, []); + + const expandPendingLargePastes = useCallback((text: string) => { + let expanded = text; + for (const [placeholder, actual] of Object.entries(pendingLargePastesRef.current)) { + if (expanded.includes(placeholder)) { + expanded = expanded.split(placeholder).join(actual); + } + } + return expanded; + }, []); + + React.useEffect(() => { + if (inputState.value === '') { + clearPendingLargePastes(); + } + }, [clearPendingLargePastes, inputState.value]); React.useEffect(() => { const store = FlowChatStore.getInstance(); @@ -282,6 +344,7 @@ export const ChatInput: React.FC = ({ const message = customEvent.detail?.message; if (message) { + clearPendingLargePastes(); dispatchInput({ type: 'ACTIVATE' }); dispatchInput({ type: 'SET_VALUE', payload: message }); @@ -296,10 +359,11 @@ export const ChatInput: React.FC = ({ return () => { window.removeEventListener('fill-chat-input', handleFillInput); }; - }, []); + }, [clearPendingLargePastes]); React.useEffect(() => { const handleFillChatInput = (data: { content: string }) => { + clearPendingLargePastes(); dispatchInput({ type: 'ACTIVATE' }); dispatchInput({ type: 'SET_VALUE', payload: data.content }); @@ -313,7 +377,7 @@ export const ChatInput: React.FC = ({ return () => { globalEventBus.off('fill-chat-input', handleFillChatInput); }; - }, []); + }, [clearPendingLargePastes]); // Handle MCP App ui/message requests (aligned with VSCode behavior) React.useEffect(() => { @@ -339,6 +403,7 @@ export const ChatInput: React.FC = ({ .join('\n\n'); if (textContent) { + clearPendingLargePastes(); dispatchInput({ type: 'ACTIVATE' }); dispatchInput({ type: 'SET_VALUE', payload: textContent }); } @@ -391,7 +456,7 @@ export const ChatInput: React.FC = ({ return () => { globalEventBus.off('mcp-app:message', handleMcpAppMessage); }; - }, [inputState.value, addContext, currentImageCount]); + }, [inputState.value, addContext, clearPendingLargePastes, currentImageCount]); React.useEffect(() => { const handleInsertContextTag = (event: Event) => { @@ -517,6 +582,7 @@ export const ChatInput: React.FC = ({ // (EventHandlerModule sets queuedInput on failed turns), NOT for live typing. // Restoring while the user is actively typing would overwrite their draft. log.debug('Detected queuedInput, restoring message to input', { queuedInput }); + clearPendingLargePastes(); dispatchInput({ type: 'ACTIVATE' }); dispatchInput({ type: 'SET_VALUE', payload: queuedInput }); inputValueRef.current = queuedInput; @@ -528,6 +594,7 @@ export const ChatInput: React.FC = ({ }, [ derivedState?.queuedInput, effectiveTargetSessionId, + clearPendingLargePastes, ]); React.useEffect(() => { @@ -693,6 +760,7 @@ export const ChatInput: React.FC = ({ } }); + prunePendingLargePastes(text); dispatchInput({ type: 'SET_VALUE', payload: text }); inputValueRef.current = text; @@ -750,7 +818,7 @@ export const ChatInput: React.FC = ({ selectedIndex: 0, }); } - }, [contexts, derivedState, inputState.isActive, removeContext, setQueuedInput, slashCommandState.isActive, slashCommandState.kind]); + }, [contexts, derivedState, inputState.isActive, prunePendingLargePastes, removeContext, setQueuedInput, slashCommandState.isActive, slashCommandState.kind]); const submitBtwFromInput = useCallback(async () => { if (!derivedState) return; @@ -763,11 +831,15 @@ export const ChatInput: React.FC = ({ return; } - const message = inputState.value.trim(); + const originalMessage = inputState.value.trim(); + const originalPendingLargePastes = { ...pendingLargePastesRef.current }; + const message = expandPendingLargePastes(originalMessage).trim(); + const messageCharCount = getCharacterCount(message); const question = message.replace(/^\/btw\b/i, '').trim(); // Clear input without adding to main history. dispatchInput({ type: 'CLEAR_VALUE' }); + clearPendingLargePastes(); setQueuedInput(null); setSlashCommandState({ isActive: false, kind: 'modes', query: '', selectedIndex: 0 }); @@ -776,6 +848,21 @@ export const ChatInput: React.FC = ({ return; } + if (messageCharCount > CHAT_INPUT_CONFIG.largePaste.maxMessageChars) { + notificationService.error( + t('input.messageTooLarge', { + max: CHAT_INPUT_CONFIG.largePaste.maxMessageChars, + count: messageCharCount, + defaultValue: 'Message exceeds the maximum length of {{max}} characters ({{count}} provided).', + }), + { duration: 4000 } + ); + pendingLargePastesRef.current = originalPendingLargePastes; + dispatchInput({ type: 'ACTIVATE' }); + dispatchInput({ type: 'SET_VALUE', payload: originalMessage }); + return; + } + try { const { childSessionId } = await startBtwThread({ parentSessionId: currentSessionId, @@ -795,9 +882,10 @@ export const ChatInput: React.FC = ({ } catch (e) { log.error('Failed to start /btw thread', { e }); dispatchInput({ type: 'ACTIVATE' }); - dispatchInput({ type: 'SET_VALUE', payload: message }); + pendingLargePastesRef.current = originalPendingLargePastes; + dispatchInput({ type: 'SET_VALUE', payload: originalMessage }); } - }, [currentSessionId, derivedState, inputState.value, isBtwSession, setQueuedInput, t, workspacePath]); + }, [clearPendingLargePastes, currentSessionId, derivedState, expandPendingLargePastes, inputState.value, isBtwSession, setQueuedInput, t, workspacePath]); const submitCompactFromInput = useCallback(async () => { if (!effectiveTargetSessionId || !effectiveTargetSession) { @@ -879,7 +967,10 @@ export const ChatInput: React.FC = ({ if (!draftTrimmed) return; - const message = draftTrimmed; + const originalMessage = draftTrimmed; + const originalPendingLargePastes = { ...pendingLargePastesRef.current }; + const message = expandPendingLargePastes(originalMessage).trim(); + const messageCharCount = getCharacterCount(message); if (message.toLowerCase().startsWith('/btw')) { // When idle, /btw can be sent via the normal send button. @@ -907,19 +998,37 @@ export const ChatInput: React.FC = ({ setSavedDraft(''); dispatchInput({ type: 'CLEAR_VALUE' }); + clearPendingLargePastes(); // Clear machine queue too; otherwise the queuedInput→input sync effect puts the text back after send. setQueuedInput(null); + if (messageCharCount > CHAT_INPUT_CONFIG.largePaste.maxMessageChars) { + notificationService.error( + t('input.messageTooLarge', { + max: CHAT_INPUT_CONFIG.largePaste.maxMessageChars, + count: messageCharCount, + defaultValue: 'Message exceeds the maximum length of {{max}} characters ({{count}} provided).', + }), + { duration: 4000 } + ); + pendingLargePastesRef.current = originalPendingLargePastes; + dispatchInput({ type: 'ACTIVATE' }); + dispatchInput({ type: 'SET_VALUE', payload: originalMessage }); + return; + } + try { await sendMessage(message); + clearPendingLargePastes(); dispatchInput({ type: 'CLEAR_VALUE' }); dispatchInput({ type: 'DEACTIVATE' }); } catch (error) { log.error('Failed to send message', { error }); + pendingLargePastesRef.current = originalPendingLargePastes; dispatchInput({ type: 'ACTIVATE' }); - dispatchInput({ type: 'SET_VALUE', payload: message }); + dispatchInput({ type: 'SET_VALUE', payload: originalMessage }); if (derivedState?.isProcessing) { - setQueuedInput(message); + setQueuedInput(originalMessage); } } }, [ @@ -929,6 +1038,8 @@ export const ChatInput: React.FC = ({ sendMessage, addToHistory, effectiveTargetSessionId, + clearPendingLargePastes, + expandPendingLargePastes, setQueuedInput, submitBtwFromInput, submitCompactFromInput, @@ -1668,6 +1779,7 @@ export const ChatInput: React.FC = ({ ref={richTextInputRef} value={inputState.value} onChange={handleInputChange} + onLargePaste={createLargePastePlaceholder} onKeyDown={handleKeyDown} onCompositionStart={handleImeCompositionStart} onCompositionEnd={handleImeCompositionEnd} diff --git a/src/web-ui/src/flow_chat/components/RichTextInput.tsx b/src/web-ui/src/flow_chat/components/RichTextInput.tsx index d7797ab4..fb5923cf 100644 --- a/src/web-ui/src/flow_chat/components/RichTextInput.tsx +++ b/src/web-ui/src/flow_chat/components/RichTextInput.tsx @@ -17,6 +17,7 @@ export interface MentionState { export interface RichTextInputProps { value: string; onChange: (value: string, contexts: ContextItem[]) => void; + onLargePaste?: (text: string) => string | null; onKeyDown?: (e: React.KeyboardEvent) => void; onCompositionStart?: () => void; onCompositionEnd?: () => void; @@ -34,6 +35,7 @@ export interface RichTextInputProps { export const RichTextInput = React.forwardRef(({ value, onChange, + onLargePaste, onKeyDown, onCompositionStart, onCompositionEnd, @@ -446,14 +448,15 @@ export const RichTextInput = React.forwardRef { isComposingRef.current = false; }); - }, [onMentionStateChange]); + }, [onLargePaste, onMentionStateChange]); const handleKeyDown = useCallback((e: React.KeyboardEvent) => { const nativeIsComposing = (e.nativeEvent as KeyboardEvent).isComposing; diff --git a/src/web-ui/src/flow_chat/constants/chatInputConfig.ts b/src/web-ui/src/flow_chat/constants/chatInputConfig.ts index d9286e73..02df943e 100644 --- a/src/web-ui/src/flow_chat/constants/chatInputConfig.ts +++ b/src/web-ui/src/flow_chat/constants/chatInputConfig.ts @@ -3,6 +3,11 @@ */ export const CHAT_INPUT_CONFIG = { + largePaste: { + thresholdChars: 1000, + maxMessageChars: 1 << 20, + }, + // Image input constraints. image: { maxCount: 5, diff --git a/src/web-ui/src/locales/en-US/flow-chat.json b/src/web-ui/src/locales/en-US/flow-chat.json index 54e2e8e8..f32f5fe3 100644 --- a/src/web-ui/src/locales/en-US/flow-chat.json +++ b/src/web-ui/src/locales/en-US/flow-chat.json @@ -126,6 +126,8 @@ "imageAddedSuccess": "Successfully added {{count}} images", "imageAddedSingle": "Added clipboard image: {{name}}", "imagePasteFailed": "Image paste failed", + "largePastePlaceholder": "[Pasted Content {{count}} chars]", + "messageTooLarge": "Message exceeds the maximum length of {{max}} characters ({{count}} provided).", "openWorkspaceFolder": "Open workspace folder", "openWorkspaceFolderFailed": "Failed to open workspace folder: {{error}}", "spaceToActivate": "Press Space to type" diff --git a/src/web-ui/src/locales/zh-CN/flow-chat.json b/src/web-ui/src/locales/zh-CN/flow-chat.json index 7f6e2190..efc1c558 100644 --- a/src/web-ui/src/locales/zh-CN/flow-chat.json +++ b/src/web-ui/src/locales/zh-CN/flow-chat.json @@ -126,6 +126,8 @@ "imageAddedSuccess": "成功添加 {{count}} 张图片", "imageAddedSingle": "已添加剪贴板图片: {{name}}", "imagePasteFailed": "图片粘贴失败", + "largePastePlaceholder": "[已粘贴内容 {{count}} 字符]", + "messageTooLarge": "消息超过最大长度 {{max}} 个字符(当前 {{count}} 个字符)。", "openWorkspaceFolder": "打开工作区文件夹", "openWorkspaceFolderFailed": "打开工作区文件夹失败:{{error}}", "spaceToActivate": "按空格键快速键入"