Display edit diffs (#469)

This commit is contained in:
Solomon
2025-08-15 11:18:24 +01:00
committed by GitHub
parent 6d65ea18af
commit ca9504f84b
16 changed files with 842 additions and 143 deletions

View File

@@ -7,13 +7,16 @@ use json_patch::Patch;
use serde::{Deserialize, Serialize};
use tokio::{io::AsyncWriteExt, process::Command};
use ts_rs::TS;
use utils::{msg_store::MsgStore, path::make_path_relative, shell::get_shell_command};
use utils::{
diff::create_unified_diff, msg_store::MsgStore, path::make_path_relative,
shell::get_shell_command,
};
use crate::{
command::CommandBuilder,
executors::{ExecutorError, StandardCodingAgentExecutor},
logs::{
ActionType, EditDiff, NormalizedEntry, NormalizedEntryType, TodoItem as LogsTodoItem,
ActionType, FileChange, NormalizedEntry, NormalizedEntryType, TodoItem as LogsTodoItem,
stderr_processor::normalize_stderr_logs,
utils::{EntryIndexProvider, patch::ConversationPatch},
},
@@ -450,17 +453,16 @@ impl AmpContentItem {
path: make_path_relative(path, worktree_path),
},
AmpToolData::CreateFile { path, content, .. } => {
let diffs = content
let changes = content
.as_ref()
.map(|content| EditDiff::Replace {
old: String::new(),
new: content.clone(),
.map(|content| FileChange::Write {
content: content.clone(),
})
.into_iter()
.collect();
ActionType::FileEdit {
path: make_path_relative(path, worktree_path),
diffs,
changes,
}
}
AmpToolData::EditFile {
@@ -469,17 +471,21 @@ impl AmpContentItem {
new_str,
..
} => {
let diffs = if old_str.is_some() || new_str.is_some() {
vec![EditDiff::Replace {
old: old_str.clone().unwrap_or_default(),
new: new_str.clone().unwrap_or_default(),
let changes = if old_str.is_some() || new_str.is_some() {
vec![FileChange::Edit {
unified_diff: create_unified_diff(
path,
old_str.as_deref().unwrap_or(""),
new_str.as_deref().unwrap_or(""),
),
has_line_numbers: false,
}]
} else {
vec![]
};
ActionType::FileEdit {
path: make_path_relative(path, worktree_path),
diffs,
changes,
}
}
AmpToolData::Bash { command, .. } => ActionType::CommandRun {

View File

@@ -7,14 +7,18 @@ use serde::{Deserialize, Serialize};
use tokio::{io::AsyncWriteExt, process::Command};
use ts_rs::TS;
use utils::{
log_msg::LogMsg, msg_store::MsgStore, path::make_path_relative, shell::get_shell_command,
diff::{concatenate_diff_hunks, create_unified_diff, create_unified_diff_hunk},
log_msg::LogMsg,
msg_store::MsgStore,
path::make_path_relative,
shell::get_shell_command,
};
use crate::{
command::CommandBuilder,
executors::{ExecutorError, StandardCodingAgentExecutor},
logs::{
ActionType, EditDiff, NormalizedEntry, NormalizedEntryType, TodoItem,
ActionType, FileChange, NormalizedEntry, NormalizedEntryType, TodoItem,
stderr_processor::normalize_stderr_logs,
utils::{EntryIndexProvider, patch::ConversationPatch},
},
@@ -434,28 +438,32 @@ impl ClaudeLogProcessor {
old_string,
new_string,
} => {
let diffs = if old_string.is_some() || new_string.is_some() {
vec![EditDiff::Replace {
old: old_string.clone().unwrap_or_default(),
new: new_string.clone().unwrap_or_default(),
let changes = if old_string.is_some() || new_string.is_some() {
vec![FileChange::Edit {
unified_diff: create_unified_diff(
file_path,
&old_string.clone().unwrap_or_default(),
&new_string.clone().unwrap_or_default(),
),
has_line_numbers: false,
}]
} else {
vec![]
};
ActionType::FileEdit {
path: make_path_relative(file_path, worktree_path),
diffs,
changes,
}
}
ClaudeToolData::MultiEdit { file_path, edits } => {
let diffs = edits
let hunks: Vec<String> = edits
.iter()
.filter_map(|edit| {
if edit.old_string.is_some() || edit.new_string.is_some() {
Some(EditDiff::Replace {
old: edit.old_string.clone().unwrap_or_default(),
new: edit.new_string.clone().unwrap_or_default(),
})
Some(create_unified_diff_hunk(
&edit.old_string.clone().unwrap_or_default(),
&edit.new_string.clone().unwrap_or_default(),
))
} else {
None
}
@@ -463,17 +471,19 @@ impl ClaudeLogProcessor {
.collect();
ActionType::FileEdit {
path: make_path_relative(file_path, worktree_path),
diffs,
changes: vec![FileChange::Edit {
unified_diff: concatenate_diff_hunks(file_path, &hunks),
has_line_numbers: false,
}],
}
}
ClaudeToolData::Write { file_path, content } => {
let diffs = vec![EditDiff::Replace {
old: String::new(),
new: content.clone(),
let diffs = vec![FileChange::Write {
content: content.clone(),
}];
ActionType::FileEdit {
path: make_path_relative(file_path, worktree_path),
diffs,
changes: diffs,
}
}
ClaudeToolData::Bash { command, .. } => ActionType::CommandRun {
@@ -503,7 +513,7 @@ impl ClaudeLogProcessor {
}
ClaudeToolData::NotebookEdit { notebook_path, .. } => ActionType::FileEdit {
path: make_path_relative(notebook_path, worktree_path),
diffs: vec![],
changes: vec![],
},
ClaudeToolData::TodoWrite { todos } => ActionType::TodoManagement {
todos: todos

View File

@@ -7,13 +7,18 @@ use regex::Regex;
use serde::{Deserialize, Serialize};
use tokio::{io::AsyncWriteExt, process::Command};
use ts_rs::TS;
use utils::{msg_store::MsgStore, path::make_path_relative, shell::get_shell_command};
use utils::{
diff::{concatenate_diff_hunks, extract_unified_diff_hunks},
msg_store::MsgStore,
path::make_path_relative,
shell::get_shell_command,
};
use crate::{
command::CommandBuilder,
executors::{ExecutorError, StandardCodingAgentExecutor},
logs::{
ActionType, EditDiff, NormalizedEntry, NormalizedEntryType,
ActionType, FileChange, NormalizedEntry, NormalizedEntryType,
utils::{EntryIndexProvider, patch::ConversationPatch},
},
};
@@ -342,7 +347,7 @@ pub enum CodexMsgContent {
PatchApplyBegin {
call_id: Option<String>,
auto_approved: Option<bool>,
changes: std::collections::HashMap<String, FileChange>,
changes: std::collections::HashMap<String, CodexFileChange>,
},
#[serde(rename = "patch_apply_end")]
@@ -389,7 +394,7 @@ pub enum CodexMsgContent {
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
#[serde(rename_all = "snake_case")]
pub enum FileChange {
pub enum CodexFileChange {
Add {
content: String,
},
@@ -471,25 +476,39 @@ impl CodexJson {
make_path_relative(file_path, &current_dir.to_string_lossy());
// Try to extract unified diff from change data
let mut diffs = vec![];
let mut changes = vec![];
match change_data {
FileChange::Update { unified_diff, .. } => {
CodexFileChange::Update {
unified_diff,
move_path,
} => {
let mut new_path = relative_path.clone();
if let Some(move_path) = move_path {
new_path = make_path_relative(
&move_path.to_string_lossy(),
&current_dir.to_string_lossy(),
);
changes.push(FileChange::Rename {
new_path: new_path.clone(),
});
}
if !unified_diff.is_empty() {
diffs.push(EditDiff::Unified {
unified_diff: unified_diff.clone(),
let hunks = extract_unified_diff_hunks(unified_diff);
changes.push(FileChange::Edit {
unified_diff: concatenate_diff_hunks(&new_path, &hunks),
has_line_numbers: true,
});
}
}
FileChange::Add { content } => {
// For new files, we could show the content as a diff
diffs.push(EditDiff::Replace {
old: String::new(),
new: content.clone(),
CodexFileChange::Add { content } => {
changes.push(FileChange::Write {
content: content.clone(),
});
}
FileChange::Delete => {
// For deletions, we don't have old content to show
CodexFileChange::Delete => {
changes.push(FileChange::Delete);
}
};
@@ -499,7 +518,7 @@ impl CodexJson {
tool_name: "edit".to_string(),
action_type: ActionType::FileEdit {
path: relative_path.clone(),
diffs,
changes,
},
},
content: relative_path,

View File

@@ -7,13 +7,21 @@ use futures::StreamExt;
use serde::{Deserialize, Serialize};
use tokio::{io::AsyncWriteExt, process::Command};
use ts_rs::TS;
use utils::{msg_store::MsgStore, path::make_path_relative, shell::get_shell_command};
use utils::{
diff::{
concatenate_diff_hunks, create_unified_diff, create_unified_diff_hunk,
extract_unified_diff_hunks,
},
msg_store::MsgStore,
path::make_path_relative,
shell::get_shell_command,
};
use crate::{
command::CommandBuilder,
executors::{ExecutorError, StandardCodingAgentExecutor},
logs::{
ActionType, EditDiff, NormalizedEntry, NormalizedEntryType, TodoItem,
ActionType, FileChange, NormalizedEntry, NormalizedEntryType, TodoItem,
plain_text_processor::PlainTextLogProcessor,
utils::{ConversationPatch, EntryIndexProvider},
},
@@ -480,39 +488,50 @@ impl CursorToolCall {
(
ActionType::FileEdit {
path: path.clone(),
diffs: vec![],
changes: vec![],
},
format!("`{path}`"),
)
}
CursorToolCall::Edit { args, .. } => {
let path = make_path_relative(&args.path, worktree_path);
let mut diffs = vec![];
let mut changes = vec![];
if let Some(_apply_patch) = &args.apply_patch {
// todo: handle v4a
if let Some(apply_patch) = &args.apply_patch {
let hunks = extract_unified_diff_hunks(&apply_patch.patch_content);
changes.push(FileChange::Edit {
unified_diff: concatenate_diff_hunks(&path, &hunks),
has_line_numbers: false,
});
}
if let Some(str_replace) = &args.str_replace {
diffs.push(EditDiff::Replace {
old: str_replace.old_text.clone(),
new: str_replace.new_text.clone(),
changes.push(FileChange::Edit {
unified_diff: create_unified_diff(
&path,
&str_replace.old_text,
&str_replace.new_text,
),
has_line_numbers: false,
});
}
if let Some(multi_str_replace) = &args.multi_str_replace {
for edit in multi_str_replace.edits.iter() {
diffs.push(EditDiff::Replace {
old: edit.old_text.clone(),
new: edit.new_text.clone(),
let hunks: Vec<String> = multi_str_replace
.edits
.iter()
.map(|edit| create_unified_diff_hunk(&edit.old_text, &edit.new_text))
.collect();
changes.push(FileChange::Edit {
unified_diff: concatenate_diff_hunks(&path, &hunks),
has_line_numbers: false,
});
}
}
(
ActionType::FileEdit {
path: path.clone(),
diffs,
changes,
},
format!("`{path}`"),
)
@@ -522,7 +541,7 @@ impl CursorToolCall {
(
ActionType::FileEdit {
path: path.clone(),
diffs: vec![],
changes: vec![],
},
format!("`{path}`"),
)

View File

@@ -9,13 +9,16 @@ use regex::Regex;
use serde::{Deserialize, Serialize};
use tokio::{io::AsyncWriteExt, process::Command};
use ts_rs::TS;
use utils::{msg_store::MsgStore, path::make_path_relative, shell::get_shell_command};
use utils::{
diff::create_unified_diff, msg_store::MsgStore, path::make_path_relative,
shell::get_shell_command,
};
use crate::{
command::CommandBuilder,
executors::{ExecutorError, StandardCodingAgentExecutor},
logs::{
ActionType, EditDiff, NormalizedEntry, NormalizedEntryType, TodoItem,
ActionType, FileChange, NormalizedEntry, NormalizedEntryType, TodoItem,
plain_text_processor::{MessageBoundary, PlainTextLogProcessor},
utils::EntryIndexProvider,
},
@@ -679,16 +682,14 @@ impl ToolUtils {
Tool::Write {
file_path, content, ..
} => {
let diffs = match content {
Some(content) => vec![EditDiff::Replace {
old: String::new(),
new: content.clone(),
}],
None => Vec::new(),
let changes = if let Some(content) = content.clone() {
vec![FileChange::Write { content }]
} else {
vec![]
};
ActionType::FileEdit {
path: make_path_relative(file_path, worktree_path),
diffs,
changes,
}
}
Tool::Edit {
@@ -697,16 +698,16 @@ impl ToolUtils {
new_string,
..
} => {
let diffs = match (old_string, new_string) {
(Some(old), Some(new)) => vec![EditDiff::Replace {
old: old.clone(),
new: new.clone(),
let changes = match (old_string, new_string) {
(Some(old), Some(new)) => vec![FileChange::Edit {
unified_diff: create_unified_diff(file_path, old, new),
has_line_numbers: false,
}],
_ => Vec::new(),
};
ActionType::FileEdit {
path: make_path_relative(file_path, worktree_path),
diffs,
changes,
}
}
Tool::Bash { command, .. } => ActionType::CommandRun {

View File

@@ -5,14 +5,6 @@ pub mod plain_text_processor;
pub mod stderr_processor;
pub mod utils;
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
#[ts(export)]
#[serde(tag = "format", rename_all = "snake_case")]
pub enum EditDiff {
Unified { unified_diff: String },
Replace { old: String, new: String },
}
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
pub struct NormalizedConversation {
pub entries: Vec<NormalizedEntry>,
@@ -64,7 +56,7 @@ pub enum ActionType {
},
FileEdit {
path: String,
diffs: Vec<EditDiff>,
changes: Vec<FileChange>,
},
CommandRun {
command: String,
@@ -89,3 +81,21 @@ pub enum ActionType {
description: String,
},
}
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
#[serde(tag = "action", rename_all = "snake_case")]
pub enum FileChange {
/// Create a file if it doesn't exist, and overwrite its content.
Write { content: String },
/// Delete a file.
Delete,
/// Rename a file.
Rename { new_path: String },
/// Edit a file with a unified diff.
Edit {
/// Unified diff containing file header and hunks.
unified_diff: String,
/// Whether line number in the hunks are reliable.
has_line_numbers: bool,
},
}

View File

@@ -80,7 +80,7 @@ fn generate_types_content() -> String {
executors::logs::NormalizedConversation::decl(),
executors::logs::NormalizedEntry::decl(),
executors::logs::NormalizedEntryType::decl(),
executors::logs::EditDiff::decl(),
executors::logs::FileChange::decl(),
executors::logs::ActionType::decl(),
executors::logs::TodoItem::decl(),
executors::logs::utils::patch::PatchType::decl(),

View File

@@ -29,3 +29,4 @@ futures = "0.3.31"
tokio-stream = { version = "0.1.17", features = ["sync"] }
shellexpand = "3.1.1"
which = "8.0.0"
similar = "2"

View File

@@ -1,4 +1,5 @@
use serde::{Deserialize, Serialize};
use similar::{ChangeTag, TextDiff};
use ts_rs::TS;
// Structs compatable with props: https://github.com/MrWangJustToDo/git-diff-view
@@ -17,3 +18,178 @@ pub struct Diff {
pub new_file: Option<FileDiffDetails>,
pub hunks: Vec<String>,
}
// ==============================
// Unified diff utility functions
// ==============================
/// Converts a replace diff to a unified diff hunk without the hunk header.
/// The hunk returned will have valid hunk, and diff lines.
pub fn create_unified_diff_hunk(old: &str, new: &str) -> String {
// normalize ending line feed to optimize diff output
let mut old = old.to_string();
let mut new = new.to_string();
if !old.ends_with('\n') {
old.push('\n');
}
if !new.ends_with('\n') {
new.push('\n');
}
let diff = TextDiff::from_lines(&old, &new);
let mut out = String::new();
// We need a valud hunk header. assume lines are 0. but - + count will be correct.
let old_count = diff.old_slices().len();
let new_count = diff.new_slices().len();
out.push_str(&format!("@@ -0,{old_count} +0,{new_count} @@\n"));
for change in diff.iter_all_changes() {
let sign = match change.tag() {
ChangeTag::Equal => ' ',
ChangeTag::Delete => '-',
ChangeTag::Insert => '+',
};
let val = change.value();
out.push(sign);
out.push_str(val);
}
out
}
/// Creates a full unified diff with the file path in the header.
pub fn create_unified_diff(file_path: &str, old: &str, new: &str) -> String {
let mut out = String::new();
out.push_str(format!("--- a/{file_path}\n+++ b/{file_path}\n").as_str());
out.push_str(&create_unified_diff_hunk(old, new));
out
}
/// Extracts unified diff hunks from a string containing a full unified diff.
/// Tolerates non-diff lines and missing `@@`` hunk headers.
pub fn extract_unified_diff_hunks(unified_diff: &str) -> Vec<String> {
let lines = unified_diff.split_inclusive('\n').collect::<Vec<_>>();
if !lines.iter().any(|l| l.starts_with("@@")) {
// No @@ hunk headers: treat as a single hunk
let hunk = lines
.iter()
.copied()
.filter(|line| line.starts_with([' ', '+', '-']))
.collect::<String>();
return if hunk.is_empty() {
vec![]
} else {
vec!["@@\n".to_string() + &hunk]
};
}
let mut hunks = vec![];
let mut current_hunk: Option<String> = None;
// Collect hunks starting with @@ headers
for line in lines {
if line.starts_with("@@") {
// new hunk starts
if let Some(hunk) = current_hunk.take() {
// flush current hunk
if !hunk.is_empty() {
hunks.push(hunk);
}
}
current_hunk = Some(line.to_string());
} else if let Some(ref mut hunk) = current_hunk {
if line.starts_with([' ', '+', '-']) {
// hunk content
hunk.push_str(line);
} else {
// unkown line, flush current hunk
if !hunk.is_empty() {
hunks.push(hunk.clone());
}
current_hunk = None;
}
}
}
// we have reached the end. flush the last hunk if it exists
if let Some(hunk) = current_hunk
&& !hunk.is_empty()
{
hunks.push(hunk);
}
// Fix hunk headers if they are empty @@\n
hunks = fix_hunk_headers(hunks);
hunks
}
// Helper function to ensure valid hunk headers
fn fix_hunk_headers(hunks: Vec<String>) -> Vec<String> {
if hunks.is_empty() {
return hunks;
}
let mut new_hunks = Vec::new();
// if hunk header is empty @@\n, ten we need to replace it with a valid header
for hunk in hunks {
let mut lines = hunk
.split_inclusive('\n')
.map(str::to_string)
.collect::<Vec<_>>();
if lines.len() < 2 {
// empty hunk, skip
continue;
}
let header = &lines[0];
if !header.starts_with("@@") {
// no header, skip
continue;
}
if header.trim() == "@@" {
// empty header, replace with a valid one
lines.remove(0);
let old_count = lines
.iter()
.filter(|line| line.starts_with(['-', ' ']))
.count();
let new_count = lines
.iter()
.filter(|line| line.starts_with(['+', ' ']))
.count();
let new_header = format!("@@ -0,{old_count} +0,{new_count} @@");
lines.insert(0, new_header);
new_hunks.push(lines.join(""));
} else {
// valid header, keep as is
new_hunks.push(hunk);
}
}
new_hunks
}
/// Creates a full unified diff with the file path in the header,
pub fn concatenate_diff_hunks(file_path: &str, hunks: &[String]) -> String {
let mut unified_diff = String::new();
let header = format!("--- a/{file_path}\n+++ b/{file_path}\n");
unified_diff.push_str(&header);
if !hunks.is_empty() {
unified_diff.push_str(hunks.join("\n").as_str());
if !unified_diff.ends_with('\n') {
unified_diff.push('\n');
}
}
unified_diff
}

View File

@@ -1,4 +1,4 @@
use std::path::Path;
use std::path::{Path, PathBuf};
/// Convert absolute paths to relative paths based on worktree path
/// This is a robust implementation that handles symlinks and edge cases
@@ -13,21 +13,25 @@ pub fn make_path_relative(path: &str, worktree_path: &str) -> String {
return path.to_string();
}
// Try to make path relative to the worktree path
match path_obj.strip_prefix(worktree_path_obj) {
Ok(relative_path) => {
let path_obj = normalize_macos_private_alias(path_obj);
let worktree_path_obj = normalize_macos_private_alias(worktree_path_obj);
if let Ok(relative_path) = path_obj.strip_prefix(&worktree_path_obj) {
let result = relative_path.to_string_lossy().to_string();
tracing::debug!("Successfully made relative: '{}' -> '{}'", path, result);
if result.is_empty() {
".".to_string()
} else {
result
return ".".to_string();
}
return result;
}
Err(_) => {
// Handle symlinks by resolving canonical paths
let canonical_path = std::fs::canonicalize(path);
let canonical_worktree = std::fs::canonicalize(worktree_path);
if !path_obj.exists() || !worktree_path_obj.exists() {
return path.to_string();
}
// canonicalize may fail if paths don't exist
let canonical_path = std::fs::canonicalize(&path_obj);
let canonical_worktree = std::fs::canonicalize(&worktree_path_obj);
match (canonical_path, canonical_worktree) {
(Ok(canon_path), Ok(canon_worktree)) => {
@@ -48,10 +52,9 @@ pub fn make_path_relative(path: &str, worktree_path: &str) -> String {
result
);
if result.is_empty() {
".".to_string()
} else {
result
return ".".to_string();
}
result
}
Err(e) => {
tracing::warn!(
@@ -74,7 +77,28 @@ pub fn make_path_relative(path: &str, worktree_path: &str) -> String {
}
}
}
/// Normalize macOS prefix /private/var/ and /private/tmp/ to their public aliases without resolving paths.
/// This allows prefix normalization to work when the full paths don't exist.
fn normalize_macos_private_alias<P: AsRef<Path>>(p: P) -> PathBuf {
let p = p.as_ref();
if cfg!(target_os = "macos")
&& let Some(s) = p.to_str()
{
if s == "/private/var" {
return PathBuf::from("/var");
}
if let Some(rest) = s.strip_prefix("/private/var/") {
return PathBuf::from(format!("/var/{rest}"));
}
if s == "/private/tmp" {
return PathBuf::from("/tmp");
}
if let Some(rest) = s.strip_prefix("/private/tmp/") {
return PathBuf::from(format!("/tmp/{rest}"));
}
}
p.to_path_buf()
}
pub fn get_vibe_kanban_temp_dir() -> std::path::PathBuf {
@@ -125,4 +149,27 @@ mod tests {
"/other/path/file.js"
);
}
#[cfg(target_os = "macos")]
#[test]
fn test_make_path_relative_macos_private_alias() {
// Simulate a worktree under /var with a path reported under /private/var
let worktree = "/var/folders/zz/abc123/T/vibe-kanban-dev/worktrees/vk-test";
let path_under_private = format!(
"/private/var{}/hello-world.txt",
worktree.strip_prefix("/var").unwrap()
);
assert_eq!(
make_path_relative(&path_under_private, worktree),
"hello-world.txt"
);
// Also handle the inverse: worktree under /private and path under /var
let worktree_private = format!("/private{}", worktree);
let path_under_var = format!("{}/hello-world.txt", worktree);
assert_eq!(
make_path_relative(&path_under_var, &worktree_private),
"hello-world.txt"
);
}
}

View File

@@ -7,7 +7,7 @@ import {
CheckSquare,
ChevronRight,
ChevronUp,
// Edit,
Edit,
Eye,
Globe,
Plus,
@@ -16,7 +16,12 @@ import {
Terminal,
User,
} from 'lucide-react';
import { NormalizedEntry, type NormalizedEntryType } from 'shared/types.ts';
import {
NormalizedEntry,
type NormalizedEntryType,
type ActionType,
} from 'shared/types.ts';
import FileChangeRenderer from './FileChangeRenderer';
type Props = {
entry: NormalizedEntry;
@@ -58,8 +63,8 @@ const getEntryIcon = (entryType: NormalizedEntryType) => {
if (action_type.action === 'file_read') {
return <Eye className="h-4 w-4 text-orange-600" />;
// } else if (action_type.action === 'file_edit') {
// return <Edit className="h-4 w-4 text-red-600" />;
} else if (action_type.action === 'file_edit') {
return <Edit className="h-4 w-4 text-red-600" />;
} else if (action_type.action === 'command_run') {
return <Terminal className="h-4 w-4 text-yellow-600" />;
} else if (action_type.action === 'search') {
@@ -146,6 +151,15 @@ function DisplayConversationEntry({ entry, index }: Props) {
const isExpanded = expandedErrors.has(index);
const hasMultipleLines = isErrorMessage && entry.content.includes('\n');
const fileEdit =
entry.entry_type.type === 'tool_use' &&
entry.entry_type.action_type.action === 'file_edit'
? (entry.entry_type.action_type as Extract<
ActionType,
{ action: 'file_edit' }
>)
: null;
return (
<div key={index} className="px-4 py-1">
<div className="flex items-start gap-3">
@@ -209,6 +223,16 @@ function DisplayConversationEntry({ entry, index }: Props) {
)}
</div>
)}
{fileEdit &&
Array.isArray(fileEdit.changes) &&
fileEdit.changes.map((change, idx) => (
<FileChangeRenderer
key={idx}
path={fileEdit.path}
change={change}
/>
))}
</div>
</div>
</div>

View File

@@ -0,0 +1,131 @@
import { useMemo, useState } from 'react';
import {
DiffView,
DiffModeEnum,
DiffLineType,
parseInstance,
} from '@git-diff-view/react';
import { ThemeMode } from 'shared/types';
import { ChevronRight, ChevronUp } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { useConfig } from '@/components/config-provider';
import { getHighLightLanguageFromPath } from '@/utils/extToLanguage';
import '@/styles/diff-style-overrides.css';
import '@/styles/edit-diff-overrides.css';
type Props = {
path: string;
unifiedDiff: string;
hasLineNumbers: boolean;
};
/**
* Process hunks for @git-diff-view/react
* - Extract additions/deletions for display
* - Decide whether to hide line numbers based on backend data
*/
function processUnifiedDiff(unifiedDiff: string, hasLineNumbers: boolean) {
const totalHunks = unifiedDiff
.split('\n')
.filter((line) => line.startsWith('@@ ')).length;
// Hide line numbers when backend says they are unreliable
const hideNums = !hasLineNumbers;
// Pre-compute additions/deletions using the library parser so counts are available while collapsed
let additions = 0;
let deletions = 0;
try {
const parsed = parseInstance.parse(unifiedDiff);
for (const h of parsed.hunks) {
for (const line of h.lines) {
if (line.type === DiffLineType.Add) additions++;
else if (line.type === DiffLineType.Delete) deletions++;
}
}
} catch (err) {
console.error('Failed to parse diff hunks:', err);
}
return {
hunks: [unifiedDiff],
hideLineNumbers: hideNums,
totalHunks,
additions,
deletions,
};
}
function EditDiffRenderer({ path, unifiedDiff, hasLineNumbers }: Props) {
const { config } = useConfig();
const [expanded, setExpanded] = useState(false);
let theme: 'light' | 'dark' | undefined = 'light';
if (config?.theme === ThemeMode.DARK) {
theme = 'dark';
}
const { hunks, hideLineNumbers, totalHunks, additions, deletions } = useMemo(
() => processUnifiedDiff(unifiedDiff, hasLineNumbers),
[path, unifiedDiff, hasLineNumbers]
);
const hideLineNumbersClass = hideLineNumbers ? ' edit-diff-hide-nums' : '';
const diffData = useMemo(() => {
const lang = getHighLightLanguageFromPath(path) || 'plaintext';
return {
hunks,
oldFile: { fileName: path, fileLang: lang },
newFile: { fileName: path, fileLang: lang },
};
}, [hunks, path]);
return (
<div className="my-4 border">
<div className="flex items-center px-4 py-2">
<Button
variant="ghost"
size="sm"
onClick={() => setExpanded((e) => !e)}
className="h-6 w-6 p-0 mr-2"
title={expanded ? 'Collapse' : 'Expand'}
aria-expanded={expanded}
>
{expanded ? (
<ChevronUp className="h-3 w-3" />
) : (
<ChevronRight className="h-3 w-3" />
)}
</Button>
<p
className="text-xs font-mono overflow-x-auto flex-1"
style={{ color: 'hsl(var(--muted-foreground) / 0.7)' }}
>
{path}{' '}
<span style={{ color: 'hsl(var(--console-success))' }}>
+{additions}
</span>{' '}
<span style={{ color: 'hsl(var(--console-error))' }}>
-{deletions}
</span>
</p>
</div>
{expanded && totalHunks > 0 && (
<div className={'mt-2' + hideLineNumbersClass}>
<DiffView
data={diffData}
diffViewWrap={false}
diffViewTheme={theme}
diffViewHighlight
diffViewMode={DiffModeEnum.Unified}
diffViewFontSize={12}
/>
</div>
)}
</div>
);
}
export default EditDiffRenderer;

View File

@@ -0,0 +1,152 @@
import { useState } from 'react';
import { ThemeMode, type FileChange } from 'shared/types';
import { useConfig } from '@/components/config-provider';
import { Button } from '@/components/ui/button';
import {
ChevronRight,
ChevronUp,
Trash2,
ArrowLeftRight,
ArrowRight,
} from 'lucide-react';
import { getHighLightLanguageFromPath } from '@/utils/extToLanguage';
import EditDiffRenderer from './EditDiffRenderer';
import FileContentView from './FileContentView';
import '@/styles/diff-style-overrides.css';
type Props = {
path: string;
change: FileChange;
};
function isWrite(
change: FileChange
): change is Extract<FileChange, { action: 'write'; content: string }> {
return change?.action === 'write';
}
function isDelete(
change: FileChange
): change is Extract<FileChange, { action: 'delete' }> {
return change?.action === 'delete';
}
function isRename(
change: FileChange
): change is Extract<FileChange, { action: 'rename'; new_path: string }> {
return change?.action === 'rename';
}
function isEdit(
change: FileChange
): change is Extract<FileChange, { action: 'edit' }> {
return change?.action === 'edit';
}
const FileChangeRenderer = ({ path, change }: Props) => {
const { config } = useConfig();
const [expanded, setExpanded] = useState(false);
let theme: 'light' | 'dark' | undefined = 'light';
if (config?.theme === ThemeMode.DARK) theme = 'dark';
// Edit: delegate to EditDiffRenderer for identical styling and behavior
if (isEdit(change)) {
return (
<EditDiffRenderer
path={path}
unifiedDiff={change.unified_diff}
hasLineNumbers={change.has_line_numbers}
/>
);
}
// Title row content and whether the row is expandable
const { titleNode, expandable } = (() => {
const commonTitleClass = 'text-xs font-mono overflow-x-auto flex-1';
const commonTitleStyle = {
color: 'hsl(var(--muted-foreground) / 0.7)',
};
if (isDelete(change)) {
return {
titleNode: (
<p className={commonTitleClass} style={commonTitleStyle}>
<Trash2 className="h-3 w-3 inline mr-1.5" aria-hidden />
Delete <span className="ml-1">{path}</span>
</p>
),
expandable: false,
};
}
if (isRename(change)) {
return {
titleNode: (
<p className={commonTitleClass} style={commonTitleStyle}>
<ArrowLeftRight className="h-3 w-3 inline mr-1.5" aria-hidden />
Rename <span className="ml-1">{path}</span>{' '}
<ArrowRight className="h-3 w-3 inline mx-1" aria-hidden />{' '}
<span>{change.new_path}</span>
</p>
),
expandable: false,
};
}
if (isWrite(change)) {
return {
titleNode: (
<p className={commonTitleClass} style={commonTitleStyle}>
Write to <span className="ml-1">{path}</span>
</p>
),
expandable: true,
};
}
// No fallback: render nothing for unknown change types
return {
titleNode: null,
expandable: false,
};
})();
// nothing to display
if (!titleNode) {
return null;
}
return (
<div className="my-4 border">
<div className="flex items-center px-4 py-2">
{expandable && (
<Button
variant="ghost"
size="sm"
onClick={() => setExpanded((e) => !e)}
className="h-6 w-6 p-0 mr-2"
title={expanded ? 'Collapse' : 'Expand'}
aria-expanded={expanded}
>
{expanded ? (
<ChevronUp className="h-3 w-3" />
) : (
<ChevronRight className="h-3 w-3" />
)}
</Button>
)}
{titleNode}
</div>
{/* Body */}
{isWrite(change) && expanded && (
<FileContentView
content={change.content}
lang={getHighLightLanguageFromPath(path)}
theme={theme}
/>
)}
</div>
);
};
export default FileChangeRenderer;

View File

@@ -0,0 +1,63 @@
import { useMemo } from 'react';
import { DiffView, DiffModeEnum } from '@git-diff-view/react';
import { generateDiffFile } from '@git-diff-view/file';
import '@/styles/diff-style-overrides.css';
import '@/styles/edit-diff-overrides.css';
type Props = {
content: string;
lang: string | null;
theme?: 'light' | 'dark';
className?: string;
};
/**
* View syntax highlighted file content.
*/
function FileContentView({ content, lang, theme, className }: Props) {
// Uses the syntax highlighter from @git-diff-view/react without any diff-related features.
// This allows uniform styling with EditDiffRenderer.
const diffFile = useMemo(() => {
try {
const instance = generateDiffFile(
'', // old file
'', // old content (empty)
'', // new file
content, // new content
'', // old lang
lang || 'plaintext' // new lang
);
instance.initRaw();
return instance;
} catch {
return null;
}
}, [content, lang]);
return (
<div
className={['plain-file-content edit-diff-hide-nums', className]
.filter(Boolean)
.join(' ')}
>
<div className="px-4 py-2">
{diffFile ? (
<DiffView
diffFile={diffFile}
diffViewWrap={false}
diffViewTheme={theme}
diffViewHighlight
diffViewMode={DiffModeEnum.Unified}
diffViewFontSize={12}
/>
) : (
<pre className="text-xs font-mono overflow-x-auto whitespace-pre">
{content}
</pre>
)}
</div>
</div>
);
}
export default FileContentView;

View File

@@ -0,0 +1,32 @@
/* Hide line numbers for replace (old/new) diffs rendered via DiffView */
.edit-diff-hide-nums .diff-line-old-num,
.edit-diff-hide-nums .diff-line-new-num,
.edit-diff-hide-nums .diff-line-num {
display: none !important;
}
/* Ensure number gutters don't consume space when hidden */
.edit-diff-hide-nums .diff-line-old-num + .diff-line-old-content,
.edit-diff-hide-nums .diff-line-new-num + .diff-line-new-content,
.edit-diff-hide-nums .diff-line-num + .diff-line-content {
padding-left: 0 !important;
}
.plain-file-content .diff-style-root {
/* neutralize addition backgrounds */
--diff-add-content--: hsl(var(--background));
--diff-add-content-highlight--: hsl(var(--background));
}
.plain-file-content .diff-line-content-operator {
display: none !important; /* hide leading '+' operator column */
}
.plain-file-content .diff-line-content-item {
padding-left: 0 !important; /* remove indent left by operator column */
}
/* hide unified hunk header rows (e.g. @@ -1,+n @@) */
.plain-file-content .diff-line-hunk-content {
display: none !important;
}

View File

@@ -174,9 +174,17 @@ export type NormalizedEntry = { timestamp: string | null, entry_type: Normalized
export type NormalizedEntryType = { "type": "user_message" } | { "type": "assistant_message" } | { "type": "tool_use", tool_name: string, action_type: ActionType, } | { "type": "system_message" } | { "type": "error_message" } | { "type": "thinking" };
export type EditDiff = { "format": "unified", unified_diff: string, } | { "format": "replace", old: string, new: string, };
export type FileChange = { "action": "write", content: string, } | { "action": "delete" } | { "action": "rename", new_path: string, } | { "action": "edit",
/**
* Unified diff containing file header and hunks.
*/
unified_diff: string,
/**
* Whether line number in the hunks are reliable.
*/
has_line_numbers: boolean, };
export type ActionType = { "action": "file_read", path: string, } | { "action": "file_edit", path: string, diffs: Array<EditDiff>, } | { "action": "command_run", command: string, } | { "action": "search", query: string, } | { "action": "web_fetch", url: string, } | { "action": "task_create", description: string, } | { "action": "plan_presentation", plan: string, } | { "action": "todo_management", todos: Array<TodoItem>, operation: string, } | { "action": "other", description: string, };
export type ActionType = { "action": "file_read", path: string, } | { "action": "file_edit", path: string, changes: Array<FileChange>, } | { "action": "command_run", command: string, } | { "action": "search", query: string, } | { "action": "web_fetch", url: string, } | { "action": "task_create", description: string, } | { "action": "plan_presentation", plan: string, } | { "action": "todo_management", todos: Array<TodoItem>, operation: string, } | { "action": "other", description: string, };
export type TodoItem = { content: string, status: string, priority: string | null, };