Display edit diffs (#469)
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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, ¤t_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(),
|
||||
¤t_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,
|
||||
|
||||
@@ -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}`"),
|
||||
)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
32
frontend/src/styles/edit-diff-overrides.css
Normal file
32
frontend/src/styles/edit-diff-overrides.css
Normal 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;
|
||||
}
|
||||
@@ -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, };
|
||||
|
||||
|
||||
Reference in New Issue
Block a user