From 6308e4b3975b9de0d29c5f35f762c39197b7aede Mon Sep 17 00:00:00 2001 From: Vinh Nguyen Date: Sat, 28 Feb 2026 19:47:29 +0700 Subject: [PATCH 01/23] chore: update homebrew formula to 0.84.1 --- homebrew/vtcode.rb | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/homebrew/vtcode.rb b/homebrew/vtcode.rb index d75634a50..5b7d337cc 100644 --- a/homebrew/vtcode.rb +++ b/homebrew/vtcode.rb @@ -2,15 +2,15 @@ class Vtcode < Formula desc "Rust-based terminal coding agent with semantic code intelligence" homepage "https://github.com/vinhnx/vtcode" license "MIT" - version "0.84.0" + version "0.84.1" on_macos do if Hardware::CPU.arm? url "https://github.com/vinhnx/vtcode/releases/download/#{version}/vtcode-#{version}-aarch64-apple-darwin.tar.gz" - sha256 "f2c8b71d4825c35620fcc8977fd762d196733bd4eb9c0ffb8bf29a49ee3c0e0f" + sha256 "ebeecfe7b524a9150bfc55be91b9c25ac8abd37d586b8b947f1db921c8ab7b74" else url "https://github.com/vinhnx/vtcode/releases/download/#{version}/vtcode-#{version}-x86_64-apple-darwin.tar.gz" - sha256 "b5dd7383de7b304dcf5401c4dac161c4018626b3931e399e9fd7d7ba6b640017" + sha256 "38809948ce1183561bcad5348aa080a618c8b2634bcfcbc1678d39289e6f8782" end end From f1ef0a2c98726be25da1533ee452e61ef996b113 Mon Sep 17 00:00:00 2001 From: Vinh Nguyen Date: Sat, 28 Feb 2026 19:55:47 +0700 Subject: [PATCH 02/23] fix: update contributor handling in changelog template --- cliff.toml | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/cliff.toml b/cliff.toml index a00cd8ef0..09134721d 100644 --- a/cliff.toml +++ b/cliff.toml @@ -42,9 +42,12 @@ body = """ {% endfor %}\ ### Contributors -{% for contributor in commits | map(attribute="remote.username") | unique %}\ -{% if contributor %}@{{ contributor }} {% endif %}\ -{% endfor %}\ +{% set contributors = commits | map(attribute="remote.username") | unique | filter %}\ +{% if contributors | length > 0 %}\ +{% for contributor in contributors %}@{{ contributor }} {% endfor %}\ +{% else %}\ +@vinhnx +{% endif %}\ """ footer = "" From 3edcf204e58a4ee243786a5803a2ce1014b65360 Mon Sep 17 00:00:00 2001 From: Vinh Nguyen Date: Sat, 28 Feb 2026 21:45:13 +0700 Subject: [PATCH 03/23] Refactor diff rendering and styling for consistency and accessibility --- Cargo.lock | 1 + Cargo.toml | 1 + docs/guides/lifecycle-hooks.md | 5 +- docs/project/TODO.md | 319 +++++++++++++ src/agent/runloop/tool_output/files.rs | 157 ++---- src/agent/runloop/tool_output/files_diff.rs | 352 +------------- src/agent/runloop/tool_output/files_tests.rs | 79 ++-- src/agent/runloop/tool_output/mod.rs | 12 +- src/agent/runloop/tool_output/streams.rs | 235 +++++---- .../runloop/tool_output/streams_helpers.rs | 30 +- src/agent/runloop/tool_output/styles.rs | 134 ++---- .../unified/tool_pipeline/pty_stream.rs | 2 +- .../unified/tool_routing/permission_prompt.rs | 130 ++++- .../runloop/unified/tool_summary_helpers.rs | 12 +- src/agent/runloop/unified/turn/context.rs | 10 +- vtcode-commons/src/diff_paths.rs | 296 ++++++++++++ vtcode-commons/src/lib.rs | 1 + vtcode-commons/src/styling.rs | 170 ++++++- vtcode-core/src/tools/handlers/read_file.rs | 55 ++- vtcode-core/src/tools/registry/executors.rs | 11 +- vtcode-core/src/ui/diff_renderer.rs | 53 +-- vtcode-core/src/ui/git_config.rs | 28 +- vtcode-core/src/utils/ansi.rs | 68 ++- vtcode-core/src/utils/diff_styles.rs | 5 +- .../src/core_tui/session/diff_preview.rs | 12 +- .../src/core_tui/session/reflow/blocks.rs | 5 +- vtcode-tui/src/ui/markdown.rs | 128 +---- vtcode-tui/src/utils/diff_styles.rs | 445 ++++++------------ vtcode.toml | 2 +- 29 files changed, 1468 insertions(+), 1290 deletions(-) create mode 100644 vtcode-commons/src/diff_paths.rs diff --git a/Cargo.lock b/Cargo.lock index ef2ece0af..f6de2080e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6124,6 +6124,7 @@ dependencies = [ "tracing-subscriber", "url", "vtcode-acp-client", + "vtcode-commons", "vtcode-config", "vtcode-core", "vtcode-tui", diff --git a/Cargo.toml b/Cargo.toml index 265f0d468..63892a9e1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -122,6 +122,7 @@ unwrap_used = "deny" [dependencies] vtcode-acp-client = { path = "vtcode-acp-client", version = "0.84.1" } +vtcode-commons = { path = "vtcode-commons", version = "0.84.1" } vtcode-config = { path = "vtcode-config", version = "0.84.1" } vtcode-core = { path = "vtcode-core", version = "0.84.1", default-features = false } vtcode-tui = { path = "vtcode-tui", version = "0.84.1" } diff --git a/docs/guides/lifecycle-hooks.md b/docs/guides/lifecycle-hooks.md index 1c696613a..b9d65175e 100644 --- a/docs/guides/lifecycle-hooks.md +++ b/docs/guides/lifecycle-hooks.md @@ -218,8 +218,9 @@ Hooks can append strings to the model context in two ways: 2. Provide `hookSpecificOutput.additionalContext` as a JSON array or string in the JSON response. -Messages emitted via stderr or interpreted fields are surfaced inside VT Code's -UI so you can monitor hook activity. +Messages emitted via stderr or interpreted fields are captured for diagnostics. +Fatal/error diagnostics are written to tracing logs for debugging and are not +rendered in the TUI transcript. ## Best Practices diff --git a/docs/project/TODO.md b/docs/project/TODO.md index d328854a3..5ee8c40d3 100644 --- a/docs/project/TODO.md +++ b/docs/project/TODO.md @@ -198,3 +198,322 @@ improve agent's postamlbe summarization. reference and also suggest next actions ``` the goal is to provide users with a clear and concise summary of the agent's actions, including any relevant metrics such as API usage, time spent, and code changes. Additionally, the summary should suggest next steps for the user to take based on the outcome of the agent's actions. This could include options to resume the session, view detailed logs, or take specific actions based on the results of the agent's work. The postamble summarization should be designed to help users quickly understand the results of their interactions with the agent and guide them towards meaningful next steps. The tone should be informative and actionable, providing users with the information they need to make informed decisions about how to proceed after the agent has completed its tasks. Not too verbose, but comprehensive enough to cover the key outcomes and next steps. Not too blunt, but clear and concise. + +--- + +check Tool-call budget exhausted for this turn (32/32).│ +│ Start a new turn with "continue" to proceed. + +``` +{ + "metadata": { + "workspace_label": "vtcode", + "workspace_path": "/Users/vinhnguyenxuan/Developer/learn-by-doing/vtcode", + "model": "glm-5:cloud", + "provider": "ollama", + "theme": "ciapre-blue", + "reasoning_effort": "low", + "loaded_skills": [] + }, + "started_at": "2026-02-28T13:41:53.430225Z", + "ended_at": "2026-02-28T13:49:12.870328Z", + "total_messages": 10, + "distinct_tools": [ + "run_pty_cmd", + "unified_file", + "unified_search" + ], + "transcript": [ + "run cargo clippy and fix", + " Running cargo clippy to identify issues.", + " ✓ run completed (exit code: 0)", + " Found 3 clippy warnings. Let me fix them.", + "• Read file src/agent/runloop/tool_output/styles.rs", + " └ Offset lines: 165", + " └ Limit: 35", + " if is_diff_addition_line(trimmed) {", + " return git.add;", + " }", + " if is_diff_deletion_line(trimmed) {", + "• Read file src/agent/runloop/tool_output/styles.rs", + " use std::collections::HashMap;", + " use anstyle::{AnsiColor, Color, Effects, Style as AnsiStyle};", + " use vtcode_commons::diff_paths::{", + " is_diff_addition_line, is_diff_deletion_line, is_diff_header_line,", + "• Read file src/agent/runloop/tool_output/styles.rs", + " └ Limit: 50", + " use std::collections::HashMap;", + " use anstyle::{AnsiColor, Color, Effects, Style as AnsiStyle};", + " use vtcode_commons::diff_paths::{", + " is_diff_addition_line, is_diff_deletion_line, is_diff_header_line,", + "• Read file src/agent/runloop/tool_output/styles.rs", + " └ Offset lines: 1", + " └ Limit: 30", + " use std::collections::HashMap;", + " use anstyle::{AnsiColor, Color, Effects, Style as AnsiStyle};", + " use vtcode_commons::diff_paths::{", + " is_diff_addition_line, is_diff_deletion_line, is_diff_header_line,", + "❋ Editing src/agent/runloop/tool_output/styles.rs...", + "• Edit file src/agent/runloop/tool_output/styles.rs", + " └ Action: edit", + " --- a/src/agent/runloop/tool_output/styles.rs", + " +++ b/src/agent/runloop/tool_output/styles.rs", + " @@ -1,11 +1,8 @@", + " use std::collections::HashMap; ", + " use anstyle::{AnsiColor, Color, Effects, Style as AnsiStyle}; ", + " -use vtcode_commons::diff_paths::{ ", + " - is_diff_addition_line, is_diff_deletion_line, is_diff_header_line, ", + " -}; ", + " -use vtcode_core::config::constants::tools; ", + " -use vtcode_core::utils::diff_styles::{DiffColorLevel, DiffTheme, diff_add_bg, diff_del_bg}; ", + " +use vtcode_commons::diff_paths::{is_diff_addition_line, is_diff_deletion_line}; ", + " +use vtcode_core::utils::diff_styles::{DiffTheme, diff_add_bg, diff_del_bg}; ", + " use vtcode_core::utils::style_helpers::bold_color; ", + " /// Get background color for diff lines based on detected theme and color level. ", + " ✗ run error, exit code: 101", + " Let me read the files that need fixes.", + "• Read file src/agent/runloop/tool_output/styles.rs", + " └ Limit: 50", + " use std::collections::HashMap;", + " use anstyle::{AnsiColor, Color, Effects, Style as AnsiStyle};", + " use vtcode_commons::diff_paths::{is_diff_addition_line, is_diff_deletion_line};", + " use vtcode_core::utils::diff_styles::{DiffTheme, diff_add_bg, diff_del_bg};", + "• Read file src/agent/runloop/tool_output/styles.rs", + " └ Offset lines: 1", + " └ Limit: 50", + " use std::collections::HashMap;", + " use anstyle::{AnsiColor, Color, Effects, Style as AnsiStyle};", + " use vtcode_commons::diff_paths::{is_diff_addition_line, is_diff_deletion_line};", + " use vtcode_core::utils::diff_styles::{DiffTheme, diff_add_bg, diff_del_bg};", + "• Read file src/agent/runloop/tool_output/styles.rs", + " └ Limit: 50", + " use std::collections::HashMap;", + " use anstyle::{AnsiColor, Color, Effects, Style as AnsiStyle};", + " use vtcode_commons::diff_paths::{is_diff_addition_line, is_diff_deletion_line};", + " use vtcode_core::utils::diff_styles::{DiffTheme, diff_add_bg, diff_del_bg};", + "• Read file src/agent/runloop/tool_output/styles.rs", + " └ Offset: 30", + " └ Limit: 15", + " let remove_effects = Effects::DIMMED;", + " Self {", + " add: Some(", + " AnsiStyle::new()", + "• Read file src/agent/runloop/tool_output/styles.rs", + " └ Offset: 1", + " └ Limit: 100", + " use std::collections::HashMap;", + " use anstyle::{AnsiColor, Color, Effects, Style as AnsiStyle};", + " use vtcode_commons::diff_paths::{is_diff_addition_line, is_diff_deletion_line};", + " use vtcode_core::utils::diff_styles::{DiffTheme, diff_add_bg, diff_del_bg};", + " Let me fix the unused imports directly.", + "❋ Editing src/agent/runloop/tool_output/styles.rs...", + "• Edit file src/agent/runloop/tool_output/styles.rs", + " --- a/src/agent/runloop/tool_output/styles.rs", + " +++ b/src/agent/runloop/tool_output/styles.rs", + " @@ -1,6 +1,4 @@", + " -use std::collections::HashMap; ", + " - ", + " -use anstyle::{AnsiColor, Color, Effects, Style as AnsiStyle}; ", + " +use anstyle::Color; ", + " use vtcode_commons::diff_paths::{is_diff_addition_line, is_diff_deletion_line}; ", + " use vtcode_core::utils::diff_styles::{DiffTheme, diff_add_bg, diff_del_bg}; ", + " use vtcode_core::utils::style_helpers::bold_color; ", + "Reached maximum tool loops (20)", + "Tool loop limit increased to 60 (+40, cap 60)", + "• Read file src/agent/runloop/tool_output/styles.rs", + " └ Limit: 20", + " use anstyle::Color;", + " use vtcode_commons::diff_paths::{is_diff_addition_line, is_diff_deletion_line};", + " use vtcode_core::utils::diff_styles::{DiffTheme, diff_add_bg, diff_del_bg};", + " use vtcode_core::utils::style_helpers::bold_color;", + "❋ Editing src/agent/runloop/tool_output/styles.rs...", + "• Edit file src/agent/runloop/tool_output/styles.rs", + " --- a/src/agent/runloop/tool_output/styles.rs", + " +++ b/src/agent/runloop/tool_output/styles.rs", + " @@ -1,5 +1,4 @@", + " use anstyle::Color; ", + " -use vtcode_commons::diff_paths::{is_diff_addition_line, is_diff_deletion_line}; ", + " use vtcode_core::utils::diff_styles::{DiffTheme, diff_add_bg, diff_del_bg}; ", + " use vtcode_core::utils::style_helpers::bold_color; ", + " The first issue was already fixed. Now fixing the unused imports in vtcode-core/src/subagents/mod.rs.", + "• Read file vtcode-core/src/subagents/mod.rs", + " └ Limit: 30", + " //! Subagent system for VT Code", + " //!", + " //! Provides specialized AI subagents that can be delegated tasks with:", + " //! - Isolated context (separate conversation)", + "• Read file vtcode-core/src/subagents/mod.rs", + " └ Limit: 50", + " //! Subagent system for VT Code", + " //!", + " //! Provides specialized AI subagents that can be delegated tasks with:", + " //! - Isolated context (separate conversation)", + "❋ Editing vtcode-core/src/subagents/mod.rs...", + "• Read file vtcode-core/src/subagents/mod.rs", + " └ Limit: 30", + " //! Subagent system for VT Code", + " //!", + " //! Provides specialized AI subagents that can be delegated tasks with:", + " //! - Isolated context (separate conversation)", + "• Read file vtcode-core/src/subagents/mod.rs", + " └ Action: read", + " └ Limit: 50", + " //! Subagent system for VT Code", + " //!", + " //! Provides specialized AI subagents that can be delegated tasks with:", + " //! - Isolated context (separate conversation)", + " ✗ run error, exit code: 101", + "• Read file vtcode-core/src/subagents/mod.rs", + " //! Subagent system for VT Code", + " //!", + " //! Provides specialized AI subagents that can be delegated tasks with:", + " //! - Isolated context (separate conversation)", + "• Read file vtcode-core/src/subagents/mod.rs", + " └ Offset lines: 1", + " └ Limit: 50", + " //! Subagent system for VT Code", + " //!", + " //! Provides specialized AI subagents that can be delegated tasks with:", + " //! - Isolated context (separate conversation)", + " Let me use grep to find the unused imports.", + "• Search text Use Unified search", + " └ Pattern: ^use ", + " └ Path: vtcode-core/src/subagents/mod.rs", + " └ Context lines: 0", + " ✗ run error, exit code: 101", + "Tool-call budget exhausted for this turn (32/32). Start a new turn with \"continue\" to proceed.", + "show diff on src/agent/runloop/tool_output/files.rs", + "LLM request failed: Network error: Ollama Network error: error sending request for url (https://ollama.com/api/chat)", + "Interrupt received. Stopping task... (Press Ctrl+C again within 2s to exit)" + ], + "messages": [ + { + "role": "User", + "content": "run cargo clippy and fix", + "tool_call_id": null + }, + { + "role": "Assistant", + "content": "Running `cargo clippy` to identify issues.", + "tool_call_id": null + }, + { + "role": "Tool", + "content": "{\"error\":\"Policy violation: exceeded max tool calls per turn (32)\",\"failure_kind\":\"policy\",\"error_class\":\"policy_blocked\",\"is_recoverable\":false,\"next_action\":\"Switch to an allowed tool or mode.\"}", + "tool_call_id": "tool_call_0" + }, + { + "role": "Assistant", + "content": "Found 3 clippy warnings. Let me fix them.", + "tool_call_id": null + }, + { + "role": "System", + "content": "Loop detector blocked repeated read-only calls. Use `grep_file` or adjust `offset`/`limit` before retrying.", + "tool_call_id": null + }, + { + "role": "Assistant", + "content": "The first issue was already fixed. Now fixing the unused imports in `vtcode-core/src/subagents/mod.rs`.", + "tool_call_id": null + }, + { + "role": "System", + "content": "Tool-call budget warning: 24/32 used; 8 remaining for this turn. Use targeted extraction/batching before additional tool calls.", + "tool_call_id": null + }, + { + "role": "Assistant", + "content": "Let me use grep to find the unused imports.", + "tool_call_id": null + }, + { + "role": "System", + "content": "Tool-call budget exhausted for this turn (32/32). Start a new turn with \"continue\" to proceed.", + "tool_call_id": null + }, + { + "role": "User", + "content": "show diff on src/agent/runloop/tool_output/files.rs", + "tool_call_id": null + } + ] +} +``` + +--- + +fix repeated tool calls for agent. + +``` +{ + "metadata": { + "workspace_label": "vtcode", + "workspace_path": "/Users/vinhnguyenxuan/Developer/learn-by-doing/vtcode", + "model": "glm-5:cloud", + "provider": "ollama", + "theme": "ciapre-blue", + "reasoning_effort": "low", + "loaded_skills": [] + }, + "started_at": "2026-02-28T13:57:18.817399Z", + "ended_at": "2026-02-28T14:06:22.120996Z", + "total_messages": 5, + "distinct_tools": [ + "run_pty_cmd" + ], + "transcript": [ + "run cargo check and fix issues", + " Running cargo check to identify issues.", + " ✓ run completed (exit code: 0)", + " cargo check passed with no issues. Build completed successfully.", + "Worked for 1m 19s", + "run cargo clippy and fix small isues", + " ✓ run completed (exit code: 0)", + " Running cargo clippy --fix to auto-fix issues, then I'll handle remaining ones manually.", + " ✓ run completed (exit code: 0)", + "LLM request failed, retrying in 0.5s... (attempt 2/3)", + " ✓ run completed (exit code: 0)", + "Tool 'Run command (PTY)' execution cancelled", + "Interrupted current task. Press Ctrl+C again to exit." + ], + "messages": [ + { + "role": "User", + "content": "run cargo check and fix issues", + "tool_call_id": null + }, + { + "role": "Assistant", + "content": "Running `cargo check` to identify issues.", + "tool_call_id": null + }, + { + "role": "Tool", + "content": "{\"output\":\" Compiling ring v0.17.14\\n Building [======================> ] 766/806: ring(build) \\n Building [======================> ] 766/806: ring(build) \\n Compiling rustls v0.23.35\\n Compiling vtcode-config v0.84.1 (/Users/vinhnguyenxuan/Developer/learn-by-doing/vtcode/vtcode-config)\\n Building [======================> ] 767/806: ring, ring, vtcode-config(b…\\n Building [======================> ] 768/806: ring, ring, vtcode-config(b…\\n Building [======================> ] 769/806: ring, ring, vtcode-config(b…\\n Building [======================> ] 770/806: ring, ring \\n Building [======================> ] 767/806: ring, ring, vtcode-config(b…\\n Building [======================> ] 768/806: ring, ring, vtcode-config(b…\\n Building [======================> ] 769/806: ring, ring, vtcode-config(b…\\n Building [======================> ] 770/806: ring, ring \\n Checking rustls-webpki v0.103.8\\n Building [======================> ] 771/806: ring, rustls-webpki \\n Building [======================> ] 771/806: rustls-webpki, ring, rustls…\\n Building [======================> ] 772/806: rustls-webpki, ring, rustls \\n Building [======================> ] 772/806: rustls-webpki, ring, rustls…\\n Building [======================> ] 773/806: rustls-webpki, rustls, rust…\\n Building [=======================> ] 774/806: rustls, rustls \\n Building [======================> ] 771/806: ring, rustls-webpki \\n Building [======================> ] 771/806: rustls-webpki, ring, rustls…\\n Building [======================> ] 772/806: rustls-webpki, ring, rustls \\n Building [======================> ] 772/806: rustls-webpki, ring, rustls…\\n Building [======================> ] 773/806: rustls-webpki, rustls, rust…\\n Building [=======================> ] 774/806: rustls, rustls \\n Checking tokio-rustls v0.26.4\\n Checking rustls-platform-verifier v0.6.2\\n Checking tungstenite v0.28.0\\n Building [=======================> ] 775/806: tokio-rustls, tungstenite, …\\n Building [=======================> ] 776/806: tokio-rustls, tungstenite, …\\n Building [=======================> ] 775/806: tokio-rustls, tungstenite, …\\n Building [=======================> ] 776/806: tokio-rustls, tungstenite, …\\n Checking hyper-rustls v0.27.7\\n Building [=======================> ] 777/806: hyper-rustls, tungstenite, …\\n Building [=======================> ] 777/806: hyper-rustls, tungstenite, …\\n Checking reqwest v0.12.28\\n Checking reqwest v0.13.1\\n Building [=======================> ] 778/806: reqwest, reqwest, tungsteni…\\n Checking tokio-tungstenite v0.28.0\\n Building [=======================> ] 779/806: reqwest, reqwest, rustls, t…\\n Building [=======================> ] 780/806: reqwest, reqwest, rustls, t…\\n Building [=======================> ] 781/806: reqwest, reqwest, rustls, h…\\n Building [=======================> ] 779/806: reqwest, reqwest, rustls, t…\\n Building [=======================> ] 780/806: reqwest, reqwest, rustls, t…\\n Building [=======================> ] 781/806: reqwest, reqwest, rustls, h…\\n Checking vtcode-commons v0.84.1 (/Users/vinhnguyenxuan/Developer/learn-by-doing/vtcode/vtcode-commons)\\n Building [=======================> ] 782/806: reqwest, vtcode-commons, ru…\\n Building [=======================> ] 782/806: reqwest, vtcode-commons, ru…\\n Building [=======================> ] 783/806: reqwest, vtcode-commons, ru…\\n Checking jsonschema v0.40.2\\n Checking rmcp v0.14.0\\n Building [=======================> ] 783/806: reqwest, vtcode-commons, rm…\\n Checking rig-core v0.23.1\\n Building [=======================> ] 783/806: reqwest, rig-core, vtcode-c…\\n Checking vtcode-acp-client v0.84.1 (/Users/vinhnguyenxuan/Developer/learn-by-doing/vtcode/vtcode-acp-client)\\n Building [=======================> ] 784/806: reqwest, rig-core, vtcode-c…\\n Building [=======================> ] 784/806: reqwest, rig-core, vtcode-c…\\n Checking openai-harmony v0.0.8\\n Building [=======================> ] 784/806: openai-harmony, reqwest, ri…\\n Building [=======================> ] 784/806: openai-harmony, reqwest, ri…\\n Checking self_update v0.42.0\\n Building [=======================> ] 784/806: openai-harmony, self_update…\\n Building [=======================> ] 785/806: openai-harmony, reqwest, ri…\\n Building [=======================> ] 784/806: openai-harmony, self_update…\\n Building [=======================> ] 785/806: openai-harmony, reqwest, ri…\\n Building [=======================> ] 786/806: reqwest, rig-core, vtcode-c…\\n Checking vtcode-bash-runner v0.84.1 (/Users/vinhnguyenxuan/Developer/learn-by-doing/vtcode/vtcode-bash-runner)\\n Building [=======================> ] 787/806: vtcode-bash-runner, reqwest…\\n Building [=======================> ] 787/806: vtcode-bash-runner, reqwest…\\n Checking vtcode-exec-events v0.84.1 (/Users/vinhnguyenxuan/Developer/learn-by-doing/vtcode/vtcode-exec-events)\\n Building [=======================> ] 788/806: reqwest, rig-core, rmcp, ru…\\n Building [=======================> ] 789/806: vtcode-commons, reqwest, ri…\\n Building [=======================> ] 788/806: reqwest, rig-core, rmcp, ru…\\n Building [=======================> ] 789/806: vtcode-commons, reqwest, ri…\\n Checking vtcode-indexer v0.84.1 (/Users/vinhnguyenxuan/Developer/learn-by-doing/vtcode/vtcode-indexer)\\n Building [=======================> ] 790/806: vtcode-commons, reqwest, ri…\\n Building [=======================> ] 790/806: vtcode-commons, reqwest, ri…\\n Checking vtcode-tui v0.84.1 (/Users/vinhnguyenxuan/Developer/learn-by-doing/vtcode/vtcode-tui)\\n Building [=======================> ] 791/806: vtcode-commons, reqwest, ri…\\n Building [=======================> ] 791/806: vtcode-commons, reqwest, ri…\\n Checking vtcode-file-search v0.84.1 (/Users/vinhnguyenxuan/Developer/learn-by-doing/vtcode/vtcode-file-search)\\n Building [=======================> ] 792/806: vtcode-file-search, vtcode-…\\n Building [=======================> ] 793/806: vtcode-commons, vtcode-conf…\\n Building [=======================> ] 794/806: vtcode-commons, vtcode-conf…\\n Building [=======================> ] 795/806: vtcode-commons, vtcode-conf…\\n Building [=======================> ] 796/806: vtcode-commons, reqwest, ri…\\n Building [=======================> ] 797/806: vtcode-commons, reqwest, ri…\\n Building [=======================> ] 798/806: vtcode-commons, rig-core, v…\\n Building [=======================> ] 799/806: rig-core, vtcode-config \\n Building [=======================> ] 800/806: vtcode-config \\n Building [=======================> ] 792/806: vtcode-file-search, vtcode-…\\n Building [=======================> ] 793/806: vtcode-commons, vtcode-conf…\\n Building [=======================> ] 794/806: vtcode-commons, vtcode-conf…\\n Building [=======================> ] 795/806: vtcode-commons, vtcode-conf…\\n Building [=======================> ] 796/806: vtcode-commons, reqwest, ri…\\n Building [=======================> ] 797/806: vtcode-commons, reqwest, ri…\\n Building [=======================> ] 798/806: vtcode-commons, rig-core, v…\\n Building [=======================> ] 799/806: rig-core, vtcode-config \\n Building [=======================> ] 800/806: vtcode-config \\n Compiling vtcode-core v0.84.1 (/Users/vinhnguyenxuan/Developer/learn-by-doing/vtcode/vtcode-core)\\n Building [=======================> ] 801/806: vtcode-core(build.rs) \\n Building [=======================> ] 802/806: vtcode-core(build) \\n Building [=======================> ] 803/806: vtcode-core \\n Building [=======================> ] 801/806: vtcode-core(build.rs) \\n Building [=======================> ] 802/806: vtcode-core(build) \\n Building [=======================> ] 803/806: vtcode-core \\n Checking vtcode v0.84.1 (/Users/vinhnguyenxuan/Developer/learn-by-doing/vtcode)\\n Building [=======================> ] 804/806: vtcode \\n Building [=======================> ] 805/806: vtcode(bin) \\n Building [=======================> ] 804/806: vtcode \\n Building [=======================> ] 805/806: vtcode(bin) \\n Finished `dev` profile [unoptimized] target(s) in 39.06s\\n\",\"is_exited\":true,\"session_id\":\"run-5e4d7a90\",\"command\":\"cargo check\",\"working_directory\":\".\",\"exit_code\":0}", + "tool_call_id": "tool_call_0" + }, + { + "role": "Assistant", + "content": "`cargo check` passed with no issues. Build completed successfully.", + "tool_call_id": null + }, + { + "role": "User", + "content": "run cargo clippy and fix small isues", + "tool_call_id": null + } + ] +} +``` + +--- + +improve file brower selection -> user input, it should show @file alias but full path context for agent. the agent should not confusedd by the @file alias and should be able to understand the full path context. This will help the agent to accurately identify the file and perform the necessary actions on it. Additionally, the UI can display the @file alias for better readability and user experience, while still providing the full path context in the background for the agent's processing. + +--- + +show fatal and errors in tracing session log for +debug purpose. but don't show them in tui transcript. + +for example: notification can catch error, fatal, but in the final sessions log, it doesn't record (/Users/vinhnguyenxuan/.vtcode/sessions/session-vtcode-20260228T141549Z_228471-31761.json) any error or fatal, which makes it hard to debug when something goes wrong. We should make sure that any errors or fatals that occur during the session are properly logged in the session log for debugging purposes, even if they are not shown in the TUI transcript. This way, we can have a complete record of what happened during the session and can investigate any issues that arise more effectively. diff --git a/src/agent/runloop/tool_output/files.rs b/src/agent/runloop/tool_output/files.rs index 9dbbdd529..a6811009c 100644 --- a/src/agent/runloop/tool_output/files.rs +++ b/src/agent/runloop/tool_output/files.rs @@ -1,20 +1,17 @@ -use anstyle::{Effects, Style as AnsiStyle}; use anyhow::Result; use serde_json::Value; +use vtcode_commons::diff_paths::{language_hint_from_path, looks_like_diff_content}; +use vtcode_core::config::ToolOutputMode; use vtcode_core::config::constants::tools; use vtcode_core::utils::ansi::{AnsiRenderer, MessageStyle}; -use super::styles::{GitStyles, LsStyles, select_line_style}; +use super::streams::render_diff_content_block; +use super::styles::{GitStyles, LsStyles}; #[path = "files_diff.rs"] mod files_diff; -#[cfg(test)] -pub(super) use files_diff::format_diff_content_lines; -pub(super) use files_diff::{ - format_condensed_edit_diff_lines, format_diff_content_lines_with_numbers, -}; +pub(crate) use files_diff::format_diff_content_lines_with_numbers; /// Constants for line and content limits (compact display) -const MAX_DIFF_LINE_LENGTH: usize = 100; // Reduced for compact view const MAX_DISPLAYED_FILES: usize = 100; // Limit displayed files to reduce clutter /// Safely truncate text to a maximum character length, respecting UTF-8 boundaries @@ -86,7 +83,7 @@ pub(crate) fn render_write_file_preview( if !diff_content.is_empty() { renderer.line(MessageStyle::ToolDetail, "")?; - render_edit_diff_preview(renderer, diff_content, git_styles, ls_styles)?; + render_diff_content(renderer, diff_content, git_styles, ls_styles)?; } if get_bool(diff_value, "truncated") { @@ -334,7 +331,8 @@ pub(crate) fn render_read_file_output(renderer: &mut AnsiRenderer, val: &Value) renderer.line(MessageStyle::ToolDetail, "")?; render_diff_content(renderer, content, &git_styles, &ls_styles)?; } else { - render_content_preview(renderer, content)?; + let file_path = get_string(val, "file_path").or_else(|| get_string(val, "path")); + render_content_preview(renderer, content, file_path)?; } } @@ -342,8 +340,8 @@ pub(crate) fn render_read_file_output(renderer: &mut AnsiRenderer, val: &Value) } const MAX_BATCH_DISPLAY_FILES: usize = 10; -const MAX_PREVIEW_LINES: usize = 4; -const MAX_PREVIEW_LINE_LEN: usize = 80; +const MAX_PREVIEW_LINES: usize = 12; +const MAX_FULL_RENDER_LINES: usize = 80; fn shorten_path(path: &str, max_len: usize) -> String { if path.len() <= max_len { @@ -375,23 +373,39 @@ fn strip_line_number(line: &str) -> &str { line } -fn render_content_preview(renderer: &mut AnsiRenderer, content: &str) -> Result<()> { - let meaningful: Vec<&str> = content - .lines() - .map(strip_line_number) - .map(|l| l.trim()) - .filter(|l| !l.is_empty()) - .take(MAX_PREVIEW_LINES) - .collect(); - - if meaningful.is_empty() { +fn render_content_preview( + renderer: &mut AnsiRenderer, + content: &str, + file_path: Option<&str>, +) -> Result<()> { + let all_lines: Vec<&str> = content.lines().map(strip_line_number).collect(); + if all_lines.is_empty() || all_lines.iter().all(|line| line.trim().is_empty()) { return Ok(()); } + let show_all = all_lines.len() <= MAX_FULL_RENDER_LINES; + let display_lines: &[&str] = if show_all { + &all_lines + } else { + &all_lines[..MAX_PREVIEW_LINES.min(all_lines.len())] + }; renderer.line(MessageStyle::ToolDetail, "")?; - for line in &meaningful { - let display = truncate_text_safe(line, MAX_PREVIEW_LINE_LEN); - renderer.line(MessageStyle::ToolDetail, &format!(" {}", display))?; + + let language = file_path + .and_then(language_hint_from_path) + .unwrap_or_default(); + let mut markdown = format!("```{language}\n"); + for line in display_lines { + markdown.push_str(line); + markdown.push('\n'); + } + markdown.push_str("```"); + renderer.render_markdown_output(MessageStyle::ToolDetail, &markdown)?; + if !show_all { + renderer.line( + MessageStyle::ToolDetail, + &format!("… +{} more lines", all_lines.len() - display_lines.len()), + )?; } Ok(()) @@ -404,76 +418,16 @@ fn render_diff_content( git_styles: &GitStyles, ls_styles: &LsStyles, ) -> Result<()> { - let lines_to_render = format_diff_content_lines_with_numbers(diff_content); - render_diff_lines(renderer, &lines_to_render, git_styles, ls_styles) -} - -fn render_edit_diff_preview( - renderer: &mut AnsiRenderer, - diff_content: &str, - git_styles: &GitStyles, - ls_styles: &LsStyles, -) -> Result<()> { - let lines_to_render = format_condensed_edit_diff_lines(diff_content); - render_diff_lines(renderer, &lines_to_render, git_styles, ls_styles) -} - -fn render_diff_lines( - renderer: &mut AnsiRenderer, - lines_to_render: &[String], - git_styles: &GitStyles, - ls_styles: &LsStyles, -) -> Result<()> { - for line in lines_to_render { - let truncated = truncate_text_safe(&line, MAX_DIFF_LINE_LENGTH); - if let Some(style) = - select_line_style(Some(tools::WRITE_FILE), truncated, git_styles, ls_styles) - { - let display_line = format_diff_line_for_display(truncated, style); - renderer.line_with_override_style(MessageStyle::ToolDetail, style, &display_line)?; - } else { - renderer.line(MessageStyle::ToolDetail, truncated)?; - } - } - - Ok(()) -} - -fn format_diff_line_for_display(line: &str, style: AnsiStyle) -> String { - if let Some((gutter, content)) = split_diff_gutter(line) { - let dimmed = AnsiStyle::new().effects(Effects::DIMMED); - return format!("{dimmed}{gutter}{style}{content}"); - } - line.to_string() -} - -fn split_diff_gutter(line: &str) -> Option<(&str, &str)> { - let mut chars = line.char_indices(); - let (_, marker) = chars.next()?; - if !matches!(marker, '+' | '-' | ' ') { - return None; - } - - let rest = line.get(1..)?; - let first_digit = rest.find(|c: char| c.is_ascii_digit())?; - if first_digit == 0 || !rest[..first_digit].chars().all(|c| c == ' ') { - return None; - } - - let digits = &rest[first_digit..]; - let digits_len = digits.chars().take_while(|c| c.is_ascii_digit()).count(); - if digits_len == 0 { - return None; - } - - let after_digits = first_digit + digits_len; - let separator = rest[after_digits..].chars().next()?; - if separator != ' ' { - return None; - } - - let gutter_end = 1 + after_digits + separator.len_utf8(); - Some(line.split_at(gutter_end)) + render_diff_content_block( + renderer, + diff_content, + Some(tools::WRITE_FILE), + git_styles, + ls_styles, + MessageStyle::ToolDetail, + ToolOutputMode::Compact, + usize::MAX, + ) } pub(super) fn colorize_diff_summary_line(line: &str, _supports_color: bool) -> Option { @@ -491,19 +445,6 @@ pub(super) fn colorize_diff_summary_line(line: &str, _supports_color: bool) -> O } } -fn looks_like_diff_content(content: &str) -> bool { - content.lines().any(|line| { - let trimmed = line.trim_start(); - trimmed.starts_with("diff --") - || trimmed.starts_with("index ") - || trimmed.starts_with("@@") - || trimmed.starts_with("---") - || trimmed.starts_with("+++") - || trimmed.starts_with("new file mode") - || trimmed.starts_with("deleted file mode") - }) -} - #[cfg(test)] #[path = "files_tests.rs"] mod tests; diff --git a/src/agent/runloop/tool_output/files_diff.rs b/src/agent/runloop/tool_output/files_diff.rs index c8a5f1c0d..7c7efef5d 100644 --- a/src/agent/runloop/tool_output/files_diff.rs +++ b/src/agent/runloop/tool_output/files_diff.rs @@ -1,44 +1,6 @@ -const SMALL_CONTEXT_RADIUS: usize = 1; -const MAX_EDIT_PREVIEW_LINES: usize = 28; -const EDIT_PREVIEW_HEAD_LINES: usize = 18; -const EDIT_PREVIEW_TAIL_LINES: usize = 8; - -fn format_start_only_hunk_header(line: &str) -> Option { - let trimmed = line.trim_end(); - if !trimmed.starts_with("@@ -") { - return None; - } - - let rest = trimmed.strip_prefix("@@ -")?; - let mut parts = rest.split_whitespace(); - let old_part = parts.next()?; - let new_part = parts.next()?; - - if !new_part.starts_with('+') { - return None; - } - - let old_start = old_part.split(',').next()?.parse::().ok()?; - let new_start = new_part - .trim_start_matches('+') - .split(',') - .next()? - .parse::() - .ok()?; - - Some(format!("@@ -{} +{} @@", old_start, new_start)) -} - -pub(crate) fn format_diff_content_lines(diff_content: &str) -> Vec { - let mut lines: Vec = Vec::new(); - - for line in diff_content.lines() { - let rewritten = format_start_only_hunk_header(line).unwrap_or_else(|| line.to_string()); - lines.push(rewritten); - } - - lines -} +use vtcode_commons::diff_paths::{ + format_start_only_hunk_header, is_diff_addition_line, is_diff_deletion_line, parse_hunk_starts, +}; pub(crate) fn format_diff_content_lines_with_numbers(diff_content: &str) -> Vec { let mut lines = Vec::new(); @@ -51,7 +13,10 @@ pub(crate) fn format_diff_content_lines_with_numbers(diff_content: &str) -> Vec< old_line_no = old_start; new_line_no = new_start; in_hunk = true; - lines.push(format!("@@ -{} +{} @@", old_start, new_start)); + lines.push( + format_start_only_hunk_header(line) + .unwrap_or_else(|| format!("@@ -{} +{} @@", old_start, new_start)), + ); continue; } @@ -60,13 +25,13 @@ pub(crate) fn format_diff_content_lines_with_numbers(diff_content: &str) -> Vec< continue; } - if line.starts_with('+') && !line.starts_with("+++") { + if is_diff_addition_line(line) { lines.push(format!("+{:>5} {}", new_line_no, &line[1..])); new_line_no = new_line_no.saturating_add(1); continue; } - if line.starts_with('-') && !line.starts_with("---") { + if is_diff_deletion_line(line) { lines.push(format!("-{:>5} {}", old_line_no, &line[1..])); old_line_no = old_line_no.saturating_add(1); continue; @@ -85,302 +50,3 @@ pub(crate) fn format_diff_content_lines_with_numbers(diff_content: &str) -> Vec< lines } - -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -enum PreviewLineKind { - Context, - Addition, - Removal, -} - -#[derive(Clone, Debug)] -struct PreviewLine { - kind: PreviewLineKind, - line_number: usize, - text: String, -} - -#[derive(Clone, Debug, Default)] -struct ParsedFileDiff { - path: String, - additions: usize, - deletions: usize, - hunks: Vec>, -} - -fn parse_hunk_starts(line: &str) -> Option<(usize, usize)> { - let trimmed = line.trim_end(); - let rest = trimmed.strip_prefix("@@ -")?; - let mut parts = rest.split_whitespace(); - let old_part = parts.next()?; - let new_part = parts.next()?; - if !new_part.starts_with('+') { - return None; - } - - let old_start = old_part.split(',').next()?.parse::().ok()?; - let new_start = new_part - .trim_start_matches('+') - .split(',') - .next()? - .parse::() - .ok()?; - Some((old_start, new_start)) -} - -fn parse_git_diff_path(line: &str) -> Option { - let mut parts = line.split_whitespace(); - if parts.next()? != "diff" || parts.next()? != "--git" { - return None; - } - - let _old_path = parts.next()?; - let new_path = parts.next()?; - Some(new_path.trim_start_matches("b/").to_string()) -} - -fn parse_marker_path(line: &str) -> Option { - let marker = line.trim_start(); - if !marker.starts_with("+++ ") { - return None; - } - let path = marker - .strip_prefix("+++ ")? - .trim() - .trim_start_matches("b/") - .trim_start_matches("a/"); - if path == "/dev/null" || path.is_empty() { - return None; - } - Some(path.to_string()) -} - -fn format_preview_line(line: &PreviewLine) -> String { - match line.kind { - PreviewLineKind::Context => format!("{:>5} {}", line.line_number, line.text), - PreviewLineKind::Addition => format!("+{:>4} {}", line.line_number, line.text), - PreviewLineKind::Removal => format!("-{:>4} {}", line.line_number, line.text), - } -} - -fn condense_hunk_lines(lines: &[PreviewLine], context_radius: usize) -> Vec { - if lines.is_empty() { - return Vec::new(); - } - - let mut keep = vec![false; lines.len()]; - for (idx, line) in lines.iter().enumerate() { - if matches!( - line.kind, - PreviewLineKind::Addition | PreviewLineKind::Removal - ) { - let start = idx.saturating_sub(context_radius); - let end = (idx + context_radius).min(lines.len().saturating_sub(1)); - for flag in keep.iter_mut().take(end + 1).skip(start) { - *flag = true; - } - } - } - - if !keep.iter().any(|k| *k) { - return Vec::new(); - } - - let mut condensed = Vec::new(); - let mut last_kept: Option = None; - for (idx, line) in lines.iter().enumerate() { - if !keep[idx] { - continue; - } - - if let Some(previous) = last_kept - && idx > previous + 1 - { - condensed.push("⋮".to_string()); - } - - condensed.push(format_preview_line(line)); - last_kept = Some(idx); - } - - condensed -} - -fn truncate_edit_preview_lines(lines: Vec) -> Vec { - if lines.len() <= MAX_EDIT_PREVIEW_LINES { - return lines; - } - - let head_count = EDIT_PREVIEW_HEAD_LINES.min(lines.len()); - let tail_count = EDIT_PREVIEW_TAIL_LINES.min(lines.len().saturating_sub(head_count)); - let omitted = lines.len().saturating_sub(head_count + tail_count); - - let mut output = Vec::with_capacity(head_count + tail_count + 1); - output.extend(lines[..head_count].iter().cloned()); - if omitted > 0 { - output.push(format!("⋮ +{} more lines", omitted)); - } - if tail_count > 0 { - output.extend(lines[lines.len() - tail_count..].iter().cloned()); - } - output -} - -fn parse_unified_diff_for_preview(diff_content: &str) -> Vec { - let mut files: Vec = Vec::new(); - let mut current: Option = None; - let mut current_hunk: Vec = Vec::new(); - let mut old_line_no = 0usize; - let mut new_line_no = 0usize; - let mut in_hunk = false; - - let flush_hunk = |current_hunk: &mut Vec, current: &mut Option| { - if current_hunk.is_empty() { - return; - } - if let Some(file) = current.as_mut() { - file.hunks.push(std::mem::take(current_hunk)); - } - }; - - let flush_file = |files: &mut Vec, current: &mut Option| { - if let Some(file) = current.take() - && (!file.hunks.is_empty() || file.additions > 0 || file.deletions > 0) - { - files.push(file); - } - }; - - for line in diff_content.lines() { - if let Some(path) = parse_git_diff_path(line) { - flush_hunk(&mut current_hunk, &mut current); - flush_file(&mut files, &mut current); - current = Some(ParsedFileDiff { - path, - ..ParsedFileDiff::default() - }); - in_hunk = false; - continue; - } - - if let Some(path) = parse_marker_path(line) { - if current.is_none() { - current = Some(ParsedFileDiff { - path, - ..ParsedFileDiff::default() - }); - } else if let Some(file) = current.as_mut() - && file.path.is_empty() - { - file.path = path; - } - } - - if let Some((old_start, new_start)) = parse_hunk_starts(line) { - if current.is_none() { - current = Some(ParsedFileDiff { - path: "file".to_string(), - ..ParsedFileDiff::default() - }); - } - flush_hunk(&mut current_hunk, &mut current); - in_hunk = true; - old_line_no = old_start; - new_line_no = new_start; - continue; - } - - if !in_hunk { - continue; - } - - if line.starts_with('+') && !line.starts_with("+++") { - if let Some(file) = current.as_mut() { - file.additions += 1; - } - current_hunk.push(PreviewLine { - kind: PreviewLineKind::Addition, - line_number: new_line_no, - text: line[1..].to_string(), - }); - new_line_no = new_line_no.saturating_add(1); - continue; - } - - if line.starts_with('-') && !line.starts_with("---") { - if let Some(file) = current.as_mut() { - file.deletions += 1; - } - current_hunk.push(PreviewLine { - kind: PreviewLineKind::Removal, - line_number: old_line_no, - text: line[1..].to_string(), - }); - old_line_no = old_line_no.saturating_add(1); - continue; - } - - if let Some(context_line) = line.strip_prefix(' ') { - let line_number = new_line_no; - current_hunk.push(PreviewLine { - kind: PreviewLineKind::Context, - line_number, - text: context_line.to_string(), - }); - old_line_no = old_line_no.saturating_add(1); - new_line_no = new_line_no.saturating_add(1); - continue; - } - } - - flush_hunk(&mut current_hunk, &mut current); - flush_file(&mut files, &mut current); - files -} - -pub(crate) fn format_condensed_edit_diff_lines(diff_content: &str) -> Vec { - let parsed_files = parse_unified_diff_for_preview(diff_content); - if parsed_files.is_empty() { - return format_diff_content_lines(diff_content); - } - - let mut output = Vec::new(); - for (file_index, file) in parsed_files.iter().enumerate() { - if file_index > 0 { - output.push(String::new()); - } - - let display_path = if file.path.is_empty() { - "file" - } else { - file.path.as_str() - }; - output.push(format!( - "• Edited {} (+{} -{})", - display_path, file.additions, file.deletions - )); - - let mut file_lines = Vec::new(); - for (hunk_index, hunk) in file.hunks.iter().enumerate() { - let condensed = condense_hunk_lines(hunk, SMALL_CONTEXT_RADIUS); - if condensed.is_empty() { - continue; - } - - if hunk_index > 0 && !file_lines.is_empty() { - file_lines.push("⋮".to_string()); - } - file_lines.extend(condensed); - } - - if file_lines.is_empty() { - continue; - } - - for line in truncate_edit_preview_lines(file_lines) { - output.push(format!(" {}", line)); - } - } - - output -} diff --git a/src/agent/runloop/tool_output/files_tests.rs b/src/agent/runloop/tool_output/files_tests.rs index 64e13c2b9..d9b9c5e01 100644 --- a/src/agent/runloop/tool_output/files_tests.rs +++ b/src/agent/runloop/tool_output/files_tests.rs @@ -1,5 +1,4 @@ use super::*; -use anstyle::Style as AnsiStyle; #[test] fn formats_unified_diff_with_hunk_headers() { @@ -12,7 +11,7 @@ index 0000000..1111111 100644 -old +new "; - let lines = format_diff_content_lines(diff); + let lines = format_diff_content_lines_with_numbers(diff); assert_eq!(lines[0], "diff --git a/file1.txt b/file1.txt"); assert!(lines.iter().any(|line| line == "@@ -1 +1 @@")); // No "• Diff" summary line generated @@ -28,7 +27,7 @@ fn formats_diff_without_git_header() { -before +after "; - let lines = format_diff_content_lines(diff); + let lines = format_diff_content_lines_with_numbers(diff); assert!(lines.iter().any(|line| line.starts_with("+++ "))); assert!(lines.iter().any(|line| line == "@@ -2 +2 @@")); // No "• Diff" summary line generated @@ -93,7 +92,7 @@ index 0000000..1111111 100644 let mut best_score = (0usize, 0u8); for block in blocks { "; - let lines = format_diff_content_lines(diff); + let lines = format_diff_content_lines_with_numbers(diff); // No "• Diff" summary line generated assert!(!lines.iter().any(|l| l.starts_with("• Diff "))); @@ -102,7 +101,7 @@ index 0000000..1111111 100644 } #[test] -fn formats_condensed_edit_preview_with_summary_and_small_context() { +fn edit_preview_uses_standard_numbered_diff_formatting() { let diff = "\ diff --git a/vtcode-config/src/loader/config.rs b/vtcode-config/src/loader/config.rs index 0000000..1111111 100644 @@ -119,26 +118,43 @@ index 0000000..1111111 100644 +tool_success = false "; - let lines = format_condensed_edit_diff_lines(diff); + let lines = format_diff_content_lines_with_numbers(diff); assert_eq!( lines[0], - "• Edited vtcode-config/src/loader/config.rs (+2 -2)" + "diff --git a/vtcode-config/src/loader/config.rs b/vtcode-config/src/loader/config.rs" + ); + assert!(lines.iter().any(|line| line == "@@ -536 +536 @@")); + assert!(lines.iter().any(|line| line == "@@ -545 +545 @@")); + assert!( + lines + .iter() + .any(|line| line.starts_with("- 537 suppress_when_focused = false")) + ); + assert!( + lines + .iter() + .any(|line| line.starts_with("+ 537 suppress_when_focused = true")) + ); + assert!( + lines + .iter() + .any(|line| line.starts_with("- 546 tool_success = true")) + ); + assert!( + lines + .iter() + .any(|line| line.starts_with("+ 546 tool_success = false")) ); - assert!(lines.iter().any(|line| line.contains("- 537"))); - assert!(lines.iter().any(|line| line.contains("+ 537"))); - assert!(lines.iter().any(|line| line.contains("- 546"))); - assert!(lines.iter().any(|line| line.contains("+ 546"))); - assert!(lines.iter().any(|line| line.contains("⋮"))); } #[test] -fn condensed_edit_preview_falls_back_for_non_diff() { - let lines = format_condensed_edit_diff_lines("plain text output"); +fn standard_diff_formatter_preserves_non_diff_lines() { + let lines = format_diff_content_lines_with_numbers("plain text output"); assert_eq!(lines, vec!["plain text output".to_string()]); } #[test] -fn condensed_edit_preview_handles_diff_without_diff_git_header() { +fn standard_diff_formatter_handles_diff_without_diff_git_header() { let diff = "\ --- a/file2.txt +++ b/file2.txt @@ -147,32 +163,9 @@ fn condensed_edit_preview_handles_diff_without_diff_git_header() { +after "; - let lines = format_condensed_edit_diff_lines(diff); - assert_eq!(lines[0], "• Edited file2.txt (+1 -1)"); - assert!(lines.iter().any(|line| line.contains("- 2"))); - assert!(lines.iter().any(|line| line.contains("+ 2"))); -} - -#[test] -fn split_diff_gutter_detects_numbered_lines() { - let (gutter, content) = split_diff_gutter("+ 10 new value").expect("expected gutter split"); - assert_eq!(gutter, "+ 10 "); - assert_eq!(content, "new value"); - - assert!(split_diff_gutter("+++ b/file.rs").is_none()); - assert!(split_diff_gutter("@@ -1 +1 @@").is_none()); -} - -#[test] -fn split_diff_gutter_preserves_indented_code_content() { - let (gutter, content) = split_diff_gutter("+ 10 line,").expect("expected gutter split"); - assert_eq!(gutter, "+ 10 "); - assert_eq!(content, " line,"); -} - -#[test] -fn format_diff_line_for_display_dims_numbered_gutter() { - let rendered = format_diff_line_for_display("- 120 old value", AnsiStyle::new()); - assert!(rendered.contains("\u{1b}[2m")); - assert!(rendered.ends_with("old value")); + let lines = format_diff_content_lines_with_numbers(diff); + assert_eq!(lines[0], "--- a/file2.txt"); + assert_eq!(lines[1], "+++ b/file2.txt"); + assert!(lines.iter().any(|line| line.starts_with("- 2 before"))); + assert!(lines.iter().any(|line| line.starts_with("+ 2 after"))); } diff --git a/src/agent/runloop/tool_output/mod.rs b/src/agent/runloop/tool_output/mod.rs index 256c95ca1..8fae7e0be 100644 --- a/src/agent/runloop/tool_output/mod.rs +++ b/src/agent/runloop/tool_output/mod.rs @@ -30,7 +30,10 @@ use vtcode_core::config::mcp::McpRendererProfile; use vtcode_core::utils::ansi::{AnsiRenderer, MessageStyle}; use commands::render_terminal_command_panel; -use files::{render_list_dir_output, render_read_file_output, render_write_file_preview}; +use files::{ + format_diff_content_lines_with_numbers, render_list_dir_output, render_read_file_output, + render_write_file_preview, +}; use mcp::{ render_context7_output, render_generic_output, render_sequential_output, resolve_renderer_profile, @@ -231,6 +234,10 @@ pub(crate) async fn render_tool_output( Ok(()) } +pub(crate) fn format_unified_diff_lines(diff_content: &str) -> Vec { + format_diff_content_lines_with_numbers(diff_content) +} + fn is_git_diff_payload(val: &Value) -> bool { val.get("content_type") .and_then(Value::as_str) @@ -592,7 +599,8 @@ mod tests { let transcript_dump = transcript::snapshot().join("\n"); assert!(transcript_dump.contains("diff --git a/src/lib.rs b/src/lib.rs")); - assert!(transcript_dump.contains("+new")); + assert!(transcript_dump.contains("@@ -1 +1 @@")); + assert!(transcript_dump.contains("new")); assert!( !transcript_dump.contains("└ "), "run-command preview prefix should not appear for git diff payload" diff --git a/src/agent/runloop/tool_output/streams.rs b/src/agent/runloop/tool_output/streams.rs index 2484c8d42..33c95989f 100644 --- a/src/agent/runloop/tool_output/streams.rs +++ b/src/agent/runloop/tool_output/streams.rs @@ -40,11 +40,13 @@ //! with a notification displayed to the client. use std::borrow::Cow; -use std::path::Path; use anstyle::{AnsiColor, Effects, Reset, Style as AnsiStyle}; use anyhow::Result; use smallvec::SmallVec; +use vtcode_commons::diff_paths::{ + language_hint_from_path, parse_diff_git_path, parse_diff_marker_path, +}; use vtcode_core::config::ToolOutputMode; use vtcode_core::config::loader::VTCodeConfig; use vtcode_core::ui::markdown; @@ -109,40 +111,6 @@ fn format_hidden_lines_summary(hidden: usize) -> String { } } -fn parse_diff_git_path(line: &str) -> Option { - let mut parts = line.split_whitespace(); - if parts.next()? != "diff" || parts.next()? != "--git" { - return None; - } - let _old = parts.next()?; - let new_path = parts.next()?; - Some(new_path.trim_start_matches("b/").to_string()) -} - -fn parse_diff_marker_path(line: &str) -> Option { - let trimmed = line.trim_start(); - if !(trimmed.starts_with("--- ") || trimmed.starts_with("+++ ")) { - return None; - } - let path = trimmed.split_whitespace().nth(1)?; - if path == "/dev/null" { - return None; - } - Some( - path.trim_start_matches("a/") - .trim_start_matches("b/") - .to_string(), - ) -} - -fn language_hint_from_path(path: &str) -> Option { - Path::new(path) - .extension() - .and_then(|ext| ext.to_str()) - .filter(|ext| !ext.is_empty()) - .map(|ext| ext.to_ascii_lowercase()) -} - fn split_numbered_diff_line(line: &str) -> Option<(char, &str, &str)> { let mut chars = line.char_indices(); let (_, marker) = chars.next()?; @@ -269,13 +237,11 @@ fn format_diff_line_with_gutter_and_syntax( out.push_str(&format!("{line_no:>line_number_width$} ")); if let Some(highlighted) = highlight_diff_content(content, language_hint, bg) { out.push_str(&highlighted); + } else if let Some(style) = base_style { + out.push_str(&style.render().to_string()); + out.push_str(content); } else { - if let Some(style) = base_style { - out.push_str(&style.render().to_string()); - out.push_str(content); - } else { - out.push_str(content); - } + out.push_str(content); } out.push_str(&reset.to_string()); out @@ -368,6 +334,97 @@ async fn render_run_command_preview( Ok(()) } +pub(crate) fn render_diff_content_block( + renderer: &mut AnsiRenderer, + diff_content: &str, + tool_name: Option<&str>, + git_styles: &GitStyles, + ls_styles: &LsStyles, + fallback_style: MessageStyle, + mode: ToolOutputMode, + tail_limit: usize, +) -> Result<()> { + let diff_lines = format_diff_content_lines_with_numbers(diff_content); + let total = diff_lines.len(); + let effective_limit = + if renderer.prefers_untruncated_output() || matches!(mode, ToolOutputMode::Full) { + tail_limit.max(1000) + } else { + tail_limit + }; + let (lines_slice, truncated) = if total > effective_limit { + let start = total.saturating_sub(effective_limit); + (&diff_lines[start..], total > effective_limit) + } else { + (&diff_lines[..], false) + }; + + if truncated { + let hidden = total.saturating_sub(lines_slice.len()); + if hidden > 0 { + renderer.line( + MessageStyle::ToolDetail, + &format!("[... {} lines truncated ...]", hidden), + )?; + } + } + + let mut display_buffer = String::with_capacity(256); + let mut current_language_hint: Option = None; + let line_number_width = numbered_diff_line_width(lines_slice); + for line in lines_slice { + if line.is_empty() { + continue; + } + if let Some(path) = parse_diff_git_path(line).or_else(|| parse_diff_marker_path(line)) { + current_language_hint = language_hint_from_path(&path); + } + display_buffer.clear(); + if line.len() > MAX_LINE_LENGTH { + let truncated = truncate_text_safe(line, MAX_LINE_LENGTH); + display_buffer.push_str(truncated); + display_buffer.push_str("..."); + } else { + display_buffer.push_str(line); + } + + if let Some(summary_line) = + colorize_diff_summary_line(&display_buffer, renderer.capabilities().supports_color()) + { + renderer.line_with_override_style( + fallback_style, + fallback_style.style(), + &summary_line, + )?; + continue; + } + + let line_style = select_line_style(tool_name, &display_buffer, git_styles, ls_styles); + let rendered = format_diff_line_with_gutter_and_syntax( + &display_buffer, + line_style, + current_language_hint.as_deref(), + line_number_width, + ); + if rendered != display_buffer { + let style = line_style.unwrap_or(fallback_style.style()); + renderer.line_with_override_style(fallback_style, style, &rendered)?; + continue; + } + if let Some(style) = line_style { + renderer.line_with_override_style(fallback_style, style, &display_buffer)?; + } else { + renderer.line_with_override_style( + fallback_style, + fallback_style.style(), + &display_buffer, + )?; + } + } + + Ok(()) +} + #[cfg_attr( feature = "profiling", tracing::instrument( @@ -538,92 +595,16 @@ pub(crate) async fn render_stream_section( } if is_diff_content { - let diff_lines = format_diff_content_lines_with_numbers(stripped_for_diff.as_ref()); - let total = diff_lines.len(); - let effective_limit = - if renderer.prefers_untruncated_output() || matches!(mode, ToolOutputMode::Full) { - tail_limit.max(1000) - } else { - tail_limit - }; - let (lines_slice, truncated) = if total > effective_limit { - let start = total.saturating_sub(effective_limit); - (&diff_lines[start..], total > effective_limit) - } else { - (&diff_lines[..], false) - }; - - if truncated { - let hidden = total.saturating_sub(lines_slice.len()); - if hidden > 0 { - if is_run_command { - renderer.line( - MessageStyle::ToolDetail, - &format_hidden_lines_summary(hidden), - )?; - } else { - renderer.line( - MessageStyle::ToolDetail, - &format!("[... {} lines truncated ...]", hidden), - )?; - } - } - } - - let mut display_buffer = String::with_capacity(256); - let mut current_language_hint: Option = None; - let line_number_width = numbered_diff_line_width(lines_slice); - for line in lines_slice { - if line.is_empty() { - continue; - } - if let Some(path) = parse_diff_git_path(line).or_else(|| parse_diff_marker_path(line)) { - current_language_hint = language_hint_from_path(&path); - } - display_buffer.clear(); - if line.len() > MAX_LINE_LENGTH { - let truncated = truncate_text_safe(line, MAX_LINE_LENGTH); - display_buffer.push_str(truncated); - display_buffer.push_str("..."); - } else { - display_buffer.push_str(line); - } - - if let Some(summary_line) = colorize_diff_summary_line( - &display_buffer, - renderer.capabilities().supports_color(), - ) { - renderer.line_with_override_style( - fallback_style, - fallback_style.style(), - &summary_line, - )?; - continue; - } - - let line_style = select_line_style(tool_name, &display_buffer, git_styles, ls_styles); - let rendered = format_diff_line_with_gutter_and_syntax( - &display_buffer, - line_style, - current_language_hint.as_deref(), - line_number_width, - ); - if rendered != display_buffer { - let style = line_style.unwrap_or(fallback_style.style()); - renderer.line_with_override_style(fallback_style, style, &rendered)?; - continue; - } - if let Some(style) = line_style { - renderer.line_with_override_style(fallback_style, style, &display_buffer)?; - } else { - renderer.line_with_override_style( - fallback_style, - fallback_style.style(), - &display_buffer, - )?; - } - } - + render_diff_content_block( + renderer, + stripped_for_diff.as_ref(), + tool_name, + git_styles, + ls_styles, + fallback_style, + mode, + tail_limit, + )?; return Ok(()); } diff --git a/src/agent/runloop/tool_output/streams_helpers.rs b/src/agent/runloop/tool_output/streams_helpers.rs index f2d8a9c22..c8770e547 100644 --- a/src/agent/runloop/tool_output/streams_helpers.rs +++ b/src/agent/runloop/tool_output/streams_helpers.rs @@ -4,6 +4,7 @@ use std::path::PathBuf; use anyhow::{Context, Result}; use smallvec::SmallVec; +pub(super) use vtcode_commons::diff_paths::looks_like_diff_content; use vtcode_core::config::ToolOutputMode; use vtcode_core::config::constants::defaults; use vtcode_core::config::loader::VTCodeConfig; @@ -63,23 +64,6 @@ pub(super) fn should_render_as_code_block(style: MessageStyle) -> bool { matches!(style, MessageStyle::ToolOutput | MessageStyle::Output) } -pub(super) fn looks_like_diff_content(content: &str) -> bool { - content.lines().any(|line| { - let trimmed = line.trim_start(); - trimmed.starts_with("diff --") - || trimmed.starts_with("index ") - || trimmed.starts_with("@@") - || trimmed.starts_with("--- ") - || trimmed.starts_with("+++ ") - || trimmed.starts_with("new file mode") - || trimmed.starts_with("deleted file mode") - || trimmed.starts_with("*** Begin Patch") - || trimmed.starts_with("*** Update File:") - || trimmed.starts_with("*** Add File:") - || trimmed.starts_with("*** Delete File:") - }) -} - pub(super) fn build_markdown_code_block(lines: &[&str]) -> String { let mut markdown = String::with_capacity(lines.len() * 80 + 16); markdown.push_str("```\n"); @@ -463,4 +447,16 @@ mod tests { assert_eq!(lines.first().copied(), Some("row-1")); assert_eq!(lines.last().copied(), Some("row-30")); } + + #[test] + fn diff_detector_ignores_plus_minus_plain_text() { + let plain = "+ enabled feature flag\n- disabled old path\n"; + assert!(!looks_like_diff_content(plain)); + } + + #[test] + fn diff_detector_accepts_real_unified_diff() { + let diff = "diff --git a/a.rs b/a.rs\n@@ -1 +1 @@\n-old\n+new\n"; + assert!(looks_like_diff_content(diff)); + } } diff --git a/src/agent/runloop/tool_output/styles.rs b/src/agent/runloop/tool_output/styles.rs index 8ebda3759..f0ed0a052 100644 --- a/src/agent/runloop/tool_output/styles.rs +++ b/src/agent/runloop/tool_output/styles.rs @@ -1,66 +1,23 @@ use std::collections::HashMap; -use anstyle::{Ansi256Color, AnsiColor, Color, Effects, RgbColor, Style as AnsiStyle}; -use anstyle_git; -use vtcode_core::config::constants::tools; -use vtcode_core::utils::ansi_capabilities::{ - CAPABILITIES, ColorDepth, ColorScheme, detect_color_scheme, +use anstyle::{AnsiColor, Color, Effects, Style as AnsiStyle}; +use vtcode_commons::diff_paths::{ + is_diff_addition_line, is_diff_deletion_line, is_diff_header_line, }; +use vtcode_core::config::constants::tools; +use vtcode_core::utils::diff_styles::{DiffColorLevel, DiffTheme, diff_add_bg, diff_del_bg}; use vtcode_core::utils::style_helpers::bold_color; -fn standard_diff_style(style: AnsiStyle, color: Color, bg: Option) -> AnsiStyle { - let effects = style.get_effects(); - AnsiStyle::new() - .fg_color(Some(color)) - .bg_color(bg) - .effects(effects) -} - -fn diff_line_bg_for(scheme: ColorScheme, depth: ColorDepth, is_addition: bool) -> Option { - match (scheme, depth, is_addition) { - // Light theme truecolor (GitHub-style tints). - (ColorScheme::Light, ColorDepth::TrueColor, true) => { - Some(Color::Rgb(RgbColor(218, 251, 225))) - } - (ColorScheme::Light, ColorDepth::TrueColor, false) => { - Some(Color::Rgb(RgbColor(255, 235, 233))) - } - // Dark theme truecolor. - (ColorScheme::Dark | ColorScheme::Unknown, ColorDepth::TrueColor, true) => { - Some(Color::Rgb(RgbColor(33, 58, 43))) - } - (ColorScheme::Dark | ColorScheme::Unknown, ColorDepth::TrueColor, false) => { - Some(Color::Rgb(RgbColor(74, 34, 29))) - } - // 256-color fallback. - (ColorScheme::Light, ColorDepth::Color256, true) => Some(Color::Ansi256(Ansi256Color(194))), - (ColorScheme::Light, ColorDepth::Color256, false) => { - Some(Color::Ansi256(Ansi256Color(224))) - } - (ColorScheme::Dark | ColorScheme::Unknown, ColorDepth::Color256, true) => { - Some(Color::Ansi256(Ansi256Color(22))) - } - (ColorScheme::Dark | ColorScheme::Unknown, ColorDepth::Color256, false) => { - Some(Color::Ansi256(Ansi256Color(52))) - } - // Basic 16-color fallback (still include BG so full-width extension can kick in). - (ColorScheme::Light, ColorDepth::Basic16, true) => { - Some(Color::Ansi(AnsiColor::BrightGreen)) - } - (ColorScheme::Light, ColorDepth::Basic16, false) => Some(Color::Ansi(AnsiColor::BrightRed)), - (ColorScheme::Dark | ColorScheme::Unknown, ColorDepth::Basic16, true) => { - Some(Color::Ansi(AnsiColor::Green)) - } - (ColorScheme::Dark | ColorScheme::Unknown, ColorDepth::Basic16, false) => { - Some(Color::Ansi(AnsiColor::Red)) - } - // No color support. - (_, ColorDepth::None, _) => None, - } -} - +/// Get background color for diff lines based on detected theme and color level. fn diff_line_bg_color(is_addition: bool) -> Option { - diff_line_bg_for(detect_color_scheme(), CAPABILITIES.color_depth, is_addition) + let theme = DiffTheme::detect(); + let level = DiffColorLevel::detect(); + let bg = if is_addition { + diff_add_bg(theme, level) + } else { + diff_del_bg(theme, level) + }; + Some(bg) } pub(crate) struct GitStyles { @@ -71,29 +28,26 @@ pub(crate) struct GitStyles { impl GitStyles { pub(crate) fn new() -> Self { - let parsed_added = anstyle_git::parse("new").unwrap_or_default(); - let parsed_removed = anstyle_git::parse("old").unwrap_or_default(); - let parsed_header = anstyle_git::parse("meta").unwrap_or_default(); - let remove_effects = parsed_removed.get_effects() | Effects::DIMMED; + // Use standard ANSI colors without bold - no theme dependency + // Background colors adapt to terminal theme (dark/light) + let remove_effects = Effects::DIMMED; Self { - add: Some(standard_diff_style( - parsed_added, - Color::Ansi(AnsiColor::Green), - diff_line_bg_color(true), - )), + add: Some( + AnsiStyle::new() + .fg_color(Some(Color::Ansi(AnsiColor::Green))) + .bg_color(diff_line_bg_color(true)), + ), remove: Some( - standard_diff_style( - parsed_removed, - Color::Ansi(AnsiColor::Red), - diff_line_bg_color(false), - ) - .effects(remove_effects), + AnsiStyle::new() + .fg_color(Some(Color::Ansi(AnsiColor::Red))) + .bg_color(diff_line_bg_color(false)) + .effects(remove_effects), + ), + header: Some( + AnsiStyle::new() + .fg_color(Some(Color::Ansi(AnsiColor::Cyan))) + .bg_color(None), ), - header: Some(standard_diff_style( - parsed_header, - Color::Ansi(AnsiColor::Cyan), - None, - )), } } } @@ -205,20 +159,13 @@ pub(crate) fn select_line_style( let trimmed = line.trim_start(); // Always detect and style diff lines, even when tool_name is not provided // (e.g. git_diff payloads routed through generic rendering path). - if trimmed.starts_with("diff --") - || trimmed.starts_with("index ") - || trimmed.starts_with("@@") - || trimmed.starts_with("---") - || trimmed.starts_with("+++") - || trimmed.starts_with("new file mode") - || trimmed.starts_with("deleted file mode") - { + if is_diff_header_line(trimmed) { return git.header; } - if trimmed.starts_with('+') && !trimmed.starts_with("+++") { + if is_diff_addition_line(trimmed) { return git.add; } - if trimmed.starts_with('-') && !trimmed.starts_with("---") { + if is_diff_deletion_line(trimmed) { return git.remove; } @@ -233,10 +180,9 @@ pub(crate) fn select_line_style( | tools::EDIT_FILE | tools::APPLY_PATCH, ) = tool_name + && let Some(style) = ls.style_for_line(trimmed) { - if let Some(style) = ls.style_for_line(trimmed) { - return Some(style); - } + return Some(style); } None } @@ -257,14 +203,6 @@ mod tests { assert_eq!(header, git.header); } - #[test] - fn theme_aware_diff_background_palette_is_selected() { - let light_add = diff_line_bg_for(ColorScheme::Light, ColorDepth::TrueColor, true); - let dark_del = diff_line_bg_for(ColorScheme::Dark, ColorDepth::TrueColor, false); - assert_eq!(light_add, Some(Color::Rgb(RgbColor(218, 251, 225)))); - assert_eq!(dark_del, Some(Color::Rgb(RgbColor(74, 34, 29)))); - } - #[test] fn removed_diff_lines_are_dimmed_relative_to_additions() { let git = GitStyles::new(); diff --git a/src/agent/runloop/unified/tool_pipeline/pty_stream.rs b/src/agent/runloop/unified/tool_pipeline/pty_stream.rs index fb797df0b..8a5e61038 100644 --- a/src/agent/runloop/unified/tool_pipeline/pty_stream.rs +++ b/src/agent/runloop/unified/tool_pipeline/pty_stream.rs @@ -326,7 +326,7 @@ fn apply_sgr_codes(sequence: &str, current: &mut InlineTextStyle, fallback: &Inl 2 => current.effects |= Effects::DIMMED, 3 => current.effects |= Effects::ITALIC, 4 => current.effects |= Effects::UNDERLINE, - 22 | 23 | 24 => clear_sgr_effects(&mut current.effects, code), + 22..=24 => clear_sgr_effects(&mut current.effects, code), 30..=37 | 90..=97 => current.color = ansi_color_from_ansi_code(code), 39 => current.color = fallback.color, 40..=47 | 100..=107 => { diff --git a/src/agent/runloop/unified/tool_routing/permission_prompt.rs b/src/agent/runloop/unified/tool_routing/permission_prompt.rs index d19aa9e50..37c9cea4f 100644 --- a/src/agent/runloop/unified/tool_routing/permission_prompt.rs +++ b/src/agent/runloop/unified/tool_routing/permission_prompt.rs @@ -11,6 +11,7 @@ use vtcode_core::utils::ansi::AnsiRenderer; use vtcode_tui::InlineEvent; use vtcode_tui::InlineHandle; +use crate::agent::runloop::tool_output::format_unified_diff_lines; use crate::agent::runloop::unified::state::{CtrlCSignal, CtrlCState}; use crate::agent::runloop::unified::ui_interaction::PlaceholderGuard; @@ -71,6 +72,100 @@ pub(super) fn shell_command_requires_prompt(command: &str) -> bool { vtcode_core::command_safety::command_might_be_dangerous(&tokens) } +fn tool_args_diff_preview(tool_name: &str, tool_args: Option<&Value>) -> Option> { + let args = tool_args?.as_object()?; + let (before, after) = match tool_name { + "edit_file" => { + let old_str = args + .get("old_str") + .or_else(|| args.get("old_string")) + .and_then(Value::as_str)?; + let new_str = args + .get("new_str") + .or_else(|| args.get("new_string")) + .and_then(Value::as_str)?; + (Some(old_str), new_str) + } + "write_file" | "create_file" => { + let content = args.get("content").and_then(Value::as_str)?; + (None, content) + } + "unified_file" => { + let action = args + .get("action") + .and_then(Value::as_str) + .or_else(|| { + if args.get("old_str").is_some() || args.get("old_string").is_some() { + Some("edit") + } else if args.get("content").is_some() { + Some("write") + } else { + None + } + }) + .unwrap_or("read"); + + match action { + "edit" => { + let old_str = args + .get("old_str") + .or_else(|| args.get("old_string")) + .and_then(Value::as_str)?; + let new_str = args + .get("new_str") + .or_else(|| args.get("new_string")) + .and_then(Value::as_str)?; + (Some(old_str), new_str) + } + "write" | "create" => { + let content = args.get("content").and_then(Value::as_str)?; + (None, content) + } + _ => return None, + } + } + _ => return None, + }; + let path = args + .get("path") + .or_else(|| args.get("file_path")) + .or_else(|| args.get("target_path")) + .and_then(Value::as_str) + .unwrap_or("(unknown file)"); + + let diff_preview = vtcode_core::tools::file_ops::build_diff_preview(path, before, after); + let skipped = diff_preview + .get("skipped") + .and_then(Value::as_bool) + .unwrap_or(false); + if skipped { + let reason = diff_preview + .get("reason") + .and_then(Value::as_str) + .unwrap_or("preview unavailable"); + return Some(vec![format!("diff: {}", reason)]); + } + + let content = diff_preview + .get("content") + .and_then(Value::as_str) + .unwrap_or(""); + if content.is_empty() { + return Some(vec!["(no changes)".to_string()]); + } + + let lines = format_unified_diff_lines(content); + if lines.len() <= 80 { + return Some(lines); + } + + let mut preview = Vec::with_capacity(61); + preview.extend(lines.iter().take(40).cloned()); + preview.push(format!("… +{} lines", lines.len().saturating_sub(60))); + preview.extend(lines.iter().skip(lines.len().saturating_sub(20)).cloned()); + Some(preview) +} + pub(super) async fn prompt_tool_permission( display_name: &str, tool_name: &str, @@ -95,22 +190,27 @@ pub(super) async fn prompt_tool_permission( if let Some(args) = tool_args && let Some(obj) = args.as_object() { - for (key, value) in obj.iter().take(3) { - if let Some(str_val) = value.as_str() { - let truncated = if str_val.len() > 60 { - format!("{}...", &str_val[..57]) - } else { - str_val.to_string() - }; - description_lines.push(format!(" {}: {}", key, truncated)); - } else if let Some(bool_val) = value.as_bool() { - description_lines.push(format!(" {}: {}", key, bool_val)); - } else if let Some(num_val) = value.as_number() { - description_lines.push(format!(" {}: {}", key, num_val)); + if let Some(diff_lines) = tool_args_diff_preview(tool_name, tool_args) { + description_lines.push(String::new()); + description_lines.extend(diff_lines); + } else { + for (key, value) in obj.iter().take(3) { + if let Some(str_val) = value.as_str() { + let truncated = if str_val.len() > 60 { + format!("{}...", &str_val[..57]) + } else { + str_val.to_string() + }; + description_lines.push(format!(" {}: {}", key, truncated)); + } else if let Some(bool_val) = value.as_bool() { + description_lines.push(format!(" {}: {}", key, bool_val)); + } else if let Some(num_val) = value.as_number() { + description_lines.push(format!(" {}: {}", key, num_val)); + } + } + if obj.len() > 3 { + description_lines.push(format!(" ... and {} more arguments", obj.len() - 3)); } - } - if obj.len() > 3 { - description_lines.push(format!(" ... and {} more arguments", obj.len() - 3)); } } diff --git a/src/agent/runloop/unified/tool_summary_helpers.rs b/src/agent/runloop/unified/tool_summary_helpers.rs index b0ccf883d..d023d5279 100644 --- a/src/agent/runloop/unified/tool_summary_helpers.rs +++ b/src/agent/runloop/unified/tool_summary_helpers.rs @@ -208,10 +208,18 @@ pub(super) fn collect_param_details(args: &Value, keys: &HashSet) -> Vec }; let include_all = keys.is_empty(); for (key, value) in map { - // Skip command-related keys and edit_file content keys (too verbose) + // Skip command-related and raw content keys (too verbose in summaries) if matches!( key.as_str(), - "command" | "raw_command" | "bash_command" | "cmd" | "old_str" | "new_str" + "command" + | "raw_command" + | "bash_command" + | "cmd" + | "old_str" + | "new_str" + | "content" + | "new_content" + | "text" ) { continue; } diff --git a/src/agent/runloop/unified/turn/context.rs b/src/agent/runloop/unified/turn/context.rs index 0141d1f34..49987e2e3 100644 --- a/src/agent/runloop/unified/turn/context.rs +++ b/src/agent/runloop/unified/turn/context.rs @@ -9,6 +9,9 @@ use std::sync::Arc; use std::sync::RwLock; use std::time::{Duration, Instant}; use tokio::sync::Notify; +use vtcode_commons::diff_paths::{ + is_diff_addition_line, is_diff_deletion_line, is_diff_header_line, +}; use vtcode_core::config::loader::VTCodeConfig; use vtcode_core::core::agent::snapshots::SnapshotManager; use vtcode_core::core::agent::steering::SteeringMessage; @@ -576,10 +579,9 @@ fn is_diff_like_fenced_recap(text: &str) -> bool { has_fence = true; continue; } - if trimmed.starts_with("diff --git") - || trimmed.starts_with("@@") - || (trimmed.starts_with('+') && !trimmed.starts_with("+++")) - || (trimmed.starts_with('-') && !trimmed.starts_with("---")) + if is_diff_header_line(trimmed) + || is_diff_addition_line(trimmed) + || is_diff_deletion_line(trimmed) { has_diff_marker = true; } diff --git a/vtcode-commons/src/diff_paths.rs b/vtcode-commons/src/diff_paths.rs new file mode 100644 index 000000000..cc8c569a9 --- /dev/null +++ b/vtcode-commons/src/diff_paths.rs @@ -0,0 +1,296 @@ +use std::path::Path; + +/// Parse `diff --git a/... b/...` line and return normalized new path. +pub fn parse_diff_git_path(line: &str) -> Option { + let mut parts = line.split_whitespace(); + if parts.next()? != "diff" || parts.next()? != "--git" { + return None; + } + let _old = parts.next()?; + let new_path = parts.next()?; + Some(new_path.trim_start_matches("b/").to_string()) +} + +/// Parse unified diff marker line (`---`/`+++`) and return normalized path. +pub fn parse_diff_marker_path(line: &str) -> Option { + let trimmed = line.trim_start(); + if !(is_diff_old_file_marker_line(trimmed) || is_diff_new_file_marker_line(trimmed)) { + return None; + } + let path = trimmed.split_whitespace().nth(1)?; + if path == "/dev/null" { + return None; + } + Some( + path.trim_start_matches("a/") + .trim_start_matches("b/") + .to_string(), + ) +} + +/// Convert file path to language hint based on extension. +pub fn language_hint_from_path(path: &str) -> Option { + Path::new(path) + .extension() + .and_then(|ext| ext.to_str()) + .filter(|ext| !ext.is_empty()) + .map(|ext| ext.to_ascii_lowercase()) +} + +/// Whether a line is a unified diff addition content line (`+...`, excluding `+++` marker). +pub fn is_diff_addition_line(line: &str) -> bool { + line.starts_with('+') && !line.starts_with("+++") +} + +/// Whether a line is a unified diff removal content line (`-...`, excluding `---` marker). +pub fn is_diff_deletion_line(line: &str) -> bool { + line.starts_with('-') && !line.starts_with("---") +} + +/// Whether a line is a unified diff old-file marker (`--- ...`). +pub fn is_diff_old_file_marker_line(line: &str) -> bool { + line.starts_with("--- ") +} + +/// Whether a line is a unified diff new-file marker (`+++ ...`). +pub fn is_diff_new_file_marker_line(line: &str) -> bool { + line.starts_with("+++ ") +} + +/// Whether a line is an apply_patch operation header. +pub fn is_apply_patch_header_line(line: &str) -> bool { + line.starts_with("*** Begin Patch") + || line.starts_with("*** Update File:") + || line.starts_with("*** Add File:") + || line.starts_with("*** Delete File:") +} + +/// Whether a line is a recognized diff metadata/header line. +pub fn is_diff_header_line(line: &str) -> bool { + line.starts_with("diff --git ") + || line.starts_with("@@") + || line.starts_with("index ") + || line.starts_with("new file mode ") + || line.starts_with("deleted file mode ") + || line.starts_with("rename from ") + || line.starts_with("rename to ") + || line.starts_with("copy from ") + || line.starts_with("copy to ") + || line.starts_with("similarity index ") + || line.starts_with("dissimilarity index ") + || line.starts_with("old mode ") + || line.starts_with("new mode ") + || line.starts_with("Binary files ") + || line.starts_with("\\ No newline at end of file") + || is_diff_new_file_marker_line(line) + || is_diff_old_file_marker_line(line) + || is_apply_patch_header_line(line) +} + +/// Heuristic classifier for unified/git diff content. +/// +/// This intentionally avoids classifying plain source code containing `+`/`-` +/// lines as a diff unless there are structural diff markers. +pub fn looks_like_diff_content(content: &str) -> bool { + let mut has_git_header = false; + let mut has_hunk = false; + let mut has_old_marker = false; + let mut has_new_marker = false; + let mut has_add = false; + let mut has_del = false; + let mut has_binary_or_mode_header = false; + let mut has_apply_patch = false; + + for raw in content.lines() { + let line = raw.trim_start(); + if line.is_empty() { + continue; + } + + if line.starts_with("diff --git ") { + has_git_header = true; + continue; + } + if line.starts_with("@@") { + has_hunk = true; + continue; + } + if is_diff_old_file_marker_line(line) { + has_old_marker = true; + continue; + } + if is_diff_new_file_marker_line(line) { + has_new_marker = true; + continue; + } + if is_apply_patch_header_line(line) { + has_apply_patch = true; + continue; + } + if line.starts_with("new file mode ") + || line.starts_with("deleted file mode ") + || line.starts_with("rename from ") + || line.starts_with("rename to ") + || line.starts_with("copy from ") + || line.starts_with("copy to ") + || line.starts_with("similarity index ") + || line.starts_with("dissimilarity index ") + || line.starts_with("old mode ") + || line.starts_with("new mode ") + || line.starts_with("Binary files ") + || line.starts_with("index ") + || line.starts_with("\\ No newline at end of file") + { + has_binary_or_mode_header = true; + continue; + } + + if is_diff_addition_line(line) { + has_add = true; + continue; + } + if is_diff_deletion_line(line) { + has_del = true; + } + } + + if has_apply_patch { + return true; + } + if has_git_header && (has_hunk || has_old_marker || has_new_marker || has_binary_or_mode_header) + { + return true; + } + if has_hunk && (has_old_marker || has_new_marker || has_add || has_del) { + return true; + } + if has_old_marker && has_new_marker && (has_add || has_del) { + return true; + } + + false +} + +/// Parse unified diff hunk header starts from `@@ -old,+new @@`. +pub fn parse_hunk_starts(line: &str) -> Option<(usize, usize)> { + let trimmed = line.trim_end(); + let rest = trimmed.strip_prefix("@@ -")?; + let mut parts = rest.split_whitespace(); + let old_part = parts.next()?; + let new_part = parts.next()?; + if !new_part.starts_with('+') { + return None; + } + + let old_start = old_part.split(',').next()?.parse::().ok()?; + let new_start = new_part + .trim_start_matches('+') + .split(',') + .next()? + .parse::() + .ok()?; + Some((old_start, new_start)) +} + +/// Normalize hunk header to start-only form: `@@ -old +new @@`. +pub fn format_start_only_hunk_header(line: &str) -> Option { + let (old_start, new_start) = parse_hunk_starts(line)?; + Some(format!("@@ -{} +{} @@", old_start, new_start)) +} + +#[cfg(test)] +mod tests { + use super::{ + format_start_only_hunk_header, is_apply_patch_header_line, is_diff_addition_line, + is_diff_deletion_line, is_diff_header_line, is_diff_new_file_marker_line, + is_diff_old_file_marker_line, language_hint_from_path, looks_like_diff_content, + parse_diff_git_path, parse_diff_marker_path, parse_hunk_starts, + }; + + #[test] + fn parses_git_diff_path() { + let line = "diff --git a/src/lib.rs b/src/lib.rs"; + assert_eq!(parse_diff_git_path(line).as_deref(), Some("src/lib.rs")); + } + + #[test] + fn parses_marker_path() { + assert_eq!( + parse_diff_marker_path("+++ b/src/main.rs").as_deref(), + Some("src/main.rs") + ); + assert_eq!(parse_diff_marker_path("--- /dev/null"), None); + } + + #[test] + fn infers_language_hint_from_extension() { + assert_eq!( + language_hint_from_path("src/main.RS").as_deref(), + Some("rs") + ); + assert_eq!(language_hint_from_path("Makefile"), None); + } + + #[test] + fn parses_hunk_starts() { + assert_eq!(parse_hunk_starts("@@ -536,4 +540,5 @@"), Some((536, 540))); + assert_eq!(parse_hunk_starts("not a hunk"), None); + } + + #[test] + fn formats_start_only_hunk_header() { + assert_eq!( + format_start_only_hunk_header("@@ -536,4 +540,5 @@"), + Some("@@ -536 +540 @@".to_string()) + ); + } + + #[test] + fn detects_diff_add_remove_lines() { + assert!(is_diff_addition_line("+added")); + assert!(!is_diff_addition_line("+++ b/file.rs")); + assert!(is_diff_deletion_line("-removed")); + assert!(!is_diff_deletion_line("--- a/file.rs")); + } + + #[test] + fn detects_diff_header_lines() { + assert!(is_diff_header_line("diff --git a/a b/a")); + assert!(is_diff_header_line("@@ -1 +1 @@")); + assert!(is_diff_header_line("+++ b/src/main.rs")); + assert!(!is_diff_header_line("println!(\"diff --git\");")); + } + + #[test] + fn detects_marker_and_apply_patch_header_lines() { + assert!(is_diff_old_file_marker_line("--- a/src/lib.rs")); + assert!(is_diff_new_file_marker_line("+++ b/src/lib.rs")); + assert!(is_apply_patch_header_line("*** Update File: src/lib.rs")); + assert!(!is_apply_patch_header_line("*** End Patch")); + } + + #[test] + fn classifies_git_diff_content() { + let diff = "diff --git a/a.rs b/a.rs\n@@ -1 +1 @@\n-old\n+new\n"; + assert!(looks_like_diff_content(diff)); + } + + #[test] + fn classifies_apply_patch_content() { + let patch = "*** Begin Patch\n*** Update File: a.rs\n@@\n-old\n+new\n*** End Patch\n"; + assert!(looks_like_diff_content(patch)); + } + + #[test] + fn avoids_false_positive_for_regular_code() { + let code = + "fn delta(x: i32) -> i32 {\n let y = x + 1;\n let z = x - 1;\n y + z\n}\n"; + assert!(!looks_like_diff_content(code)); + } + + #[test] + fn avoids_false_positive_for_plus_minus_logs() { + let log = "+ started service\n- previous pid cleaned\n"; + assert!(!looks_like_diff_content(log)); + } +} diff --git a/vtcode-commons/src/lib.rs b/vtcode-commons/src/lib.rs index 9e01cce09..bf9ca860c 100644 --- a/vtcode-commons/src/lib.rs +++ b/vtcode-commons/src/lib.rs @@ -15,6 +15,7 @@ pub mod async_utils; pub mod at_pattern; pub mod colors; pub mod diff; +pub mod diff_paths; pub mod errors; pub mod formatting; pub mod fs; diff --git a/vtcode-commons/src/styling.rs b/vtcode-commons/src/styling.rs index 68a5b44be..6434defc8 100644 --- a/vtcode-commons/src/styling.rs +++ b/vtcode-commons/src/styling.rs @@ -1,6 +1,6 @@ //! Unified message styles and their logical mappings -use anstyle::{AnsiColor, Color, Style}; +use anstyle::{Ansi256Color, AnsiColor, Color, Effects, RgbColor, Style}; /// Standard color palette with semantic names #[derive(Debug, Clone, Copy)] @@ -33,19 +33,19 @@ pub fn render_styled(text: &str, color: Color, effects: Option) -> Strin let mut style = Style::new().fg_color(Some(color)); if let Some(effects_str) = effects { - let mut ansi_effects = anstyle::Effects::new(); + let mut ansi_effects = Effects::new(); for effect in effects_str.split(',') { let effect = effect.trim().to_lowercase(); match effect.as_str() { - "bold" => ansi_effects |= anstyle::Effects::BOLD, - "dim" | "dimmed" => ansi_effects |= anstyle::Effects::DIMMED, - "italic" => ansi_effects |= anstyle::Effects::ITALIC, - "underline" => ansi_effects |= anstyle::Effects::UNDERLINE, - "blink" => ansi_effects |= anstyle::Effects::BLINK, - "invert" | "reversed" => ansi_effects |= anstyle::Effects::INVERT, - "hidden" => ansi_effects |= anstyle::Effects::HIDDEN, - "strikethrough" => ansi_effects |= anstyle::Effects::STRIKETHROUGH, + "bold" => ansi_effects |= Effects::BOLD, + "dim" | "dimmed" => ansi_effects |= Effects::DIMMED, + "italic" => ansi_effects |= Effects::ITALIC, + "underline" => ansi_effects |= Effects::UNDERLINE, + "blink" => ansi_effects |= Effects::BLINK, + "invert" | "reversed" => ansi_effects |= Effects::INVERT, + "hidden" => ansi_effects |= Effects::HIDDEN, + "strikethrough" => ansi_effects |= Effects::STRIKETHROUGH, _ => {} } } @@ -96,6 +96,7 @@ pub fn dimmed_color(color: AnsiColor) -> Style { } /// Diff color palette for consistent git diff styling +/// Uses standard ANSI colors without bold for accessibility and consistency. #[derive(Debug, Clone, Copy)] pub struct DiffColorPalette { pub added_fg: Color, @@ -110,11 +111,11 @@ impl Default for DiffColorPalette { fn default() -> Self { Self { added_fg: Color::Ansi(AnsiColor::Green), - added_bg: Color::Rgb(anstyle::RgbColor(10, 24, 10)), + added_bg: Color::Rgb(RgbColor(10, 24, 10)), removed_fg: Color::Ansi(AnsiColor::Red), - removed_bg: Color::Rgb(anstyle::RgbColor(24, 10, 10)), + removed_bg: Color::Rgb(RgbColor(24, 10, 10)), header_fg: Color::Ansi(AnsiColor::Cyan), - header_bg: Color::Rgb(anstyle::RgbColor(10, 16, 20)), + header_bg: Color::Rgb(RgbColor(10, 16, 20)), } } } @@ -132,3 +133,146 @@ impl DiffColorPalette { Style::new().fg_color(Some(self.header_fg)) } } + +// ── Theme-aware diff rendering ───────────────────────────────────────────── +// +// Provides terminal-adaptive styling that adjusts background tints based on: +// 1. DiffTheme (Dark/Light) — detected from terminal background +// 2. DiffColorLevel (TrueColor/Ansi256/Ansi16) — from terminal capability +// +// Colors are selected for WCAG AA accessibility contrast ratios (4.5:1 minimum). + +/// Terminal background theme for diff rendering. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum DiffTheme { + Dark, + Light, +} + +impl DiffTheme { + /// Detect theme from the terminal environment. + pub fn detect() -> Self { + // Check COLORTERM and TERM environment variables for light theme indicators + // Default to dark theme for unknown cases (most common) + let term = std::env::var("TERM").unwrap_or_default().to_lowercase(); + if term.contains("light") { + Self::Light + } else { + Self::Dark + } + } + + pub fn is_light(self) -> bool { + self == Self::Light + } +} + +/// Terminal color capability level for palette selection. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum DiffColorLevel { + TrueColor, + Ansi256, + Ansi16, +} + +impl DiffColorLevel { + /// Detect color level from terminal capabilities. + pub fn detect() -> Self { + let colorterm = std::env::var("COLORTERM").unwrap_or_default(); + let term = std::env::var("TERM").unwrap_or_default(); + + if colorterm.contains("truecolor") || colorterm.contains("24bit") { + Self::TrueColor + } else if term.contains("256") { + Self::Ansi256 + } else { + Self::Ansi16 + } + } +} + +// ── Truecolor palette (WCAG AA compliant) ────────────────────────────────── + +// Dark theme: darker backgrounds with sufficient contrast for white/light text +const DARK_TC_ADD_LINE_BG: (u8, u8, u8) = (20, 40, 20); // #142814 - Dark green +const DARK_TC_DEL_LINE_BG: (u8, u8, u8) = (48, 16, 16); // #301010 - Dark red + +// Light theme: light backgrounds with sufficient contrast for dark text +// GitHub-inspired but adjusted for better contrast +const LIGHT_TC_ADD_LINE_BG: (u8, u8, u8) = (220, 245, 220); // #DCF5DC - Light green +const LIGHT_TC_DEL_LINE_BG: (u8, u8, u8) = (250, 220, 220); // #FADCD4 - Light red +const LIGHT_TC_ADD_NUM_BG: (u8, u8, u8) = (180, 230, 180); // #B4E6B4 - Gutter green +const LIGHT_TC_DEL_NUM_BG: (u8, u8, u8) = (245, 190, 190); // #F5BEBE - Gutter red +const LIGHT_TC_GUTTER_FG: (u8, u8, u8) = (30, 30, 30); // #1E1E1E - Near-black for contrast + +// ── 256-color palette ────────────────────────────────────────────────────── + +const DARK_256_ADD_LINE_BG: u8 = 22; // DarkGreen +const DARK_256_DEL_LINE_BG: u8 = 52; // DarkRed + +const LIGHT_256_ADD_LINE_BG: u8 = 194; // LightGreen +const LIGHT_256_DEL_LINE_BG: u8 = 224; // LightRed/Pink +const LIGHT_256_ADD_NUM_BG: u8 = 157; // SeaGreen +const LIGHT_256_DEL_NUM_BG: u8 = 217; // LightPink +const LIGHT_256_GUTTER_FG: u8 = 236; // DarkGray + +// ── Background color helpers ─────────────────────────────────────────────── + +fn rgb(t: (u8, u8, u8)) -> Color { + Color::Rgb(RgbColor(t.0, t.1, t.2)) +} + +fn indexed(i: u8) -> Color { + Color::Ansi256(Ansi256Color(i)) +} + +/// Get background color for addition lines based on theme and color level. +pub fn diff_add_bg(theme: DiffTheme, level: DiffColorLevel) -> Color { + match (theme, level) { + (DiffTheme::Dark, DiffColorLevel::TrueColor) => rgb(DARK_TC_ADD_LINE_BG), + (DiffTheme::Dark, DiffColorLevel::Ansi256) => indexed(DARK_256_ADD_LINE_BG), + (DiffTheme::Dark, DiffColorLevel::Ansi16) => Color::Ansi(AnsiColor::Green), + (DiffTheme::Light, DiffColorLevel::TrueColor) => rgb(LIGHT_TC_ADD_LINE_BG), + (DiffTheme::Light, DiffColorLevel::Ansi256) => indexed(LIGHT_256_ADD_LINE_BG), + (DiffTheme::Light, DiffColorLevel::Ansi16) => Color::Ansi(AnsiColor::BrightGreen), + } +} + +/// Get background color for deletion lines based on theme and color level. +pub fn diff_del_bg(theme: DiffTheme, level: DiffColorLevel) -> Color { + match (theme, level) { + (DiffTheme::Dark, DiffColorLevel::TrueColor) => rgb(DARK_TC_DEL_LINE_BG), + (DiffTheme::Dark, DiffColorLevel::Ansi256) => indexed(DARK_256_DEL_LINE_BG), + (DiffTheme::Dark, DiffColorLevel::Ansi16) => Color::Ansi(AnsiColor::Red), + (DiffTheme::Light, DiffColorLevel::TrueColor) => rgb(LIGHT_TC_DEL_LINE_BG), + (DiffTheme::Light, DiffColorLevel::Ansi256) => indexed(LIGHT_256_DEL_LINE_BG), + (DiffTheme::Light, DiffColorLevel::Ansi16) => Color::Ansi(AnsiColor::BrightRed), + } +} + +/// Get gutter foreground color for light theme (dark theme uses dimmed default). +pub fn diff_gutter_fg_light(level: DiffColorLevel) -> Color { + match level { + DiffColorLevel::TrueColor => rgb(LIGHT_TC_GUTTER_FG), + DiffColorLevel::Ansi256 => indexed(LIGHT_256_GUTTER_FG), + DiffColorLevel::Ansi16 => Color::Ansi(AnsiColor::Black), + } +} + +/// Get gutter background color for addition lines in light theme. +pub fn diff_gutter_bg_add_light(level: DiffColorLevel) -> Color { + match level { + DiffColorLevel::TrueColor => rgb(LIGHT_TC_ADD_NUM_BG), + DiffColorLevel::Ansi256 => indexed(LIGHT_256_ADD_NUM_BG), + DiffColorLevel::Ansi16 => Color::Ansi(AnsiColor::BrightGreen), + } +} + +/// Get gutter background color for deletion lines in light theme. +pub fn diff_gutter_bg_del_light(level: DiffColorLevel) -> Color { + match level { + DiffColorLevel::TrueColor => rgb(LIGHT_TC_DEL_NUM_BG), + DiffColorLevel::Ansi256 => indexed(LIGHT_256_DEL_NUM_BG), + DiffColorLevel::Ansi16 => Color::Ansi(AnsiColor::BrightRed), + } +} diff --git a/vtcode-core/src/tools/handlers/read_file.rs b/vtcode-core/src/tools/handlers/read_file.rs index e09308c8c..599d10c03 100644 --- a/vtcode-core/src/tools/handlers/read_file.rs +++ b/vtcode-core/src/tools/handlers/read_file.rs @@ -11,6 +11,7 @@ use serde_json::{Value, json}; use tokio::fs::File; use tokio::io::{AsyncBufReadExt, BufReader}; use tokio::sync::Semaphore; +use vtcode_commons::diff_paths::looks_like_diff_content; use crate::tools::traits::Tool; use crate::utils::serde_helpers::{deserialize_maybe_quoted, deserialize_opt_maybe_quoted}; @@ -882,26 +883,8 @@ fn condense_for_batch(lines: &mut Vec) -> (bool, usize) { } fn looks_like_diff_lines(lines: &[String]) -> bool { - lines.iter().any(|line| { - let trimmed = line.trim_start(); - trimmed.starts_with("diff --git ") - || trimmed.starts_with("@@ ") - || trimmed.starts_with("+++ ") - || trimmed.starts_with("--- ") - || trimmed.starts_with("index ") - || trimmed.starts_with("new file mode ") - || trimmed.starts_with("deleted file mode ") - || trimmed.starts_with("rename from ") - || trimmed.starts_with("rename to ") - || trimmed.starts_with("copy from ") - || trimmed.starts_with("copy to ") - || trimmed.starts_with("similarity index ") - || trimmed.starts_with("dissimilarity index ") - || trimmed.starts_with("old mode ") - || trimmed.starts_with("new mode ") - || trimmed.starts_with("Binary files ") - || trimmed.starts_with("\\ No newline at end of file") - }) + let joined = lines.join("\n"); + looks_like_diff_content(&joined) } mod defaults { @@ -1138,4 +1121,36 @@ mod tests { assert!(lines.len() < 100); assert!(lines.iter().any(|l| l.contains("omitted"))); } + + #[test] + fn condense_for_batch_does_not_treat_plus_minus_text_as_diff() { + let mut lines: Vec = (1..=60) + .map(|i| { + if i % 2 == 0 { + format!("+ normal status line {i}") + } else { + format!("- normal status line {i}") + } + }) + .collect(); + let (condensed, omitted) = condense_for_batch(&mut lines); + assert!(condensed); + assert!(omitted > 0); + } + + #[test] + fn condense_for_batch_preserves_actual_diff_output() { + let mut lines = vec![ + "diff --git a/src/main.rs b/src/main.rs".to_string(), + "index 1111111..2222222 100644".to_string(), + "--- a/src/main.rs".to_string(), + "+++ b/src/main.rs".to_string(), + "@@ -1 +1 @@".to_string(), + "-old".to_string(), + "+new".to_string(), + ]; + let (condensed, omitted) = condense_for_batch(&mut lines); + assert!(!condensed); + assert_eq!(omitted, 0); + } } diff --git a/vtcode-core/src/tools/registry/executors.rs b/vtcode-core/src/tools/registry/executors.rs index 73bfdecdf..08058fc45 100644 --- a/vtcode-core/src/tools/registry/executors.rs +++ b/vtcode-core/src/tools/registry/executors.rs @@ -84,16 +84,15 @@ fn workspace_write_policy_from_runtime_config( resource_limits, seccomp_profile, ); - if network_allow_all { - if let SandboxPolicy::WorkspaceWrite { + if network_allow_all + && let SandboxPolicy::WorkspaceWrite { network_access, network_allowlist, .. } = &mut policy - { - *network_access = true; - network_allowlist.clear(); - } + { + *network_access = true; + network_allowlist.clear(); } policy } diff --git a/vtcode-core/src/ui/diff_renderer.rs b/vtcode-core/src/ui/diff_renderer.rs index a8dc937e4..1ba5a4840 100644 --- a/vtcode-core/src/ui/diff_renderer.rs +++ b/vtcode-core/src/ui/diff_renderer.rs @@ -1,10 +1,10 @@ use crate::config::constants::diff as diff_constants; use crate::ui::git_config::GitColorConfig; use crate::utils::style_helpers::style_from_color_name; -use anstyle::{AnsiColor, Color, Reset, Style}; -use anstyle_git; +use anstyle::{Reset, Style}; use std::fmt::Write as _; use std::path::Path; +use vtcode_commons::styling::DiffColorPalette; pub(crate) struct GitDiffPalette { pub(crate) bullet: Style, @@ -23,34 +23,13 @@ fn strip_diff_background(style: Style) -> Style { style.bg_color(None) } -fn standard_diff_style(style: Style, color: AnsiColor) -> Style { - Style::new() - .fg_color(Some(Color::Ansi(color))) - .effects(style.get_effects()) -} - impl GitDiffPalette { fn new(use_colors: bool) -> Self { - let git_parse = |git_color: &str| -> Style { - if use_colors { - anstyle_git::parse(git_color).unwrap_or( - // Fallback to original behavior for backwards compatibility - match git_color { - "new" => style_from_color_name("green"), - "old" => style_from_color_name("red"), - "context" => Style::new().dimmed(), - "meta" | "header" => style_from_color_name("cyan"), - _ => Style::new(), - }, - ) - } else { - Style::new() - } - }; - - let added = standard_diff_style(git_parse("new"), AnsiColor::Green); - let removed = standard_diff_style(git_parse("old"), AnsiColor::Red); - let header = standard_diff_style(git_parse("meta"), AnsiColor::Cyan); + // Use consolidated DiffColorPalette with standard ANSI colors (no bold) + let palette = DiffColorPalette::default(); + let added = palette.added_style(); + let removed = palette.removed_style(); + let header = palette.header_style(); Self { bullet: if use_colors { @@ -64,8 +43,12 @@ impl GitDiffPalette { stat_removed: strip_diff_background(removed), line_added: strip_diff_background(added), line_removed: strip_diff_background(removed), - line_context: strip_diff_background(git_parse("context")), - line_header: strip_diff_background(header), // Git uses "meta" for headers + line_context: if use_colors { + Style::new().dimmed() + } else { + Style::new() + }, + line_header: strip_diff_background(header), line_number: if use_colors { style_from_color_name("cyan") } else { @@ -80,9 +63,11 @@ impl GitDiffPalette { return Self::new(false); } - let added = standard_diff_style(config.diff_new, AnsiColor::Green); - let removed = standard_diff_style(config.diff_old, AnsiColor::Red); - let header = standard_diff_style(config.diff_header, AnsiColor::Cyan); + // Use consolidated DiffColorPalette - ignore git config theme for consistency + let palette = DiffColorPalette::default(); + let added = palette.added_style(); + let removed = palette.removed_style(); + let header = palette.header_style(); Self { bullet: style_from_color_name("cyan"), @@ -93,7 +78,7 @@ impl GitDiffPalette { line_added: strip_diff_background(added), line_removed: strip_diff_background(removed), line_context: strip_diff_background(config.diff_context), - line_header: strip_diff_background(header), // Use the configuration from Git + line_header: strip_diff_background(header), line_number: style_from_color_name("cyan"), } } diff --git a/vtcode-core/src/ui/git_config.rs b/vtcode-core/src/ui/git_config.rs index f7899ba19..e36ed90b8 100644 --- a/vtcode-core/src/ui/git_config.rs +++ b/vtcode-core/src/ui/git_config.rs @@ -145,6 +145,8 @@ impl GitColorConfig { /// Extract a single Git color setting from config content /// /// Looks for patterns like: [color "section"] key = value + /// Note: This parses the color name but ignores bold/dim effects to ensure + /// consistent diff styling without theme-dependent formatting. fn extract_git_color(content: &str, section: &str, key: &str) -> Option