Merge branch 'main' into cline
This commit is contained in:
@@ -514,10 +514,11 @@ async fn run_connection(
|
|||||||
let conn_id = conn_id.clone();
|
let conn_id = conn_id.clone();
|
||||||
let handle = handle.clone();
|
let handle = handle.clone();
|
||||||
let perms = perms.clone();
|
let perms = perms.clone();
|
||||||
|
let perm_cwd = cwd_string.clone();
|
||||||
async move |req: RequestPermissionRequest,
|
async move |req: RequestPermissionRequest,
|
||||||
responder: Responder<RequestPermissionResponse>,
|
responder: Responder<RequestPermissionResponse>,
|
||||||
_cx: ConnectionTo<Agent>| {
|
_cx: ConnectionTo<Agent>| {
|
||||||
handle_permission_request(&conn_id, &handle, &perms, req, responder).await;
|
handle_permission_request(&conn_id, &handle, &perms, &perm_cwd, req, responder).await;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -689,7 +690,7 @@ async fn run_connection(
|
|||||||
notif.update,
|
notif.update,
|
||||||
SessionUpdate::AvailableCommandsUpdate(_)
|
SessionUpdate::AvailableCommandsUpdate(_)
|
||||||
) {
|
) {
|
||||||
emit_conversation_update(&cid, &h, notif.update);
|
emit_conversation_update(&cid, &h, notif.update, None);
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
})
|
})
|
||||||
@@ -870,6 +871,7 @@ async fn handle_permission_request(
|
|||||||
conn_id: &str,
|
conn_id: &str,
|
||||||
handle: &tauri::AppHandle,
|
handle: &tauri::AppHandle,
|
||||||
perms: &PendingPermissions,
|
perms: &PendingPermissions,
|
||||||
|
cwd: &str,
|
||||||
req: RequestPermissionRequest,
|
req: RequestPermissionRequest,
|
||||||
responder: Responder<RequestPermissionResponse>,
|
responder: Responder<RequestPermissionResponse>,
|
||||||
) {
|
) {
|
||||||
@@ -891,7 +893,38 @@ async fn handle_permission_request(
|
|||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
let tool_call_value = serde_json::to_value(&req.tool_call).unwrap_or_default();
|
let mut tool_call_value = serde_json::to_value(&req.tool_call).unwrap_or_default();
|
||||||
|
|
||||||
|
// Resolve line numbers in rawInput for edit tool permission requests
|
||||||
|
if let Some(obj) = tool_call_value.as_object_mut() {
|
||||||
|
let key = ["rawInput", "raw_input"]
|
||||||
|
.into_iter()
|
||||||
|
.find(|k| obj.contains_key(*k));
|
||||||
|
if let Some(key) = key {
|
||||||
|
match obj.get_mut(key) {
|
||||||
|
// rawInput is a JSON object: inject _start_line in place
|
||||||
|
Some(v) if v.is_object() => {
|
||||||
|
inject_start_line(v, Some(cwd));
|
||||||
|
}
|
||||||
|
// rawInput is a JSON string: parse, inject, write back as object
|
||||||
|
Some(serde_json::Value::String(text)) => {
|
||||||
|
let text = text.clone();
|
||||||
|
if let Ok(mut parsed) = serde_json::from_str::<serde_json::Value>(&text) {
|
||||||
|
if inject_start_line(&mut parsed, Some(cwd)) {
|
||||||
|
obj.insert(key.to_string(), parsed);
|
||||||
|
}
|
||||||
|
} else if text.contains("@@\n") || text.contains("@@\r\n") {
|
||||||
|
if let Some(resolved) =
|
||||||
|
crate::parsers::resolve_patch_text(&text, Some(cwd))
|
||||||
|
{
|
||||||
|
obj.insert(key.to_string(), serde_json::Value::String(resolved));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
perms.lock().await.insert(request_id.clone(), responder);
|
perms.lock().await.insert(request_id.clone(), responder);
|
||||||
|
|
||||||
@@ -1437,10 +1470,11 @@ async fn run_conversation_loop<'a>(
|
|||||||
Ok(SessionMessage::SessionMessage(dispatch)) => {
|
Ok(SessionMessage::SessionMessage(dispatch)) => {
|
||||||
let cid = conn_id.to_string();
|
let cid = conn_id.to_string();
|
||||||
let h = handle.clone();
|
let h = handle.clone();
|
||||||
|
let cwd_opt = Some(cwd);
|
||||||
let _ = MatchDispatch::new(dispatch)
|
let _ = MatchDispatch::new(dispatch)
|
||||||
.if_notification(
|
.if_notification(
|
||||||
async |notif: SessionNotification| {
|
async |notif: SessionNotification| {
|
||||||
emit_conversation_update(&cid, &h, notif.update);
|
emit_conversation_update(&cid, &h, notif.update, cwd_opt);
|
||||||
Ok(())
|
Ok(())
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -1520,6 +1554,7 @@ async fn run_conversation_loop<'a>(
|
|||||||
let h = handle.clone();
|
let h = handle.clone();
|
||||||
let runtime = terminal_runtime.clone();
|
let runtime = terminal_runtime.clone();
|
||||||
let session_id = sid.clone();
|
let session_id = sid.clone();
|
||||||
|
let cwd_opt = Some(cwd);
|
||||||
if let Err(e) = MatchDispatch::new(dispatch)
|
if let Err(e) = MatchDispatch::new(dispatch)
|
||||||
.if_notification(
|
.if_notification(
|
||||||
async |notif: SessionNotification| {
|
async |notif: SessionNotification| {
|
||||||
@@ -1527,7 +1562,7 @@ async fn run_conversation_loop<'a>(
|
|||||||
¬if.update,
|
¬if.update,
|
||||||
&mut tracked_terminal_tool_calls,
|
&mut tracked_terminal_tool_calls,
|
||||||
);
|
);
|
||||||
emit_conversation_update(&cid, &h, notif.update);
|
emit_conversation_update(&cid, &h, notif.update, cwd_opt);
|
||||||
if should_poll_now {
|
if should_poll_now {
|
||||||
poll_tracked_terminal_tool_calls(
|
poll_tracked_terminal_tool_calls(
|
||||||
runtime.as_ref(),
|
runtime.as_ref(),
|
||||||
@@ -1894,6 +1929,71 @@ fn serialize_tool_call_content(content: &[ToolCallContent]) -> Option<String> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// If the output looks like numbered lines (` 115→content`), strip them
|
||||||
|
/// and return `{"start_line":N,"content":"..."}` — same as the historical path.
|
||||||
|
fn structurize_live_output(text: &str) -> String {
|
||||||
|
if let Some(json) = crate::parsers::strip_numbered_lines(text) {
|
||||||
|
return json;
|
||||||
|
}
|
||||||
|
text.to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolve line numbers for live tool call input.
|
||||||
|
///
|
||||||
|
/// Resolve line numbers for live tool call input (string form).
|
||||||
|
///
|
||||||
|
/// - For apply_patch with bare `@@`: resolve line numbers in place.
|
||||||
|
/// - For canonical edit JSON: inject `_start_line`.
|
||||||
|
fn resolve_live_tool_input(text: &str, cwd: Option<&str>) -> String {
|
||||||
|
if text.contains("@@\n") || text.contains("@@\r\n") {
|
||||||
|
if let Some(resolved) = crate::parsers::resolve_patch_text(text, cwd) {
|
||||||
|
return resolved;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Ok(mut parsed) = serde_json::from_str::<serde_json::Value>(text) {
|
||||||
|
if inject_start_line(&mut parsed, cwd) {
|
||||||
|
return parsed.to_string();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
text.to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Try to inject `_start_line` into a JSON object with `file_path` + `old_string`.
|
||||||
|
/// Returns true if injected.
|
||||||
|
fn inject_start_line(value: &mut serde_json::Value, cwd: Option<&str>) -> bool {
|
||||||
|
let obj = match value.as_object_mut() {
|
||||||
|
Some(o) => o,
|
||||||
|
None => return false,
|
||||||
|
};
|
||||||
|
let fp = obj
|
||||||
|
.get("file_path")
|
||||||
|
.or_else(|| obj.get("path"))
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.map(|s| s.to_string());
|
||||||
|
let old_str = obj
|
||||||
|
.get("old_string")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.map(|s| s.to_string());
|
||||||
|
if let (Some(fp), Some(old_str)) = (fp, old_str) {
|
||||||
|
if let Some(sl) = find_string_start_line(&fp, &old_str, cwd) {
|
||||||
|
obj.insert("_start_line".to_string(), serde_json::json!(sl));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Find the 1-based start line of `needle` in the file at `path`.
|
||||||
|
fn find_string_start_line(path: &str, needle: &str, cwd: Option<&str>) -> Option<u64> {
|
||||||
|
if needle.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let file_lines = crate::parsers::load_file_lines(path, cwd)?;
|
||||||
|
let file_content = file_lines.join("\n");
|
||||||
|
let byte_offset = file_content.find(needle)?;
|
||||||
|
Some(file_content[..byte_offset].matches('\n').count() as u64 + 1)
|
||||||
|
}
|
||||||
|
|
||||||
fn json_value_to_text(val: &Option<serde_json::Value>) -> Option<String> {
|
fn json_value_to_text(val: &Option<serde_json::Value>) -> Option<String> {
|
||||||
match val {
|
match val {
|
||||||
Some(serde_json::Value::String(text)) => Some(text.clone()),
|
Some(serde_json::Value::String(text)) => Some(text.clone()),
|
||||||
@@ -1938,6 +2038,7 @@ fn emit_conversation_update(
|
|||||||
connection_id: &str,
|
connection_id: &str,
|
||||||
app_handle: &tauri::AppHandle,
|
app_handle: &tauri::AppHandle,
|
||||||
update: SessionUpdate,
|
update: SessionUpdate,
|
||||||
|
cwd: Option<&str>,
|
||||||
) {
|
) {
|
||||||
match update {
|
match update {
|
||||||
SessionUpdate::UserMessageChunk(_) => {
|
SessionUpdate::UserMessageChunk(_) => {
|
||||||
@@ -1978,8 +2079,10 @@ fn emit_conversation_update(
|
|||||||
}
|
}
|
||||||
SessionUpdate::ToolCall(tc) => {
|
SessionUpdate::ToolCall(tc) => {
|
||||||
let content = serialize_tool_call_content(&tc.content);
|
let content = serialize_tool_call_content(&tc.content);
|
||||||
let raw_input = json_value_to_text(&tc.raw_input);
|
let raw_input = json_value_to_text(&tc.raw_input)
|
||||||
let raw_output = json_value_to_text(&tc.raw_output);
|
.map(|text| resolve_live_tool_input(&text, cwd));
|
||||||
|
let raw_output = json_value_to_text(&tc.raw_output)
|
||||||
|
.map(|text| structurize_live_output(&text));
|
||||||
crate::web::event_bridge::emit_event(
|
crate::web::event_bridge::emit_event(
|
||||||
app_handle,
|
app_handle,
|
||||||
"acp://event",
|
"acp://event",
|
||||||
@@ -2001,8 +2104,10 @@ fn emit_conversation_update(
|
|||||||
.content
|
.content
|
||||||
.as_deref()
|
.as_deref()
|
||||||
.and_then(serialize_tool_call_content);
|
.and_then(serialize_tool_call_content);
|
||||||
let raw_input = json_value_to_text(&tcu.fields.raw_input);
|
let raw_input = json_value_to_text(&tcu.fields.raw_input)
|
||||||
let raw_output = json_value_to_text(&tcu.fields.raw_output);
|
.map(|text| resolve_live_tool_input(&text, cwd));
|
||||||
|
let raw_output = json_value_to_text(&tcu.fields.raw_output)
|
||||||
|
.map(|text| structurize_live_output(&text));
|
||||||
crate::web::event_bridge::emit_event(
|
crate::web::event_bridge::emit_event(
|
||||||
app_handle,
|
app_handle,
|
||||||
"acp://event",
|
"acp://event",
|
||||||
|
|||||||
@@ -53,6 +53,46 @@ fn strip_system_tags(text: &str) -> Option<String> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Check if a JSONL entry is a system meta message (isMeta: true).
|
/// Check if a JSONL entry is a system meta message (isMeta: true).
|
||||||
|
/// Rebuild a standard unified diff from `toolUseResult.structuredPatch`.
|
||||||
|
///
|
||||||
|
/// Each hunk in `structuredPatch` has `oldStart`, `oldLines`, `newStart`,
|
||||||
|
/// `newLines`, and `lines` (prefixed with ` `, `+`, or `-`).
|
||||||
|
fn rebuild_diff_from_structured_patch(
|
||||||
|
file_path: &str,
|
||||||
|
structured_patch: &serde_json::Value,
|
||||||
|
) -> Option<String> {
|
||||||
|
let hunks = structured_patch.as_array()?;
|
||||||
|
if hunks.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut output = String::new();
|
||||||
|
output.push_str(&format!("--- a/{}\n+++ b/{}\n", file_path, file_path));
|
||||||
|
|
||||||
|
for hunk in hunks {
|
||||||
|
let old_start = hunk.get("oldStart").and_then(|v| v.as_u64()).unwrap_or(1);
|
||||||
|
let old_lines = hunk.get("oldLines").and_then(|v| v.as_u64()).unwrap_or(0);
|
||||||
|
let new_start = hunk.get("newStart").and_then(|v| v.as_u64()).unwrap_or(1);
|
||||||
|
let new_lines = hunk.get("newLines").and_then(|v| v.as_u64()).unwrap_or(0);
|
||||||
|
|
||||||
|
output.push_str(&format!(
|
||||||
|
"@@ -{},{} +{},{} @@\n",
|
||||||
|
old_start, old_lines, new_start, new_lines
|
||||||
|
));
|
||||||
|
|
||||||
|
if let Some(lines) = hunk.get("lines").and_then(|v| v.as_array()) {
|
||||||
|
for line in lines {
|
||||||
|
if let Some(text) = line.as_str() {
|
||||||
|
output.push_str(text);
|
||||||
|
output.push('\n');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Some(output)
|
||||||
|
}
|
||||||
|
|
||||||
fn is_meta_message(value: &serde_json::Value) -> bool {
|
fn is_meta_message(value: &serde_json::Value) -> bool {
|
||||||
value
|
value
|
||||||
.get("isMeta")
|
.get("isMeta")
|
||||||
@@ -489,7 +529,7 @@ impl ClaudeParser {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
"user" => {
|
"user" => {
|
||||||
let content = extract_user_content(&value);
|
let mut content = extract_user_content(&value);
|
||||||
|
|
||||||
// Skip user messages that are empty after system tag stripping
|
// Skip user messages that are empty after system tag stripping
|
||||||
if content.is_empty() {
|
if content.is_empty() {
|
||||||
@@ -518,6 +558,31 @@ impl ClaudeParser {
|
|||||||
MessageRole::User
|
MessageRole::User
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Check toolUseResult.structuredPatch for real line numbers
|
||||||
|
if let Some(tur) = value.get("toolUseResult") {
|
||||||
|
if let Some(sp) = tur.get("structuredPatch") {
|
||||||
|
let fp = tur
|
||||||
|
.get("filePath")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.unwrap_or("file");
|
||||||
|
if let Some(diff) = rebuild_diff_from_structured_patch(fp, sp) {
|
||||||
|
// Find the matching ToolResult in this user message's content
|
||||||
|
// and replace its output_preview with the real diff
|
||||||
|
for block in content.iter_mut() {
|
||||||
|
if let ContentBlock::ToolResult {
|
||||||
|
ref mut output_preview,
|
||||||
|
is_error: false,
|
||||||
|
..
|
||||||
|
} = block
|
||||||
|
{
|
||||||
|
*output_preview = Some(diff.clone());
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
messages.push(UnifiedMessage {
|
messages.push(UnifiedMessage {
|
||||||
id: uuid,
|
id: uuid,
|
||||||
role,
|
role,
|
||||||
@@ -574,6 +639,160 @@ impl ClaudeParser {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
"tool_use" => {
|
||||||
|
// Top-level tool_use record (Claude Code JSONL format)
|
||||||
|
let timestamp = parse_timestamp(&value).unwrap_or_else(Utc::now);
|
||||||
|
let tool_name = value
|
||||||
|
.get("tool_name")
|
||||||
|
.and_then(|n| n.as_str())
|
||||||
|
.unwrap_or("unknown")
|
||||||
|
.to_string();
|
||||||
|
let input_preview = value.get("tool_input").map(|i| i.to_string());
|
||||||
|
let synthetic_id = format!("tl-tool-{}", messages.len());
|
||||||
|
|
||||||
|
// Attach to last assistant message, or create a synthetic one
|
||||||
|
if let Some(last) = messages
|
||||||
|
.iter_mut()
|
||||||
|
.rev()
|
||||||
|
.find(|m| matches!(m.role, MessageRole::Assistant))
|
||||||
|
{
|
||||||
|
last.content.push(ContentBlock::ToolUse {
|
||||||
|
tool_use_id: Some(synthetic_id),
|
||||||
|
tool_name,
|
||||||
|
input_preview,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
messages.push(UnifiedMessage {
|
||||||
|
id: format!("synth-assistant-{}", messages.len()),
|
||||||
|
role: MessageRole::Assistant,
|
||||||
|
content: vec![ContentBlock::ToolUse {
|
||||||
|
tool_use_id: Some(synthetic_id),
|
||||||
|
tool_name,
|
||||||
|
input_preview,
|
||||||
|
}],
|
||||||
|
timestamp,
|
||||||
|
usage: None,
|
||||||
|
duration_ms: None,
|
||||||
|
model: None,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"tool_result" => {
|
||||||
|
// Top-level tool_result record (Claude Code JSONL format)
|
||||||
|
let tool_output = value.get("tool_output");
|
||||||
|
let tool_name = value
|
||||||
|
.get("tool_name")
|
||||||
|
.and_then(|n| n.as_str())
|
||||||
|
.unwrap_or("");
|
||||||
|
let is_error = tool_output
|
||||||
|
.and_then(|o| o.get("exit"))
|
||||||
|
.and_then(|e| e.as_i64())
|
||||||
|
.is_some_and(|code| code != 0);
|
||||||
|
|
||||||
|
// Extract output text: prefer "preview" (read), then "output" (bash)
|
||||||
|
let output_text = tool_output
|
||||||
|
.and_then(|o| {
|
||||||
|
o.get("preview")
|
||||||
|
.or_else(|| o.get("output"))
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
})
|
||||||
|
.map(|s| s.to_string());
|
||||||
|
|
||||||
|
// Don't structurize here — `structurize_read_tool_output`
|
||||||
|
// will handle Read tool output uniformly after grouping.
|
||||||
|
let output_preview = output_text;
|
||||||
|
|
||||||
|
// Find the matching ToolUse by tool_name (reverse scan so the
|
||||||
|
// most recent match wins), then fall back to the last ToolUse
|
||||||
|
// without a paired ToolResult yet.
|
||||||
|
let existing_result_ids: std::collections::HashSet<String> = messages
|
||||||
|
.iter()
|
||||||
|
.rev()
|
||||||
|
.find(|m| matches!(m.role, MessageRole::Assistant))
|
||||||
|
.map(|m| {
|
||||||
|
m.content
|
||||||
|
.iter()
|
||||||
|
.filter_map(|b| {
|
||||||
|
if let ContentBlock::ToolResult {
|
||||||
|
tool_use_id: Some(ref id),
|
||||||
|
..
|
||||||
|
} = b
|
||||||
|
{
|
||||||
|
Some(id.clone())
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
})
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
let matching_id = messages
|
||||||
|
.iter()
|
||||||
|
.rev()
|
||||||
|
.find(|m| matches!(m.role, MessageRole::Assistant))
|
||||||
|
.and_then(|m| {
|
||||||
|
// First: try to find an unpaired ToolUse with the same tool_name
|
||||||
|
let by_name = m.content.iter().rev().find_map(|b| {
|
||||||
|
if let ContentBlock::ToolUse {
|
||||||
|
tool_use_id: Some(ref id),
|
||||||
|
tool_name: ref tn,
|
||||||
|
..
|
||||||
|
} = b
|
||||||
|
{
|
||||||
|
if tn == tool_name
|
||||||
|
&& !existing_result_ids.contains(id)
|
||||||
|
{
|
||||||
|
return Some(id.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
});
|
||||||
|
if by_name.is_some() {
|
||||||
|
return by_name;
|
||||||
|
}
|
||||||
|
// Fallback: last unpaired ToolUse regardless of name
|
||||||
|
m.content.iter().rev().find_map(|b| {
|
||||||
|
if let ContentBlock::ToolUse {
|
||||||
|
tool_use_id: Some(ref id),
|
||||||
|
..
|
||||||
|
} = b
|
||||||
|
{
|
||||||
|
if !existing_result_ids.contains(id) {
|
||||||
|
return Some(id.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
// Append ToolResult to the same assistant message so they stay in the same turn
|
||||||
|
if let Some(last) = messages
|
||||||
|
.iter_mut()
|
||||||
|
.rev()
|
||||||
|
.find(|m| matches!(m.role, MessageRole::Assistant))
|
||||||
|
{
|
||||||
|
last.content.push(ContentBlock::ToolResult {
|
||||||
|
tool_use_id: matching_id,
|
||||||
|
output_preview,
|
||||||
|
is_error,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
messages.push(UnifiedMessage {
|
||||||
|
id: format!("synth-result-{}", messages.len()),
|
||||||
|
role: MessageRole::Assistant,
|
||||||
|
content: vec![ContentBlock::ToolResult {
|
||||||
|
tool_use_id: matching_id,
|
||||||
|
output_preview,
|
||||||
|
is_error,
|
||||||
|
}],
|
||||||
|
timestamp: parse_timestamp(&value).unwrap_or_else(Utc::now),
|
||||||
|
usage: None,
|
||||||
|
duration_ms: None,
|
||||||
|
model: None,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -583,6 +802,8 @@ impl ClaudeParser {
|
|||||||
|
|
||||||
let mut turns = group_into_turns(messages);
|
let mut turns = group_into_turns(messages);
|
||||||
super::relocate_orphaned_tool_results(&mut turns);
|
super::relocate_orphaned_tool_results(&mut turns);
|
||||||
|
super::structurize_read_tool_output(&mut turns);
|
||||||
|
super::resolve_patch_line_numbers(&mut turns, cwd.as_deref());
|
||||||
let context_window_used_tokens = latest_claude_context_window_used_tokens(&turns);
|
let context_window_used_tokens = latest_claude_context_window_used_tokens(&turns);
|
||||||
let context_window_max_tokens =
|
let context_window_max_tokens =
|
||||||
claude_context_window_max_tokens_for_model(model.as_deref());
|
claude_context_window_max_tokens_for_model(model.as_deref());
|
||||||
|
|||||||
@@ -773,6 +773,8 @@ impl CodexParser {
|
|||||||
|
|
||||||
let mut turns = group_into_turns(messages);
|
let mut turns = group_into_turns(messages);
|
||||||
super::relocate_orphaned_tool_results(&mut turns);
|
super::relocate_orphaned_tool_results(&mut turns);
|
||||||
|
super::structurize_read_tool_output(&mut turns);
|
||||||
|
super::resolve_patch_line_numbers(&mut turns, cwd.as_deref());
|
||||||
let mut session_stats = super::compute_session_stats(&turns);
|
let mut session_stats = super::compute_session_stats(&turns);
|
||||||
session_stats =
|
session_stats =
|
||||||
merge_codex_total_usage_stats(session_stats, latest_total_usage, latest_total_tokens);
|
merge_codex_total_usage_stats(session_stats, latest_total_usage, latest_total_tokens);
|
||||||
|
|||||||
@@ -556,6 +556,8 @@ impl GeminiParser {
|
|||||||
|
|
||||||
let mut turns = group_into_turns(messages);
|
let mut turns = group_into_turns(messages);
|
||||||
super::relocate_orphaned_tool_results(&mut turns);
|
super::relocate_orphaned_tool_results(&mut turns);
|
||||||
|
super::structurize_read_tool_output(&mut turns);
|
||||||
|
super::resolve_patch_line_numbers(&mut turns, summary.folder_path.as_deref());
|
||||||
summary.message_count = turns.len() as u32;
|
summary.message_count = turns.len() as u32;
|
||||||
summary.id = conversation_id.to_string();
|
summary.id = conversation_id.to_string();
|
||||||
let context_window_used_tokens = super::latest_turn_total_usage_tokens(&turns);
|
let context_window_used_tokens = super::latest_turn_total_usage_tokens(&turns);
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ pub mod gemini;
|
|||||||
pub mod openclaw;
|
pub mod openclaw;
|
||||||
pub mod opencode;
|
pub mod opencode;
|
||||||
|
|
||||||
use std::collections::HashMap;
|
use std::collections::{HashMap, HashSet};
|
||||||
use std::sync::OnceLock;
|
use std::sync::OnceLock;
|
||||||
|
|
||||||
use regex::Regex;
|
use regex::Regex;
|
||||||
@@ -293,6 +293,355 @@ pub fn relocate_orphaned_tool_results(turns: &mut Vec<MessageTurn>) {
|
|||||||
turns.retain(|turn| !turn.blocks.is_empty());
|
turns.retain(|turn| !turn.blocks.is_empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Convert Read tool output from numbered-line format to `{"start_line":N,"content":"..."}`.
|
||||||
|
///
|
||||||
|
/// Claude Code embeds line numbers in Read output like ` 115→content`.
|
||||||
|
/// This splits on the `→` delimiter (or tab for older `cat -n` format),
|
||||||
|
/// extracts the starting line number, and returns clean content.
|
||||||
|
pub fn structurize_read_tool_output(turns: &mut [MessageTurn]) {
|
||||||
|
let mut read_tool_ids: HashSet<String> = HashSet::new();
|
||||||
|
for turn in turns.iter() {
|
||||||
|
for block in &turn.blocks {
|
||||||
|
if let ContentBlock::ToolUse {
|
||||||
|
tool_use_id: Some(ref id),
|
||||||
|
ref tool_name,
|
||||||
|
..
|
||||||
|
} = block
|
||||||
|
{
|
||||||
|
let name = tool_name.to_lowercase();
|
||||||
|
if matches!(
|
||||||
|
name.as_str(),
|
||||||
|
"read" | "read_file" | "readfile" | "read file" | "cat" | "view"
|
||||||
|
) {
|
||||||
|
read_tool_ids.insert(id.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for turn in turns.iter_mut() {
|
||||||
|
for block in turn.blocks.iter_mut() {
|
||||||
|
let is_read_result = matches!(
|
||||||
|
block,
|
||||||
|
ContentBlock::ToolResult { tool_use_id: Some(ref id), .. }
|
||||||
|
if read_tool_ids.contains(id)
|
||||||
|
);
|
||||||
|
if !is_read_result {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if let ContentBlock::ToolResult {
|
||||||
|
ref mut output_preview,
|
||||||
|
..
|
||||||
|
} = block
|
||||||
|
{
|
||||||
|
if let Some(ref text) = output_preview {
|
||||||
|
if let Some(json) = strip_numbered_lines(text) {
|
||||||
|
*output_preview = Some(json);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Known delimiters between line number and content.
|
||||||
|
const LINE_NUM_DELIMITERS: &[&str] = &["→", "\t"];
|
||||||
|
|
||||||
|
/// Try to split a line at a known delimiter, returning (line_number, content).
|
||||||
|
fn split_line_number(line: &str) -> Option<(u64, &str)> {
|
||||||
|
for delim in LINE_NUM_DELIMITERS {
|
||||||
|
if let Some(pos) = line.find(delim) {
|
||||||
|
let prefix = line[..pos].trim();
|
||||||
|
if let Ok(num) = prefix.parse::<u64>() {
|
||||||
|
let content_start = pos + delim.len();
|
||||||
|
return Some((num, &line[content_start..]));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
/// If most lines have a recognized line-number prefix, strip them all
|
||||||
|
/// and return `{"start_line":N,"content":"clean text"}`.
|
||||||
|
pub fn strip_numbered_lines(text: &str) -> Option<String> {
|
||||||
|
let raw_lines: Vec<&str> = text.lines().collect();
|
||||||
|
if raw_lines.len() < 2 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let matched = raw_lines
|
||||||
|
.iter()
|
||||||
|
.filter(|l| l.is_empty() || split_line_number(l).is_some())
|
||||||
|
.count();
|
||||||
|
if matched < raw_lines.len() * 4 / 5 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut start_line: u64 = 1;
|
||||||
|
let mut first = true;
|
||||||
|
let stripped: Vec<&str> = raw_lines
|
||||||
|
.iter()
|
||||||
|
.map(|line| {
|
||||||
|
if let Some((num, content)) = split_line_number(line) {
|
||||||
|
if first {
|
||||||
|
start_line = num;
|
||||||
|
first = false;
|
||||||
|
}
|
||||||
|
content
|
||||||
|
} else {
|
||||||
|
first = false;
|
||||||
|
*line
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
Some(
|
||||||
|
serde_json::json!({
|
||||||
|
"start_line": start_line,
|
||||||
|
"content": stripped.join("\n")
|
||||||
|
})
|
||||||
|
.to_string(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolve line numbers for `*** Update File` / `*** Add File` style patches.
|
||||||
|
///
|
||||||
|
/// When a hunk header is just `@@` without `-N,M +N,M`, this reads the actual
|
||||||
|
/// file from disk and matches the context lines to calculate real line numbers.
|
||||||
|
/// Falls back gracefully if the file doesn't exist or context doesn't match.
|
||||||
|
pub fn resolve_patch_line_numbers(turns: &mut [MessageTurn], cwd: Option<&str>) {
|
||||||
|
for turn in turns.iter_mut() {
|
||||||
|
for block in turn.blocks.iter_mut() {
|
||||||
|
if let ContentBlock::ToolUse {
|
||||||
|
ref tool_name,
|
||||||
|
ref mut input_preview,
|
||||||
|
..
|
||||||
|
} = block
|
||||||
|
{
|
||||||
|
let name = tool_name.to_lowercase();
|
||||||
|
if !matches!(
|
||||||
|
name.as_str(),
|
||||||
|
"apply_patch" | "edit" | "patch" | "applypatch"
|
||||||
|
) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if let Some(ref text) = input_preview {
|
||||||
|
if text.contains("@@\n") || text.contains("@@\r\n") {
|
||||||
|
if let Some(resolved) = resolve_patch_text(text, cwd) {
|
||||||
|
*input_preview = Some(resolved);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolve a single patch text, replacing bare `@@` with `@@ -N,M +N,M @@`.
|
||||||
|
pub fn resolve_patch_text(patch: &str, cwd: Option<&str>) -> Option<String> {
|
||||||
|
let mut output = String::with_capacity(patch.len() + 256);
|
||||||
|
let mut current_file_path: Option<String> = None;
|
||||||
|
let mut file_lines: Option<Vec<String>> = None;
|
||||||
|
let mut any_resolved = false;
|
||||||
|
|
||||||
|
let lines: Vec<&str> = patch.lines().collect();
|
||||||
|
let mut i = 0;
|
||||||
|
|
||||||
|
while i < lines.len() {
|
||||||
|
let line = lines[i];
|
||||||
|
|
||||||
|
// Detect file markers
|
||||||
|
if line.starts_with("*** Update File: ") || line.starts_with("*** Add File: ") {
|
||||||
|
let marker_end = if line.starts_with("*** Update File: ") {
|
||||||
|
17
|
||||||
|
} else {
|
||||||
|
14
|
||||||
|
};
|
||||||
|
let path = line[marker_end..].trim();
|
||||||
|
current_file_path = Some(path.to_string());
|
||||||
|
file_lines = load_file_lines(path, cwd);
|
||||||
|
output.push_str(line);
|
||||||
|
output.push('\n');
|
||||||
|
i += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Detect bare @@ hunk header (no line numbers)
|
||||||
|
if line == "@@" {
|
||||||
|
if let (Some(ref fl), true) = (&file_lines, current_file_path.is_some()) {
|
||||||
|
// Collect context lines from this hunk to find match position
|
||||||
|
let hunk_lines = collect_hunk_lines(&lines, i + 1);
|
||||||
|
if let Some((old_start, old_count, new_count)) =
|
||||||
|
find_hunk_position(fl, &hunk_lines)
|
||||||
|
{
|
||||||
|
let new_start = old_start; // same start for context-based patches
|
||||||
|
output.push_str(&format!(
|
||||||
|
"@@ -{},{} +{},{} @@\n",
|
||||||
|
old_start, old_count, new_start, new_count
|
||||||
|
));
|
||||||
|
any_resolved = true;
|
||||||
|
i += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Fallback: keep bare @@
|
||||||
|
output.push_str(line);
|
||||||
|
output.push('\n');
|
||||||
|
i += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
output.push_str(line);
|
||||||
|
output.push('\n');
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if any_resolved { Some(output) } else { None }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Load file lines from disk, trying both absolute path and cwd-relative.
|
||||||
|
pub fn load_file_lines(path: &str, cwd: Option<&str>) -> Option<Vec<String>> {
|
||||||
|
use std::fs;
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
let p = Path::new(path);
|
||||||
|
if p.is_absolute() {
|
||||||
|
if let Ok(content) = fs::read_to_string(p) {
|
||||||
|
return Some(content.lines().map(|l| l.to_string()).collect());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(base) = cwd {
|
||||||
|
let full = Path::new(base).join(path);
|
||||||
|
if let Ok(content) = fs::read_to_string(&full) {
|
||||||
|
return Some(content.lines().map(|l| l.to_string()).collect());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Collect lines belonging to a hunk (until next `@@` or `*** ` marker or end).
|
||||||
|
fn collect_hunk_lines<'a>(lines: &'a [&'a str], start: usize) -> Vec<&'a str> {
|
||||||
|
let mut result = Vec::new();
|
||||||
|
for &line in &lines[start..] {
|
||||||
|
if line == "@@"
|
||||||
|
|| line.starts_with("*** ")
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
result.push(line);
|
||||||
|
}
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Find where a hunk's context lines match in the file, returning (start_line, old_count, new_count).
|
||||||
|
/// `start_line` is 1-based.
|
||||||
|
///
|
||||||
|
/// The file on disk may be in either pre-patch or post-patch state, and may
|
||||||
|
/// have been further modified. We try three strategies in order:
|
||||||
|
/// 1. Contiguous match of context+added lines (post-patch file, no further edits)
|
||||||
|
/// 2. Contiguous match of context+deleted lines (pre-patch file)
|
||||||
|
/// 3. Subsequence match of context-only lines (file has been further modified)
|
||||||
|
fn find_hunk_position(
|
||||||
|
file_lines: &[String],
|
||||||
|
hunk_lines: &[&str],
|
||||||
|
) -> Option<(usize, usize, usize)> {
|
||||||
|
let mut old_count = 0usize;
|
||||||
|
let mut new_count = 0usize;
|
||||||
|
for hl in hunk_lines {
|
||||||
|
if hl.starts_with(' ') {
|
||||||
|
old_count += 1;
|
||||||
|
new_count += 1;
|
||||||
|
} else if hl.starts_with('-') {
|
||||||
|
old_count += 1;
|
||||||
|
} else if hl.starts_with('+') {
|
||||||
|
new_count += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Strategy 1: contiguous match of context+added (post-patch)
|
||||||
|
let new_view: Vec<&str> = hunk_lines
|
||||||
|
.iter()
|
||||||
|
.filter(|l| l.starts_with(' ') || l.starts_with('+'))
|
||||||
|
.map(|l| &l[1..])
|
||||||
|
.collect();
|
||||||
|
if let Some(pos) = find_contiguous(file_lines, &new_view) {
|
||||||
|
return Some((pos + 1, old_count, new_count));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Strategy 2: contiguous match of context+deleted (pre-patch)
|
||||||
|
let old_view: Vec<&str> = hunk_lines
|
||||||
|
.iter()
|
||||||
|
.filter(|l| l.starts_with(' ') || l.starts_with('-'))
|
||||||
|
.map(|l| &l[1..])
|
||||||
|
.collect();
|
||||||
|
if let Some(pos) = find_contiguous(file_lines, &old_view) {
|
||||||
|
return Some((pos + 1, old_count, new_count));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Strategy 3: subsequence match of context-only lines (file further modified)
|
||||||
|
let ctx_only: Vec<&str> = hunk_lines
|
||||||
|
.iter()
|
||||||
|
.filter(|l| l.starts_with(' '))
|
||||||
|
.map(|l| &l[1..])
|
||||||
|
.collect();
|
||||||
|
if let Some(pos) = find_subsequence(file_lines, &ctx_only) {
|
||||||
|
return Some((pos + 1, old_count, new_count));
|
||||||
|
}
|
||||||
|
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Find contiguous `view` lines in `file_lines`. Returns 0-based start index.
|
||||||
|
fn find_contiguous(file_lines: &[String], view: &[&str]) -> Option<usize> {
|
||||||
|
if view.is_empty() || view.len() > file_lines.len() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let first = view[0];
|
||||||
|
for i in 0..=(file_lines.len() - view.len()) {
|
||||||
|
if file_lines[i].as_str() != first {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if view.iter().enumerate().all(|(j, v)| file_lines[i + j].as_str() == *v) {
|
||||||
|
return Some(i);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Find `needles` as an ordered subsequence in `file_lines` within a small window.
|
||||||
|
/// Returns 0-based index of the first needle's position.
|
||||||
|
fn find_subsequence(file_lines: &[String], needles: &[&str]) -> Option<usize> {
|
||||||
|
if needles.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let first = needles[0];
|
||||||
|
for start in 0..file_lines.len() {
|
||||||
|
if file_lines[start].as_str() != first {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let mut cursor = start + 1;
|
||||||
|
let mut all_found = true;
|
||||||
|
for &needle in &needles[1..] {
|
||||||
|
// Allow up to 10 lines gap between consecutive context lines
|
||||||
|
let limit = std::cmp::min(cursor + 10, file_lines.len());
|
||||||
|
match file_lines[cursor..limit]
|
||||||
|
.iter()
|
||||||
|
.position(|fl| fl.as_str() == needle)
|
||||||
|
{
|
||||||
|
Some(offset) => cursor = cursor + offset + 1,
|
||||||
|
None => {
|
||||||
|
all_found = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if all_found {
|
||||||
|
return Some(start);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
/// Extract the last path component as the folder name.
|
/// Extract the last path component as the folder name.
|
||||||
pub fn folder_name_from_path(path: &str) -> String {
|
pub fn folder_name_from_path(path: &str) -> String {
|
||||||
path.rsplit(['/', '\\']).next().unwrap_or(path).to_string()
|
path.rsplit(['/', '\\']).next().unwrap_or(path).to_string()
|
||||||
|
|||||||
@@ -652,6 +652,8 @@ impl OpenClawParser {
|
|||||||
let folder_name = folder_path.as_ref().map(|p| folder_name_from_path(p));
|
let folder_name = folder_path.as_ref().map(|p| folder_name_from_path(p));
|
||||||
let mut turns = group_into_turns(messages);
|
let mut turns = group_into_turns(messages);
|
||||||
super::relocate_orphaned_tool_results(&mut turns);
|
super::relocate_orphaned_tool_results(&mut turns);
|
||||||
|
super::structurize_read_tool_output(&mut turns);
|
||||||
|
super::resolve_patch_line_numbers(&mut turns, cwd.as_deref());
|
||||||
|
|
||||||
let context_window_used_tokens = latest_turn_total_usage_tokens(&turns);
|
let context_window_used_tokens = latest_turn_total_usage_tokens(&turns);
|
||||||
let context_window_max_tokens = session_meta
|
let context_window_max_tokens = session_meta
|
||||||
@@ -983,9 +985,15 @@ fn extract_assistant_content(value: &serde_json::Value) -> Vec<ContentBlock> {
|
|||||||
.and_then(|n| n.as_str())
|
.and_then(|n| n.as_str())
|
||||||
.unwrap_or("unknown")
|
.unwrap_or("unknown")
|
||||||
.to_string();
|
.to_string();
|
||||||
|
let is_edit_tool = matches!(
|
||||||
|
tool_name.to_lowercase().as_str(),
|
||||||
|
"edit" | "write" | "apply_patch" | "patch" | "applypatch"
|
||||||
|
| "edit_file" | "editfile"
|
||||||
|
);
|
||||||
|
let max_len = if is_edit_tool { 50000 } else { 500 };
|
||||||
let input_preview = item.get("arguments").map(|a| {
|
let input_preview = item.get("arguments").map(|a| {
|
||||||
let s = a.to_string();
|
let s = a.to_string();
|
||||||
truncate_str(&s, 500)
|
truncate_str(&s, max_len)
|
||||||
});
|
});
|
||||||
blocks.push(ContentBlock::ToolUse {
|
blocks.push(ContentBlock::ToolUse {
|
||||||
tool_use_id,
|
tool_use_id,
|
||||||
|
|||||||
@@ -189,6 +189,8 @@ impl OpenCodeParser {
|
|||||||
let messages = self.load_sqlite_messages(&conn, conversation_id).await?;
|
let messages = self.load_sqlite_messages(&conn, conversation_id).await?;
|
||||||
let mut turns = group_into_turns(messages);
|
let mut turns = group_into_turns(messages);
|
||||||
super::relocate_orphaned_tool_results(&mut turns);
|
super::relocate_orphaned_tool_results(&mut turns);
|
||||||
|
super::structurize_read_tool_output(&mut turns);
|
||||||
|
super::resolve_patch_line_numbers(&mut turns, summary.folder_path.as_deref());
|
||||||
let context_window_used_tokens = super::latest_turn_total_usage_tokens(&turns);
|
let context_window_used_tokens = super::latest_turn_total_usage_tokens(&turns);
|
||||||
let context_window_max_tokens =
|
let context_window_max_tokens =
|
||||||
super::infer_context_window_max_tokens(summary.model.as_deref());
|
super::infer_context_window_max_tokens(summary.model.as_deref());
|
||||||
|
|||||||
@@ -91,7 +91,7 @@ export const MessageThreadScrollButton = ({
|
|||||||
!isAtBottom && (
|
!isAtBottom && (
|
||||||
<Button
|
<Button
|
||||||
className={cn(
|
className={cn(
|
||||||
"absolute bottom-4 left-[50%] translate-x-[-50%] rounded-full dark:bg-background dark:hover:bg-muted",
|
"absolute bottom-4 left-[50%] translate-x-[-50%] rounded-full bg-background/90 hover:bg-muted/90",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
onClick={handleScrollToBottom}
|
onClick={handleScrollToBottom}
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import { useTranslations } from "next-intl"
|
|||||||
import { isValidElement } from "react"
|
import { isValidElement } from "react"
|
||||||
|
|
||||||
import { CodeBlock } from "./code-block"
|
import { CodeBlock } from "./code-block"
|
||||||
|
import { UnifiedDiffPreview } from "@/components/diff/unified-diff-preview"
|
||||||
import { MessageResponse } from "./message"
|
import { MessageResponse } from "./message"
|
||||||
|
|
||||||
export type ToolProps = ComponentProps<typeof Collapsible>
|
export type ToolProps = ComponentProps<typeof Collapsible>
|
||||||
@@ -380,9 +381,10 @@ export const ToolOutput = ({
|
|||||||
<MessageResponse>{output}</MessageResponse>
|
<MessageResponse>{output}</MessageResponse>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
} else if (lang === "diff") {
|
||||||
|
Output = <UnifiedDiffPreview diffText={output} />
|
||||||
} else {
|
} else {
|
||||||
const language = detectOutputLanguage(output)
|
Output = <CodeBlock code={output} language={lang} />
|
||||||
Output = <CodeBlock code={output} language={language} />
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import { useTranslations } from "next-intl"
|
|||||||
import {
|
import {
|
||||||
ShieldAlert,
|
ShieldAlert,
|
||||||
Terminal,
|
Terminal,
|
||||||
FilePenLine,
|
|
||||||
ListTodo,
|
ListTodo,
|
||||||
Compass,
|
Compass,
|
||||||
FileText,
|
FileText,
|
||||||
@@ -15,6 +14,7 @@ import {
|
|||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { Badge } from "@/components/ui/badge"
|
import { Badge } from "@/components/ui/badge"
|
||||||
import { CodeBlock } from "@/components/ai-elements/code-block"
|
import { CodeBlock } from "@/components/ai-elements/code-block"
|
||||||
|
import { UnifiedDiffPreview } from "@/components/diff/unified-diff-preview"
|
||||||
import { MessageResponse } from "@/components/ai-elements/message"
|
import { MessageResponse } from "@/components/ai-elements/message"
|
||||||
import type { PendingPermission } from "@/contexts/acp-connections-context"
|
import type { PendingPermission } from "@/contexts/acp-connections-context"
|
||||||
import { parsePermissionToolCall } from "@/lib/permission-request"
|
import { parsePermissionToolCall } from "@/lib/permission-request"
|
||||||
@@ -86,38 +86,8 @@ export function PermissionDialog({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{hasFileChanges && (
|
{hasFileChanges && parsed.diffPreview && (
|
||||||
<div className="space-y-1.5 rounded-md border border-border/60 bg-muted/20 p-2">
|
<UnifiedDiffPreview diffText={parsed.diffPreview} />
|
||||||
<div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
|
|
||||||
<FilePenLine className="h-3.5 w-3.5" />
|
|
||||||
<span>
|
|
||||||
{t("filesSummary", { count: parsed.fileChanges.length })}
|
|
||||||
</span>
|
|
||||||
{(parsed.additions > 0 || parsed.deletions > 0) && (
|
|
||||||
<span>
|
|
||||||
+{parsed.additions} / -{parsed.deletions}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="space-y-1 rounded-md bg-muted/40 p-2">
|
|
||||||
{parsed.fileChanges.slice(0, 8).map((change, index) => (
|
|
||||||
<div
|
|
||||||
key={`${change.path}-${index}`}
|
|
||||||
className="break-all font-mono text-xs text-foreground/90"
|
|
||||||
>
|
|
||||||
{change.path}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
{parsed.fileChanges.length > 8 && (
|
|
||||||
<div className="text-xs text-muted-foreground">
|
|
||||||
{t("moreFiles", { count: parsed.fileChanges.length - 8 })}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{parsed.diffPreview && (
|
|
||||||
<CodeBlock code={parsed.diffPreview} language="diff" />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{hasPlan && (
|
{hasPlan && (
|
||||||
|
|||||||
@@ -1,13 +1,9 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useCallback, useEffect, useMemo, useRef } from "react"
|
import { useMemo } from "react"
|
||||||
import dynamic from "next/dynamic"
|
|
||||||
import type { editor as MonacoEditorNs } from "monaco-editor"
|
|
||||||
import { useTranslations } from "next-intl"
|
import { useTranslations } from "next-intl"
|
||||||
import { useFolderContext } from "@/contexts/folder-context"
|
import { useFolderContext } from "@/contexts/folder-context"
|
||||||
import { defineMonacoThemes, useMonacoThemeSync } from "@/lib/monaco-themes"
|
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
import "@/lib/monaco-local"
|
|
||||||
|
|
||||||
type RowMarker = "none" | "added" | "deleted" | "modified"
|
type RowMarker = "none" | "added" | "deleted" | "modified"
|
||||||
type DiffFileMode = "modified" | "added" | "deleted" | "renamed"
|
type DiffFileMode = "modified" | "added" | "deleted" | "renamed"
|
||||||
@@ -67,14 +63,18 @@ interface WorkingFile {
|
|||||||
hunks: WorkingHunk[]
|
hunks: WorkingHunk[]
|
||||||
}
|
}
|
||||||
|
|
||||||
interface HunkPreviewLine {
|
const ROW_CLASS: Record<RowMarker, string> = {
|
||||||
text: string
|
none: "",
|
||||||
marker: RowMarker
|
added: "bg-green-500/10 text-green-900 dark:text-green-300",
|
||||||
|
deleted: "bg-red-500/10 text-red-900 dark:text-red-300",
|
||||||
|
modified: "bg-blue-500/10 text-blue-900 dark:text-blue-300",
|
||||||
}
|
}
|
||||||
|
|
||||||
const MonacoEditor = dynamic(async () => import("@monaco-editor/react"), {
|
const SIGN_CLASS: Record<string, string> = {
|
||||||
ssr: false,
|
"+": "text-green-700 dark:text-green-400",
|
||||||
})
|
"-": "text-red-700 dark:text-red-400",
|
||||||
|
" ": "text-muted-foreground/50",
|
||||||
|
}
|
||||||
|
|
||||||
function normalizePath(raw: string): string | null {
|
function normalizePath(raw: string): string | null {
|
||||||
const trimmed = raw.trim().replace(/^"|"$/g, "")
|
const trimmed = raw.trim().replace(/^"|"$/g, "")
|
||||||
@@ -190,13 +190,11 @@ function classifyRows(rows: RawDiffRow[]): ParsedDiffRow[] {
|
|||||||
addEnd += 1
|
addEnd += 1
|
||||||
}
|
}
|
||||||
|
|
||||||
const delRows = rows.slice(index, delEnd)
|
for (let d = index; d < delEnd; d++) {
|
||||||
const addRows = rows.slice(delEnd, addEnd)
|
const row = rows[d]
|
||||||
const modifiedPairs = Math.min(delRows.length, addRows.length)
|
if (!row) continue
|
||||||
|
|
||||||
for (const [delta, row] of delRows.entries()) {
|
|
||||||
parsed.push({
|
parsed.push({
|
||||||
type: delta < modifiedPairs ? "modified" : "deleted",
|
type: "deleted",
|
||||||
text: row.text,
|
text: row.text,
|
||||||
sign: "-",
|
sign: "-",
|
||||||
oldLine: row.oldLine,
|
oldLine: row.oldLine,
|
||||||
@@ -204,9 +202,11 @@ function classifyRows(rows: RawDiffRow[]): ParsedDiffRow[] {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const [delta, row] of addRows.entries()) {
|
for (let a = delEnd; a < addEnd; a++) {
|
||||||
|
const row = rows[a]
|
||||||
|
if (!row) continue
|
||||||
parsed.push({
|
parsed.push({
|
||||||
type: delta < modifiedPairs ? "modified" : "added",
|
type: "added",
|
||||||
text: row.text,
|
text: row.text,
|
||||||
sign: "+",
|
sign: "+",
|
||||||
oldLine: row.oldLine,
|
oldLine: row.oldLine,
|
||||||
@@ -440,171 +440,65 @@ function toDisplayPath(filePath: string, folderPath: string | null): string {
|
|||||||
return normalizedPath
|
return normalizedPath
|
||||||
}
|
}
|
||||||
|
|
||||||
function countHunkChanges(hunk: ParsedDiffHunk): {
|
function rowMarker(row: ParsedDiffRow): RowMarker {
|
||||||
additions: number
|
if (row.type === "added") return "added"
|
||||||
deletions: number
|
if (row.type === "deleted") return "deleted"
|
||||||
} {
|
return "none"
|
||||||
let additions = 0
|
|
||||||
let deletions = 0
|
|
||||||
|
|
||||||
for (const row of hunk.rows) {
|
|
||||||
if (row.sign === "+") additions += 1
|
|
||||||
if (row.sign === "-") deletions += 1
|
|
||||||
}
|
|
||||||
|
|
||||||
return { additions, deletions }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildHunkPreviewLines(rows: ParsedDiffRow[]): {
|
function HunkSeparator({ hunk }: { hunk: ParsedDiffHunk }) {
|
||||||
lines: HunkPreviewLine[]
|
const label =
|
||||||
} {
|
hunk.oldStart != null && hunk.oldCount != null
|
||||||
const lines: HunkPreviewLine[] = rows.map((row) => {
|
? `@@ -${hunk.oldStart},${hunk.oldCount} +${hunk.newStart ?? hunk.oldStart},${hunk.newCount ?? hunk.oldCount} @@`
|
||||||
let marker: RowMarker = "none"
|
: "···"
|
||||||
if (row.type === "added") marker = "added"
|
|
||||||
else if (row.type === "deleted") marker = "deleted"
|
|
||||||
else if (row.type === "modified") marker = "modified"
|
|
||||||
|
|
||||||
return {
|
|
||||||
text: `${row.sign}${row.text}`,
|
|
||||||
marker,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
return {
|
|
||||||
lines,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function HunkMonacoPreview({
|
|
||||||
hunk,
|
|
||||||
modelId,
|
|
||||||
theme,
|
|
||||||
}: {
|
|
||||||
hunk: ParsedDiffHunk
|
|
||||||
modelId: string
|
|
||||||
theme: string
|
|
||||||
}) {
|
|
||||||
const t = useTranslations("Folder.diffPreview")
|
|
||||||
const editorRef = useRef<MonacoEditorNs.IStandaloneCodeEditor | null>(null)
|
|
||||||
const decorationsRef = useRef<string[]>([])
|
|
||||||
|
|
||||||
const { lines } = useMemo(() => buildHunkPreviewLines(hunk.rows), [hunk.rows])
|
|
||||||
|
|
||||||
const renderedContent = useMemo(
|
|
||||||
() => lines.map((line) => line.text).join("\n"),
|
|
||||||
[lines]
|
|
||||||
)
|
|
||||||
|
|
||||||
const applyDecorations = useCallback(() => {
|
|
||||||
const editor = editorRef.current
|
|
||||||
if (!editor) return
|
|
||||||
|
|
||||||
const model = editor.getModel()
|
|
||||||
if (!model) return
|
|
||||||
|
|
||||||
const maxLine = model.getLineCount()
|
|
||||||
const decorations: MonacoEditorNs.IModelDeltaDecoration[] = []
|
|
||||||
|
|
||||||
for (const [index, line] of lines.entries()) {
|
|
||||||
const lineNumber = index + 1
|
|
||||||
if (lineNumber > maxLine) continue
|
|
||||||
|
|
||||||
let cls: string | null = null
|
|
||||||
if (line.marker === "added") {
|
|
||||||
cls = "codeg-session-diff-line-added"
|
|
||||||
} else if (line.marker === "modified") {
|
|
||||||
cls = "codeg-session-diff-line-modified"
|
|
||||||
} else if (line.marker === "deleted") {
|
|
||||||
cls = "codeg-session-diff-line-deleted"
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!cls) continue
|
|
||||||
|
|
||||||
decorations.push({
|
|
||||||
range: {
|
|
||||||
startLineNumber: lineNumber,
|
|
||||||
startColumn: 1,
|
|
||||||
endLineNumber: lineNumber,
|
|
||||||
endColumn: 1,
|
|
||||||
},
|
|
||||||
options: {
|
|
||||||
isWholeLine: true,
|
|
||||||
className: cls,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
decorationsRef.current = editor.deltaDecorations(
|
|
||||||
decorationsRef.current,
|
|
||||||
decorations
|
|
||||||
)
|
|
||||||
}, [lines])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
applyDecorations()
|
|
||||||
}, [applyDecorations])
|
|
||||||
|
|
||||||
useEffect(
|
|
||||||
() => () => {
|
|
||||||
const editor = editorRef.current
|
|
||||||
if (!editor) return
|
|
||||||
editor.deltaDecorations(decorationsRef.current, [])
|
|
||||||
decorationsRef.current = []
|
|
||||||
},
|
|
||||||
[]
|
|
||||||
)
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<MonacoEditor
|
<div className="flex items-center gap-2 border-y border-border/50 bg-muted/30 px-3 py-0.5 font-mono text-[11px] text-muted-foreground/60">
|
||||||
beforeMount={defineMonacoThemes}
|
<span className="select-none">{label}</span>
|
||||||
onMount={(editor) => {
|
</div>
|
||||||
editorRef.current = editor
|
)
|
||||||
applyDecorations()
|
}
|
||||||
}}
|
|
||||||
path={`inmemory://session-hunk/${encodeURIComponent(modelId)}`}
|
function HunkLines({ rows }: { rows: ParsedDiffRow[] }) {
|
||||||
value={renderedContent}
|
return (
|
||||||
language="plaintext"
|
<div className="font-mono text-[12px] leading-[20px]">
|
||||||
theme={theme}
|
{rows.map((row, i) => {
|
||||||
loading={
|
const marker = rowMarker(row)
|
||||||
<div className="h-28 flex items-center justify-center text-xs text-muted-foreground">
|
return (
|
||||||
{t("loadingHunk")}
|
<div key={i} className={cn("flex", ROW_CLASS[marker])}>
|
||||||
</div>
|
<span className="w-[3.5rem] shrink-0 select-none pr-1 text-right text-muted-foreground/40">
|
||||||
}
|
{row.oldLine ?? ""}
|
||||||
options={{
|
</span>
|
||||||
readOnly: true,
|
<span className="w-[3.5rem] shrink-0 select-none pr-1 text-right text-muted-foreground/40">
|
||||||
minimap: { enabled: false },
|
{row.newLine ?? ""}
|
||||||
automaticLayout: true,
|
</span>
|
||||||
fontSize: 12,
|
<span
|
||||||
lineNumbers: "off",
|
className={cn(
|
||||||
lineDecorationsWidth: 10,
|
"w-4 shrink-0 select-none text-center",
|
||||||
glyphMargin: false,
|
SIGN_CLASS[row.sign] ?? ""
|
||||||
wordWrap: "off",
|
)}
|
||||||
scrollBeyondLastLine: false,
|
>
|
||||||
renderLineHighlight: "none",
|
{row.sign === " " ? "" : row.sign}
|
||||||
contextmenu: false,
|
</span>
|
||||||
folding: false,
|
<span className="flex-1 whitespace-pre pr-3">{row.text}</span>
|
||||||
scrollbar: {
|
</div>
|
||||||
alwaysConsumeMouseWheel: false,
|
)
|
||||||
},
|
})}
|
||||||
padding: { top: 6, bottom: 6 },
|
</div>
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function UnifiedDiffPreview({
|
export function UnifiedDiffPreview({
|
||||||
diffText,
|
diffText,
|
||||||
modelId,
|
|
||||||
className,
|
className,
|
||||||
}: {
|
}: {
|
||||||
diffText: string
|
diffText: string
|
||||||
|
/** @deprecated No longer used — kept for API compat */
|
||||||
modelId?: string
|
modelId?: string
|
||||||
className?: string
|
className?: string
|
||||||
}) {
|
}) {
|
||||||
const t = useTranslations("Folder.diffPreview")
|
const t = useTranslations("Folder.diffPreview")
|
||||||
const { folder } = useFolderContext()
|
const { folder } = useFolderContext()
|
||||||
const files = useMemo(() => parseUnifiedDiff(diffText), [diffText])
|
const files = useMemo(() => parseUnifiedDiff(diffText), [diffText])
|
||||||
const theme = useMonacoThemeSync()
|
|
||||||
|
|
||||||
if (!diffText.trim()) {
|
if (!diffText.trim()) {
|
||||||
return (
|
return (
|
||||||
@@ -621,8 +515,8 @@ export function UnifiedDiffPreview({
|
|||||||
|
|
||||||
if (files.length === 0) {
|
if (files.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div className={cn("h-full overflow-auto p-3", className)}>
|
<div className={cn("h-full overflow-auto", className)}>
|
||||||
<pre className="font-mono text-[11px] leading-5 whitespace-pre-wrap text-muted-foreground">
|
<pre className="font-mono text-[11px] leading-5 whitespace-pre-wrap text-muted-foreground p-3">
|
||||||
{diffText}
|
{diffText}
|
||||||
</pre>
|
</pre>
|
||||||
</div>
|
</div>
|
||||||
@@ -630,14 +524,14 @@ export function UnifiedDiffPreview({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn("h-full overflow-auto p-3", className)}>
|
<div className={cn("h-full overflow-auto", className)}>
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{files.map((file) => (
|
{files.map((file) => (
|
||||||
<section
|
<section
|
||||||
key={file.key}
|
key={file.key}
|
||||||
className="overflow-hidden rounded-lg border border-border bg-background"
|
className="flex max-h-[420px] flex-col rounded-lg border border-border bg-background"
|
||||||
>
|
>
|
||||||
<header className="flex items-center gap-2 border-b border-border bg-muted/40 px-3 py-2 text-[11px]">
|
<header className="flex shrink-0 items-center gap-2 border-b border-border bg-muted/40 px-3 py-2 text-[11px]">
|
||||||
<span className="shrink-0 rounded border border-border bg-background px-1.5 py-0.5 text-[10px] text-muted-foreground">
|
<span className="shrink-0 rounded border border-border bg-background px-1.5 py-0.5 text-[10px] text-muted-foreground">
|
||||||
{t(modeKey(file.mode))}
|
{t(modeKey(file.mode))}
|
||||||
</span>
|
</span>
|
||||||
@@ -657,41 +551,15 @@ export function UnifiedDiffPreview({
|
|||||||
</span>
|
</span>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div className="space-y-2 p-2">
|
<div className="overflow-auto">
|
||||||
{file.hunks.map((hunk, index) => {
|
<div className="inline-block min-w-full">
|
||||||
const hunkStats = countHunkChanges(hunk)
|
{file.hunks.map((hunk, hunkIdx) => (
|
||||||
|
<div key={hunk.key}>
|
||||||
return (
|
{hunkIdx > 0 && <HunkSeparator hunk={hunk} />}
|
||||||
<div
|
<HunkLines rows={hunk.rows} />
|
||||||
key={hunk.key}
|
|
||||||
className="rounded-md border border-border"
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-2 border-b border-border bg-muted/30 px-2 py-1 text-[10px] font-mono text-muted-foreground">
|
|
||||||
<span>{t("hunkLabel", { index: index + 1 })}</span>
|
|
||||||
<span className="ml-auto inline-flex items-center gap-2">
|
|
||||||
<span className="text-green-700 dark:text-green-400">
|
|
||||||
+{hunkStats.additions}
|
|
||||||
</span>
|
|
||||||
<span className="text-red-700 dark:text-red-400">
|
|
||||||
-{hunkStats.deletions}
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
className="min-h-[7rem]"
|
|
||||||
style={{
|
|
||||||
height: `${Math.max(120, hunk.rows.length * 20 + 18)}px`,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<HunkMonacoPreview
|
|
||||||
hunk={hunk}
|
|
||||||
modelId={`${modelId ?? "session"}:${file.key}:${hunk.key}`}
|
|
||||||
theme={theme}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
))}
|
||||||
})}
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -1270,8 +1270,7 @@ export function FileWorkspacePanel() {
|
|||||||
)}
|
)}
|
||||||
<UnifiedDiffPreview
|
<UnifiedDiffPreview
|
||||||
diffText={activeFileTab.content}
|
diffText={activeFileTab.content}
|
||||||
modelId={activeFileTab.id}
|
className="h-full p-3"
|
||||||
className="h-full"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -11,11 +11,11 @@ import {
|
|||||||
Columns2,
|
Columns2,
|
||||||
FileCode2,
|
FileCode2,
|
||||||
MessageSquare,
|
MessageSquare,
|
||||||
PanelBottom,
|
|
||||||
PanelLeft,
|
PanelLeft,
|
||||||
PanelRight,
|
PanelRight,
|
||||||
Search,
|
Search,
|
||||||
Settings,
|
Settings,
|
||||||
|
SquareTerminal,
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
import { useTranslations } from "next-intl"
|
import { useTranslations } from "next-intl"
|
||||||
import { getGitBranch, openFolderWindow, openSettingsWindow } from "@/lib/api"
|
import { getGitBranch, openFolderWindow, openSettingsWindow } from "@/lib/api"
|
||||||
@@ -340,21 +340,6 @@ export function FolderTitleBar() {
|
|||||||
>
|
>
|
||||||
<PanelLeft className="h-3.5 w-3.5" />
|
<PanelLeft className="h-3.5 w-3.5" />
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className={`h-6 w-6 hover:text-foreground/80 ${terminalOpen ? "bg-accent" : ""}`}
|
|
||||||
onClick={() => toggleTerminal()}
|
|
||||||
title={tTitleBar("withShortcut", {
|
|
||||||
label: tTitleBar("toggleTerminal"),
|
|
||||||
shortcut: formatShortcutLabel(
|
|
||||||
shortcuts.toggle_terminal,
|
|
||||||
isMac
|
|
||||||
),
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
<PanelBottom className="h-3.5 w-3.5" />
|
|
||||||
</Button>
|
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
@@ -370,6 +355,21 @@ export function FolderTitleBar() {
|
|||||||
>
|
>
|
||||||
<PanelRight className="h-3.5 w-3.5" />
|
<PanelRight className="h-3.5 w-3.5" />
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className={`h-6 w-6 hover:text-foreground/80 ${terminalOpen ? "bg-accent" : ""}`}
|
||||||
|
onClick={() => toggleTerminal()}
|
||||||
|
title={tTitleBar("withShortcut", {
|
||||||
|
label: tTitleBar("toggleTerminal"),
|
||||||
|
shortcut: formatShortcutLabel(
|
||||||
|
shortcuts.toggle_terminal,
|
||||||
|
isMac
|
||||||
|
),
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<SquareTerminal className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { memo, useMemo, useState, type ReactNode } from "react"
|
import { memo, useMemo, useState, type ReactNode } from "react"
|
||||||
import type { BundledLanguage } from "shiki"
|
|
||||||
import type { AdaptedContentPart } from "@/lib/adapters/ai-elements-adapter"
|
import type { AdaptedContentPart } from "@/lib/adapters/ai-elements-adapter"
|
||||||
import type { MessageRole } from "@/lib/types"
|
import type { MessageRole } from "@/lib/types"
|
||||||
import { normalizeToolName } from "@/lib/tool-call-normalization"
|
import { normalizeToolName } from "@/lib/tool-call-normalization"
|
||||||
@@ -17,6 +16,8 @@ import {
|
|||||||
} from "@/components/ai-elements/tool"
|
} from "@/components/ai-elements/tool"
|
||||||
import { Terminal } from "@/components/ai-elements/terminal"
|
import { Terminal } from "@/components/ai-elements/terminal"
|
||||||
import { CodeBlock } from "@/components/ai-elements/code-block"
|
import { CodeBlock } from "@/components/ai-elements/code-block"
|
||||||
|
import { UnifiedDiffPreview } from "@/components/diff/unified-diff-preview"
|
||||||
|
import { generateUnifiedDiff } from "@/lib/unified-diff-generator"
|
||||||
import {
|
import {
|
||||||
Reasoning,
|
Reasoning,
|
||||||
ReasoningTrigger,
|
ReasoningTrigger,
|
||||||
@@ -374,62 +375,6 @@ function num(obj: Record<string, unknown>, key: string): number | undefined {
|
|||||||
return typeof v === "number" ? v : undefined
|
return typeof v === "number" ? v : undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Guess shiki language from file path extension. */
|
|
||||||
const EXT_LANG_MAP: Record<string, BundledLanguage> = {
|
|
||||||
ts: "typescript",
|
|
||||||
tsx: "tsx",
|
|
||||||
js: "javascript",
|
|
||||||
jsx: "jsx",
|
|
||||||
mjs: "javascript",
|
|
||||||
cjs: "javascript",
|
|
||||||
py: "python",
|
|
||||||
rs: "rust",
|
|
||||||
go: "go",
|
|
||||||
java: "java",
|
|
||||||
css: "css",
|
|
||||||
scss: "scss",
|
|
||||||
less: "less",
|
|
||||||
html: "html",
|
|
||||||
json: "json",
|
|
||||||
jsonl: "json",
|
|
||||||
yaml: "yaml",
|
|
||||||
yml: "yaml",
|
|
||||||
md: "markdown",
|
|
||||||
mdx: "mdx",
|
|
||||||
sql: "sql",
|
|
||||||
sh: "bash",
|
|
||||||
bash: "bash",
|
|
||||||
zsh: "bash",
|
|
||||||
toml: "toml",
|
|
||||||
xml: "xml",
|
|
||||||
svg: "xml",
|
|
||||||
vue: "vue",
|
|
||||||
svelte: "svelte",
|
|
||||||
rb: "ruby",
|
|
||||||
php: "php",
|
|
||||||
swift: "swift",
|
|
||||||
kt: "kotlin",
|
|
||||||
c: "c",
|
|
||||||
cpp: "cpp",
|
|
||||||
h: "c",
|
|
||||||
hpp: "cpp",
|
|
||||||
cs: "csharp",
|
|
||||||
dart: "dart",
|
|
||||||
lua: "lua",
|
|
||||||
r: "r",
|
|
||||||
dockerfile: "dockerfile",
|
|
||||||
graphql: "graphql",
|
|
||||||
prisma: "prisma",
|
|
||||||
}
|
|
||||||
|
|
||||||
function guessLangFromPath(filePath: string): BundledLanguage {
|
|
||||||
const ext = filePath.split(".").pop()?.toLowerCase() ?? ""
|
|
||||||
// Handle dotfiles like "Dockerfile"
|
|
||||||
const basename = filePath.split("/").pop()?.toLowerCase() ?? ""
|
|
||||||
if (basename === "dockerfile") return "dockerfile"
|
|
||||||
return EXT_LANG_MAP[ext] ?? ("log" as BundledLanguage)
|
|
||||||
}
|
|
||||||
|
|
||||||
type ApplyPatchOp = "add" | "update" | "delete" | "move"
|
type ApplyPatchOp = "add" | "update" | "delete" | "move"
|
||||||
|
|
||||||
type ApplyPatchFile = {
|
type ApplyPatchFile = {
|
||||||
@@ -1227,115 +1172,55 @@ function localizeDerivedToolTitle(
|
|||||||
|
|
||||||
/** Edit tool: file path + unified diff view */
|
/** Edit tool: file path + unified diff view */
|
||||||
function EditToolInput({ input }: { input: Record<string, unknown> }) {
|
function EditToolInput({ input }: { input: Record<string, unknown> }) {
|
||||||
const t = useTranslations("Folder.chat.contentParts")
|
|
||||||
const filePath = str(input, "file_path")
|
const filePath = str(input, "file_path")
|
||||||
const oldString = str(input, "old_string") ?? ""
|
const oldString = str(input, "old_string") ?? ""
|
||||||
const newString = str(input, "new_string") ?? ""
|
const newString = str(input, "new_string") ?? ""
|
||||||
const replaceAll = input.replace_all === true
|
const startLine = num(input, "_start_line")
|
||||||
|
|
||||||
const diffCode = useMemo(() => {
|
const diffCode = useMemo(() => {
|
||||||
const parts: string[] = []
|
const diff = generateUnifiedDiff(
|
||||||
if (oldString) {
|
oldString,
|
||||||
for (const line of oldString.split("\n")) {
|
newString,
|
||||||
parts.push(`- ${line}`)
|
filePath ?? undefined
|
||||||
}
|
)
|
||||||
}
|
if (!diff || !startLine || startLine <= 1) return diff ?? ""
|
||||||
if (newString) {
|
// Replace line numbers in hunk headers with real start line
|
||||||
for (const line of newString.split("\n")) {
|
return diff.replace(
|
||||||
parts.push(`+ ${line}`)
|
/^@@ -(\d+),(\d+) \+(\d+),(\d+) @@/gm,
|
||||||
}
|
(_, _o, oc, _n, nc) => `@@ -${startLine},${oc} +${startLine},${nc} @@`
|
||||||
}
|
)
|
||||||
return parts.join("\n")
|
}, [oldString, newString, filePath, startLine])
|
||||||
}, [oldString, newString])
|
|
||||||
|
|
||||||
return (
|
return diffCode ? <UnifiedDiffPreview diffText={diffCode} /> : null
|
||||||
<div className="space-y-2">
|
|
||||||
<div className="flex items-center gap-2 text-xs">
|
|
||||||
<FilePenLineIcon className="size-3.5 shrink-0 text-muted-foreground" />
|
|
||||||
<span className="break-all font-mono text-foreground">
|
|
||||||
{filePath ?? t("unknown")}
|
|
||||||
</span>
|
|
||||||
{replaceAll && (
|
|
||||||
<span className="shrink-0 rounded bg-muted px-1.5 py-0.5 text-[10px] font-medium text-muted-foreground">
|
|
||||||
{t("replaceAll")}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{diffCode && <CodeBlock code={diffCode} language="diff" />}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Edit tool (changes payload): file list + summary + combined diff view */
|
/** Edit tool (changes payload): combined diff view */
|
||||||
function EditChangesToolInput({ changes }: { changes: EditChangePreview[] }) {
|
function EditChangesToolInput({ changes }: { changes: EditChangePreview[] }) {
|
||||||
const t = useTranslations("Folder.chat.contentParts")
|
const diffCode = useMemo(() => {
|
||||||
const { additions, deletions, diffCode } = useMemo(() => {
|
|
||||||
let additions = 0
|
|
||||||
let deletions = 0
|
|
||||||
const diffParts: string[] = []
|
const diffParts: string[] = []
|
||||||
|
|
||||||
for (const change of changes) {
|
for (const change of changes) {
|
||||||
if (change.unifiedDiff && change.unifiedDiff.trim().length > 0) {
|
if (change.unifiedDiff && change.unifiedDiff.trim().length > 0) {
|
||||||
diffParts.push(change.unifiedDiff.trim())
|
diffParts.push(change.unifiedDiff.trim())
|
||||||
diffParts.push("")
|
diffParts.push("")
|
||||||
for (const line of change.unifiedDiff.split("\n")) {
|
|
||||||
if (line.startsWith("+") && !line.startsWith("+++")) additions += 1
|
|
||||||
if (line.startsWith("-") && !line.startsWith("---")) deletions += 1
|
|
||||||
}
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
const oldLines = change.oldText ? change.oldText.split("\n") : []
|
const generated = generateUnifiedDiff(
|
||||||
const newLines = change.newText ? change.newText.split("\n") : []
|
change.oldText,
|
||||||
|
change.newText,
|
||||||
deletions += oldLines.length
|
change.path
|
||||||
additions += newLines.length
|
)
|
||||||
|
if (generated) {
|
||||||
diffParts.push(`--- ${change.path}`)
|
diffParts.push(generated)
|
||||||
diffParts.push(`+++ ${change.path}`)
|
diffParts.push("")
|
||||||
for (const line of oldLines) {
|
|
||||||
diffParts.push(`-${line}`)
|
|
||||||
}
|
}
|
||||||
for (const line of newLines) {
|
|
||||||
diffParts.push(`+${line}`)
|
|
||||||
}
|
|
||||||
diffParts.push("")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return diffParts.join("\n").trim()
|
||||||
additions,
|
|
||||||
deletions,
|
|
||||||
diffCode: diffParts.join("\n").trim(),
|
|
||||||
}
|
|
||||||
}, [changes])
|
}, [changes])
|
||||||
|
|
||||||
return (
|
return diffCode ? <UnifiedDiffPreview diffText={diffCode} /> : null
|
||||||
<div className="space-y-2">
|
|
||||||
<div className="flex flex-wrap gap-3 text-xs text-muted-foreground">
|
|
||||||
<span>{t("filesCount", { count: changes.length })}</span>
|
|
||||||
{additions > 0 && <span>+{additions}</span>}
|
|
||||||
{deletions > 0 && <span>-{deletions}</span>}
|
|
||||||
</div>
|
|
||||||
<div className="space-y-1 rounded-md bg-muted/40 p-2">
|
|
||||||
{changes.slice(0, 8).map((change, index) => (
|
|
||||||
<div key={`${change.path}-${index}`} className="flex gap-2 text-xs">
|
|
||||||
<span className="shrink-0 rounded bg-blue-500/15 px-1.5 py-0.5 font-medium uppercase text-blue-600">
|
|
||||||
{t("update")}
|
|
||||||
</span>
|
|
||||||
<span className="break-all font-mono text-foreground">
|
|
||||||
{change.path}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
{changes.length > 8 && (
|
|
||||||
<div className="text-xs text-muted-foreground">
|
|
||||||
{t("moreFiles", { count: changes.length - 8 })}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{diffCode && <CodeBlock code={diffCode} language="diff" />}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Bash / exec_command: terminal-style command display */
|
/** Bash / exec_command: terminal-style command display */
|
||||||
@@ -1370,13 +1255,60 @@ function BashToolInput({ input }: { input: Record<string, unknown> }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse structured read output from backend: `{"start_line":N,"content":"..."}`.
|
||||||
|
* Falls back to raw text with startLine=1 if not structured.
|
||||||
|
*/
|
||||||
|
function parseReadOutput(raw: string): { startLine: number; content: string } {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(raw)
|
||||||
|
if (
|
||||||
|
typeof parsed === "object" &&
|
||||||
|
parsed !== null &&
|
||||||
|
typeof parsed.start_line === "number" &&
|
||||||
|
typeof parsed.content === "string"
|
||||||
|
) {
|
||||||
|
return { startLine: parsed.start_line, content: parsed.content }
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// not JSON
|
||||||
|
}
|
||||||
|
return { startLine: 1, content: raw }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Lightweight file content viewer with line numbers */
|
||||||
|
function FileContentLines({
|
||||||
|
content,
|
||||||
|
startLine = 1,
|
||||||
|
}: {
|
||||||
|
content: string
|
||||||
|
startLine?: number
|
||||||
|
}) {
|
||||||
|
const lines = useMemo(() => content.split("\n"), [content])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="inline-block min-w-full font-mono text-[12px] leading-[20px]">
|
||||||
|
{lines.map((line, i) => (
|
||||||
|
<div key={i} className="flex">
|
||||||
|
<span className="w-[3.5rem] shrink-0 select-none pr-1 text-right text-muted-foreground/40">
|
||||||
|
{startLine + i}
|
||||||
|
</span>
|
||||||
|
<span className="flex-1 whitespace-pre pr-3">{line}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
/** Read / Write / NotebookEdit: file-focused display */
|
/** Read / Write / NotebookEdit: file-focused display */
|
||||||
function FileToolInput({
|
function FileToolInput({
|
||||||
toolName,
|
toolName,
|
||||||
input,
|
input,
|
||||||
|
output,
|
||||||
}: {
|
}: {
|
||||||
toolName: string
|
toolName: string
|
||||||
input: Record<string, unknown>
|
input: Record<string, unknown>
|
||||||
|
output?: string | null
|
||||||
}) {
|
}) {
|
||||||
const t = useTranslations("Folder.chat.contentParts")
|
const t = useTranslations("Folder.chat.contentParts")
|
||||||
const name = toolName.toLowerCase()
|
const name = toolName.toLowerCase()
|
||||||
@@ -1389,48 +1321,52 @@ function FileToolInput({
|
|||||||
const pages = str(input, "pages")
|
const pages = str(input, "pages")
|
||||||
const cellType = str(input, "cell_type")
|
const cellType = str(input, "cell_type")
|
||||||
const editMode = str(input, "edit_mode")
|
const editMode = str(input, "edit_mode")
|
||||||
|
const isRead = name === "read" || name === "read file"
|
||||||
|
|
||||||
const lang = filePath
|
const badges: string[] = []
|
||||||
? guessLangFromPath(filePath)
|
if (offset != null) badges.push(t("offset", { offset }))
|
||||||
: ("log" as BundledLanguage)
|
if (limit != null) badges.push(t("limit", { limit }))
|
||||||
|
if (pages) badges.push(t("pages", { pages }))
|
||||||
|
if (editMode) badges.push(t("mode", { mode: editMode }))
|
||||||
|
if (cellType) badges.push(t("cell", { cell: cellType }))
|
||||||
|
|
||||||
|
const { displayContent, startLine } = useMemo(() => {
|
||||||
|
if (isRead && output) {
|
||||||
|
const parsed = parseReadOutput(output)
|
||||||
|
return { displayContent: parsed.content, startLine: parsed.startLine }
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
displayContent: content ?? newSource ?? null,
|
||||||
|
startLine: 1,
|
||||||
|
}
|
||||||
|
}, [isRead, output, content, newSource])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-2">
|
<section className="flex max-h-[420px] flex-col rounded-lg border border-border bg-background">
|
||||||
{filePath && (
|
<header className="flex shrink-0 items-center gap-2 border-b border-border bg-muted/40 px-3 py-2 text-[11px]">
|
||||||
<div className="flex items-center gap-2 text-xs">
|
<span className="shrink-0 rounded border border-border bg-background px-1.5 py-0.5 text-[10px] text-muted-foreground">
|
||||||
{name === "read" || name === "read file" ? (
|
{isRead ? "READ" : "WRITE"}
|
||||||
<FileTextIcon className="size-3.5 shrink-0 text-muted-foreground" />
|
</span>
|
||||||
) : (
|
<span
|
||||||
<FilePlusIcon className="size-3.5 shrink-0 text-muted-foreground" />
|
className="min-w-0 flex-1 truncate font-mono text-foreground"
|
||||||
)}
|
title={filePath ?? undefined}
|
||||||
<span className="break-all font-mono text-foreground">
|
>
|
||||||
{filePath}
|
{filePath ?? t("unknown")}
|
||||||
|
</span>
|
||||||
|
{badges.length > 0 && (
|
||||||
|
<span className="ml-auto inline-flex shrink-0 items-center gap-2 text-[10px] text-muted-foreground">
|
||||||
|
{badges.map((b) => (
|
||||||
|
<span key={b}>{b}</span>
|
||||||
|
))}
|
||||||
</span>
|
</span>
|
||||||
|
)}
|
||||||
|
</header>
|
||||||
|
{displayContent && (
|
||||||
|
<div className="overflow-auto">
|
||||||
|
<FileContentLines content={displayContent} startLine={startLine} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{(offset != null || limit != null || pages) && (
|
</section>
|
||||||
<div className="flex flex-wrap gap-3 text-xs text-muted-foreground">
|
|
||||||
{offset != null && <span>{t("offset", { offset })}</span>}
|
|
||||||
{limit != null && <span>{t("limit", { limit })}</span>}
|
|
||||||
{pages && <span>{t("pages", { pages })}</span>}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{(cellType || editMode) && (
|
|
||||||
<div className="flex flex-wrap gap-3 text-xs text-muted-foreground">
|
|
||||||
{editMode && <span>{t("mode", { mode: editMode })}</span>}
|
|
||||||
{cellType && <span>{t("cell", { cell: cellType })}</span>}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{(name === "write" || name === "notebookedit") &&
|
|
||||||
(content || newSource) &&
|
|
||||||
(lang === "markdown" || lang === "mdx" ? (
|
|
||||||
<div className="rounded-md border p-3 text-sm prose prose-sm dark:prose-invert max-w-none [&_ul]:list-inside [&_ol]:list-inside">
|
|
||||||
<MessageResponse>{content ?? newSource ?? ""}</MessageResponse>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<CodeBlock code={content ?? newSource ?? ""} language={lang} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1641,49 +1577,7 @@ function TodoWriteToolInput({ input }: { input: Record<string, unknown> }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function ApplyPatchToolInput({ input }: { input: string }) {
|
function ApplyPatchToolInput({ input }: { input: string }) {
|
||||||
const t = useTranslations("Folder.chat.contentParts")
|
return <UnifiedDiffPreview diffText={input} />
|
||||||
const { files, additions, deletions } = useMemo(
|
|
||||||
() => parseApplyPatchInput(input),
|
|
||||||
[input]
|
|
||||||
)
|
|
||||||
const opClass: Record<ApplyPatchOp, string> = {
|
|
||||||
add: "bg-green-500/15 text-green-600",
|
|
||||||
update: "bg-blue-500/15 text-blue-600",
|
|
||||||
delete: "bg-red-500/15 text-red-600",
|
|
||||||
move: "bg-purple-500/15 text-purple-600",
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-2">
|
|
||||||
<div className="flex flex-wrap gap-3 text-xs text-muted-foreground">
|
|
||||||
<span>{t("filesCount", { count: files.length })}</span>
|
|
||||||
{additions > 0 && <span>+{additions}</span>}
|
|
||||||
{deletions > 0 && <span>-{deletions}</span>}
|
|
||||||
</div>
|
|
||||||
{files.length > 0 && (
|
|
||||||
<div className="space-y-1 rounded-md bg-muted/40 p-2">
|
|
||||||
{files.slice(0, 8).map((file, index) => (
|
|
||||||
<div key={`${file.path}-${index}`} className="flex gap-2 text-xs">
|
|
||||||
<span
|
|
||||||
className={`shrink-0 rounded px-1.5 py-0.5 font-medium uppercase ${opClass[file.op]}`}
|
|
||||||
>
|
|
||||||
{file.op}
|
|
||||||
</span>
|
|
||||||
<span className="break-all font-mono text-foreground">
|
|
||||||
{file.path}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
{files.length > 8 && (
|
|
||||||
<div className="text-xs text-muted-foreground">
|
|
||||||
{t("moreFiles", { count: files.length - 8 })}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<CodeBlock code={input} language="diff" />
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Switch mode (plan) input ──────────────────────────────────────────
|
// ── Switch mode (plan) input ──────────────────────────────────────────
|
||||||
@@ -1806,20 +1700,41 @@ function GenericToolInput({ input }: { input: string }) {
|
|||||||
|
|
||||||
// ── Dispatcher ───────────────────────────────────────────────────────
|
// ── Dispatcher ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function isTruncatedInput(input: string): boolean {
|
||||||
|
return input.endsWith('..."') || input.endsWith("...")
|
||||||
|
}
|
||||||
|
|
||||||
function StructuredToolInput({
|
function StructuredToolInput({
|
||||||
toolName,
|
toolName,
|
||||||
input,
|
input,
|
||||||
|
output,
|
||||||
}: {
|
}: {
|
||||||
toolName: string
|
toolName: string
|
||||||
input: string
|
input: string
|
||||||
|
output?: string | null
|
||||||
}) {
|
}) {
|
||||||
|
const t = useTranslations("Folder.chat.contentParts")
|
||||||
const name = toolName.toLowerCase()
|
const name = toolName.toLowerCase()
|
||||||
const parsed = tryParseJson(input)
|
const parsed = tryParseJson(input)
|
||||||
|
const truncated =
|
||||||
|
(name === "edit" || name === "write" || name === "apply_patch") &&
|
||||||
|
isTruncatedInput(input)
|
||||||
|
|
||||||
|
const truncationBanner = truncated ? (
|
||||||
|
<div className="rounded-md bg-yellow-500/10 px-2.5 py-1.5 text-[11px] text-yellow-700 dark:text-yellow-400">
|
||||||
|
{t("inputTruncated")}
|
||||||
|
</div>
|
||||||
|
) : null
|
||||||
|
|
||||||
if (name === "apply_patch") {
|
if (name === "apply_patch") {
|
||||||
const patchInput =
|
const patchInput =
|
||||||
extractApplyPatchTextFromUnknownInput(input, parsed) ?? input
|
extractApplyPatchTextFromUnknownInput(input, parsed) ?? input
|
||||||
return <ApplyPatchToolInput input={patchInput} />
|
return (
|
||||||
|
<>
|
||||||
|
{truncationBanner}
|
||||||
|
<ApplyPatchToolInput input={patchInput} />
|
||||||
|
</>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (name === "bash" || name === "exec_command") {
|
if (name === "bash" || name === "exec_command") {
|
||||||
@@ -1843,16 +1758,41 @@ function StructuredToolInput({
|
|||||||
if (name === "edit") {
|
if (name === "edit") {
|
||||||
const patchInput = extractApplyPatchTextFromUnknownInput(input, parsed)
|
const patchInput = extractApplyPatchTextFromUnknownInput(input, parsed)
|
||||||
if (patchInput) {
|
if (patchInput) {
|
||||||
return <ApplyPatchToolInput input={patchInput} />
|
return (
|
||||||
|
<>
|
||||||
|
{truncationBanner}
|
||||||
|
<ApplyPatchToolInput input={patchInput} />
|
||||||
|
</>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
if (parsed) {
|
if (parsed) {
|
||||||
const changesPayload = extractEditChangesPayload(parsed)
|
const changesPayload = extractEditChangesPayload(parsed)
|
||||||
if (changesPayload.length > 0) {
|
if (changesPayload.length > 0) {
|
||||||
return <EditChangesToolInput changes={changesPayload} />
|
return (
|
||||||
|
<>
|
||||||
|
{truncationBanner}
|
||||||
|
<EditChangesToolInput changes={changesPayload} />
|
||||||
|
</>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Prefer tool output if it contains a structured diff with real line numbers
|
||||||
|
// (injected by backend from toolUseResult.structuredPatch)
|
||||||
|
if (output && typeof output === "string" && /^@@ /m.test(output)) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{truncationBanner}
|
||||||
|
<UnifiedDiffPreview diffText={output} />
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
if (isCanonicalEditPayload(parsed)) {
|
if (isCanonicalEditPayload(parsed)) {
|
||||||
return <EditToolInput input={parsed} />
|
return (
|
||||||
|
<>
|
||||||
|
{truncationBanner}
|
||||||
|
<EditToolInput input={parsed} />
|
||||||
|
</>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
return <GenericToolInput input={input} />
|
return <GenericToolInput input={input} />
|
||||||
}
|
}
|
||||||
@@ -1864,7 +1804,7 @@ function StructuredToolInput({
|
|||||||
name === "write" ||
|
name === "write" ||
|
||||||
name === "notebookedit"
|
name === "notebookedit"
|
||||||
)
|
)
|
||||||
return <FileToolInput toolName={toolName} input={parsed} />
|
return <FileToolInput toolName={toolName} input={parsed} output={output} />
|
||||||
if (name === "glob" || name === "grep")
|
if (name === "glob" || name === "grep")
|
||||||
return <SearchToolInput toolName={toolName} input={parsed} />
|
return <SearchToolInput toolName={toolName} input={parsed} />
|
||||||
if (name === "webfetch" || name === "websearch")
|
if (name === "webfetch" || name === "websearch")
|
||||||
@@ -2287,12 +2227,18 @@ const ToolCallPart = memo(function ToolCallPart({
|
|||||||
displayCommand,
|
displayCommand,
|
||||||
isRunning,
|
isRunning,
|
||||||
])
|
])
|
||||||
|
const isFileTool =
|
||||||
|
toolNameLower === "read" ||
|
||||||
|
toolNameLower === "read file" ||
|
||||||
|
toolNameLower === "write" ||
|
||||||
|
toolNameLower === "notebookedit"
|
||||||
const shouldHideDuplicateResult =
|
const shouldHideDuplicateResult =
|
||||||
(toolNameLower === "edit" ||
|
(toolNameLower === "edit" ||
|
||||||
toolNameLower === "apply_patch" ||
|
toolNameLower === "apply_patch" ||
|
||||||
toolNameLower === "switch_mode" ||
|
toolNameLower === "switch_mode" ||
|
||||||
toolNameLower === "enterplanmode" ||
|
toolNameLower === "enterplanmode" ||
|
||||||
toolNameLower === "exitplanmode") &&
|
toolNameLower === "exitplanmode" ||
|
||||||
|
isFileTool) &&
|
||||||
!part.errorText
|
!part.errorText
|
||||||
// Cline: attempt_completion — render as an expanded card with result + progress
|
// Cline: attempt_completion — render as an expanded card with result + progress
|
||||||
if (toolNameLower === "attempt_completion") {
|
if (toolNameLower === "attempt_completion") {
|
||||||
@@ -2348,6 +2294,7 @@ const ToolCallPart = memo(function ToolCallPart({
|
|||||||
<StructuredToolInput
|
<StructuredToolInput
|
||||||
toolName={normalizedToolName}
|
toolName={normalizedToolName}
|
||||||
input={part.input}
|
input={part.input}
|
||||||
|
output={part.output}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{(toolNameLower === "task" || toolNameLower === "agent") &&
|
{(toolNameLower === "task" || toolNameLower === "agent") &&
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import { Settings } from "lucide-react"
|
|||||||
import { useTranslations } from "next-intl"
|
import { useTranslations } from "next-intl"
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
import { loadFolderHistory, openSettingsWindow } from "@/lib/api"
|
import { loadFolderHistory, openSettingsWindow } from "@/lib/api"
|
||||||
|
import { matchShortcutEvent } from "@/lib/keyboard-shortcuts"
|
||||||
|
import { useShortcutSettings } from "@/hooks/use-shortcut-settings"
|
||||||
import type { FolderHistoryEntry } from "@/lib/types"
|
import type { FolderHistoryEntry } from "@/lib/types"
|
||||||
import { FolderList } from "@/components/welcome/folder-list"
|
import { FolderList } from "@/components/welcome/folder-list"
|
||||||
import { FolderActions } from "@/components/welcome/folder-actions"
|
import { FolderActions } from "@/components/welcome/folder-actions"
|
||||||
@@ -18,6 +20,28 @@ export function WelcomeScreen() {
|
|||||||
const t = useTranslations("WelcomePage")
|
const t = useTranslations("WelcomePage")
|
||||||
const [history, setHistory] = useState<FolderHistoryEntry[]>([])
|
const [history, setHistory] = useState<FolderHistoryEntry[]>([])
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
|
const { shortcuts } = useShortcutSettings()
|
||||||
|
|
||||||
|
const handleOpenSettings = useCallback(() => {
|
||||||
|
openSettingsWindow().catch((err) => {
|
||||||
|
console.error("[WelcomeScreen] failed to open settings:", err)
|
||||||
|
const resolvedError = resolveWelcomeError(err)
|
||||||
|
toast.error(t("toasts.openSettingsFailed"), {
|
||||||
|
description: resolvedError.detail ?? t(resolvedError.key),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}, [t])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
function handleKeyDown(e: KeyboardEvent) {
|
||||||
|
if (matchShortcutEvent(e, shortcuts.open_settings)) {
|
||||||
|
e.preventDefault()
|
||||||
|
handleOpenSettings()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
document.addEventListener("keydown", handleKeyDown)
|
||||||
|
return () => document.removeEventListener("keydown", handleKeyDown)
|
||||||
|
}, [shortcuts, handleOpenSettings])
|
||||||
|
|
||||||
const refreshHistory = useCallback(async () => {
|
const refreshHistory = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
@@ -49,15 +73,7 @@ export function WelcomeScreen() {
|
|||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="h-6 w-6 hover:text-foreground/80"
|
className="h-6 w-6 hover:text-foreground/80"
|
||||||
onClick={() => {
|
onClick={handleOpenSettings}
|
||||||
openSettingsWindow().catch((err) => {
|
|
||||||
console.error("[WelcomeScreen] failed to open settings:", err)
|
|
||||||
const resolvedError = resolveWelcomeError(err)
|
|
||||||
toast.error(t("toasts.openSettingsFailed"), {
|
|
||||||
description: resolvedError.detail ?? t(resolvedError.key),
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}}
|
|
||||||
title={t("openSettings")}
|
title={t("openSettings")}
|
||||||
aria-label={t("openSettings")}
|
aria-label={t("openSettings")}
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -1491,6 +1491,7 @@
|
|||||||
"showingTailOutput": "يتم عرض نهاية المخرجات أثناء البث لتحسين الأداء.",
|
"showingTailOutput": "يتم عرض نهاية المخرجات أثناء البث لتحسين الأداء.",
|
||||||
"result": "النتيجة",
|
"result": "النتيجة",
|
||||||
"unknown": "غير معروف",
|
"unknown": "غير معروف",
|
||||||
|
"inputTruncated": "تم اقتطاع الإدخال — قد يكون الفرق غير مكتمل.",
|
||||||
"replaceAll": "استبدال الكل",
|
"replaceAll": "استبدال الكل",
|
||||||
"filesCount": "الملفات: {count}",
|
"filesCount": "الملفات: {count}",
|
||||||
"update": "تحديث",
|
"update": "تحديث",
|
||||||
|
|||||||
@@ -1491,6 +1491,7 @@
|
|||||||
"showingTailOutput": "Zur besseren Performance wird während des Streamings nur die Endausgabe angezeigt.",
|
"showingTailOutput": "Zur besseren Performance wird während des Streamings nur die Endausgabe angezeigt.",
|
||||||
"result": "Ergebnis",
|
"result": "Ergebnis",
|
||||||
"unknown": "unbekannt",
|
"unknown": "unbekannt",
|
||||||
|
"inputTruncated": "Eingabe wurde gekürzt — Diff ist möglicherweise unvollständig.",
|
||||||
"replaceAll": "ALLES ERSETZEN",
|
"replaceAll": "ALLES ERSETZEN",
|
||||||
"filesCount": "Dateien: {count}",
|
"filesCount": "Dateien: {count}",
|
||||||
"update": "aktualisieren",
|
"update": "aktualisieren",
|
||||||
|
|||||||
@@ -1491,6 +1491,7 @@
|
|||||||
"showingTailOutput": "Showing tail output while streaming for performance.",
|
"showingTailOutput": "Showing tail output while streaming for performance.",
|
||||||
"result": "Result",
|
"result": "Result",
|
||||||
"unknown": "unknown",
|
"unknown": "unknown",
|
||||||
|
"inputTruncated": "Input was truncated — diff may be incomplete.",
|
||||||
"replaceAll": "REPLACE ALL",
|
"replaceAll": "REPLACE ALL",
|
||||||
"filesCount": "Files: {count}",
|
"filesCount": "Files: {count}",
|
||||||
"update": "update",
|
"update": "update",
|
||||||
|
|||||||
@@ -1491,6 +1491,7 @@
|
|||||||
"showingTailOutput": "Mostrando la salida final durante el streaming para mejorar el rendimiento.",
|
"showingTailOutput": "Mostrando la salida final durante el streaming para mejorar el rendimiento.",
|
||||||
"result": "Resultado",
|
"result": "Resultado",
|
||||||
"unknown": "desconocido",
|
"unknown": "desconocido",
|
||||||
|
"inputTruncated": "La entrada fue truncada — el diff puede estar incompleto.",
|
||||||
"replaceAll": "REEMPLAZAR TODO",
|
"replaceAll": "REEMPLAZAR TODO",
|
||||||
"filesCount": "Archivos: {count}",
|
"filesCount": "Archivos: {count}",
|
||||||
"update": "actualizar",
|
"update": "actualizar",
|
||||||
|
|||||||
@@ -1491,6 +1491,7 @@
|
|||||||
"showingTailOutput": "Affichage de la fin de la sortie pendant le streaming pour de meilleures performances.",
|
"showingTailOutput": "Affichage de la fin de la sortie pendant le streaming pour de meilleures performances.",
|
||||||
"result": "Résultat",
|
"result": "Résultat",
|
||||||
"unknown": "inconnu",
|
"unknown": "inconnu",
|
||||||
|
"inputTruncated": "L'entrée a été tronquée — le diff peut être incomplet.",
|
||||||
"replaceAll": "TOUT REMPLACER",
|
"replaceAll": "TOUT REMPLACER",
|
||||||
"filesCount": "Fichiers : {count}",
|
"filesCount": "Fichiers : {count}",
|
||||||
"update": "mettre à jour",
|
"update": "mettre à jour",
|
||||||
|
|||||||
@@ -1491,6 +1491,7 @@
|
|||||||
"showingTailOutput": "パフォーマンスのため、ストリーミング中は末尾出力を表示しています。",
|
"showingTailOutput": "パフォーマンスのため、ストリーミング中は末尾出力を表示しています。",
|
||||||
"result": "結果",
|
"result": "結果",
|
||||||
"unknown": "不明",
|
"unknown": "不明",
|
||||||
|
"inputTruncated": "入力が切り詰められました — diff が不完全な可能性があります。",
|
||||||
"replaceAll": "すべて置換",
|
"replaceAll": "すべて置換",
|
||||||
"filesCount": "ファイル: {count}",
|
"filesCount": "ファイル: {count}",
|
||||||
"update": "更新",
|
"update": "更新",
|
||||||
|
|||||||
@@ -1491,6 +1491,7 @@
|
|||||||
"showingTailOutput": "성능을 위해 스트리밍 중에는 출력의 끝부분만 표시합니다.",
|
"showingTailOutput": "성능을 위해 스트리밍 중에는 출력의 끝부분만 표시합니다.",
|
||||||
"result": "결과",
|
"result": "결과",
|
||||||
"unknown": "알 수 없음",
|
"unknown": "알 수 없음",
|
||||||
|
"inputTruncated": "입력이 잘렸습니다 — diff가 불완전할 수 있습니다.",
|
||||||
"replaceAll": "모두 바꾸기",
|
"replaceAll": "모두 바꾸기",
|
||||||
"filesCount": "파일: {count}",
|
"filesCount": "파일: {count}",
|
||||||
"update": "업데이트",
|
"update": "업데이트",
|
||||||
|
|||||||
@@ -1491,6 +1491,7 @@
|
|||||||
"showingTailOutput": "Mostrando a saída final durante o streaming para melhor desempenho.",
|
"showingTailOutput": "Mostrando a saída final durante o streaming para melhor desempenho.",
|
||||||
"result": "Resultado",
|
"result": "Resultado",
|
||||||
"unknown": "desconhecido",
|
"unknown": "desconhecido",
|
||||||
|
"inputTruncated": "A entrada foi truncada — o diff pode estar incompleto.",
|
||||||
"replaceAll": "SUBSTITUIR TUDO",
|
"replaceAll": "SUBSTITUIR TUDO",
|
||||||
"filesCount": "Arquivos: {count}",
|
"filesCount": "Arquivos: {count}",
|
||||||
"update": "atualizar",
|
"update": "atualizar",
|
||||||
|
|||||||
@@ -1491,6 +1491,7 @@
|
|||||||
"showingTailOutput": "为保证性能,流式输出时仅显示尾部内容。",
|
"showingTailOutput": "为保证性能,流式输出时仅显示尾部内容。",
|
||||||
"result": "结果",
|
"result": "结果",
|
||||||
"unknown": "未知",
|
"unknown": "未知",
|
||||||
|
"inputTruncated": "输入已截断,diff 可能不完整。",
|
||||||
"replaceAll": "全部替换",
|
"replaceAll": "全部替换",
|
||||||
"filesCount": "文件:{count}",
|
"filesCount": "文件:{count}",
|
||||||
"update": "更新",
|
"update": "更新",
|
||||||
|
|||||||
@@ -1491,6 +1491,7 @@
|
|||||||
"showingTailOutput": "為確保效能,串流輸出時僅顯示尾端內容。",
|
"showingTailOutput": "為確保效能,串流輸出時僅顯示尾端內容。",
|
||||||
"result": "結果",
|
"result": "結果",
|
||||||
"unknown": "未知",
|
"unknown": "未知",
|
||||||
|
"inputTruncated": "輸入已截斷,diff 可能不完整。",
|
||||||
"replaceAll": "全部替換",
|
"replaceAll": "全部替換",
|
||||||
"filesCount": "檔案:{count}",
|
"filesCount": "檔案:{count}",
|
||||||
"update": "更新",
|
"update": "更新",
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ export interface PermissionFileChange {
|
|||||||
oldText: string
|
oldText: string
|
||||||
newText: string
|
newText: string
|
||||||
unifiedDiff?: string
|
unifiedDiff?: string
|
||||||
|
startLine?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PermissionPlanEntry {
|
export interface PermissionPlanEntry {
|
||||||
@@ -111,7 +112,8 @@ function buildCompactDiffFromTexts(
|
|||||||
path: string,
|
path: string,
|
||||||
oldText: string,
|
oldText: string,
|
||||||
newText: string,
|
newText: string,
|
||||||
contextLines: number = 2
|
contextLines: number = 2,
|
||||||
|
startLine: number = 1
|
||||||
): string | null {
|
): string | null {
|
||||||
const oldLines = splitNormalizedLines(oldText)
|
const oldLines = splitNormalizedLines(oldText)
|
||||||
const newLines = splitNormalizedLines(newText)
|
const newLines = splitNormalizedLines(newText)
|
||||||
@@ -145,7 +147,15 @@ function buildCompactDiffFromTexts(
|
|||||||
Math.min(oldLines.length, oldLines.length - suffix + contextLines)
|
Math.min(oldLines.length, oldLines.length - suffix + contextLines)
|
||||||
)
|
)
|
||||||
|
|
||||||
const parts: string[] = [`--- ${path}`, `+++ ${path}`]
|
const oldStart = Math.max(1, startLine + prefix - before.length)
|
||||||
|
const oldCount = before.length + removed.length + after.length
|
||||||
|
const newCount = before.length + added.length + after.length
|
||||||
|
|
||||||
|
const parts: string[] = [
|
||||||
|
`--- ${path}`,
|
||||||
|
`+++ ${path}`,
|
||||||
|
`@@ -${oldStart},${oldCount} +${oldStart},${newCount} @@`,
|
||||||
|
]
|
||||||
for (const line of before) parts.push(` ${line}`)
|
for (const line of before) parts.push(` ${line}`)
|
||||||
for (const line of removed) parts.push(`-${line}`)
|
for (const line of removed) parts.push(`-${line}`)
|
||||||
for (const line of added) parts.push(`+${line}`)
|
for (const line of added) parts.push(`+${line}`)
|
||||||
@@ -189,7 +199,13 @@ function buildDiffPreviewFromChanges(
|
|||||||
typeof change.unifiedDiff === "string" &&
|
typeof change.unifiedDiff === "string" &&
|
||||||
change.unifiedDiff.trim().length > 0
|
change.unifiedDiff.trim().length > 0
|
||||||
? change.unifiedDiff.trim()
|
? change.unifiedDiff.trim()
|
||||||
: buildCompactDiffFromTexts(change.path, change.oldText, change.newText)
|
: buildCompactDiffFromTexts(
|
||||||
|
change.path,
|
||||||
|
change.oldText,
|
||||||
|
change.newText,
|
||||||
|
2,
|
||||||
|
change.startLine ?? 1
|
||||||
|
)
|
||||||
if (!block) continue
|
if (!block) continue
|
||||||
|
|
||||||
for (const line of block.split("\n")) {
|
for (const line of block.split("\n")) {
|
||||||
@@ -350,11 +366,18 @@ function parseChangeRecord(
|
|||||||
pickString(record, ["unifiedDiff", "unified_diff", "diff", "patch"]) ??
|
pickString(record, ["unifiedDiff", "unified_diff", "diff", "patch"]) ??
|
||||||
undefined
|
undefined
|
||||||
|
|
||||||
|
const rawStartLine = record._start_line ?? record.start_line
|
||||||
|
const startLine =
|
||||||
|
typeof rawStartLine === "number" && rawStartLine > 0
|
||||||
|
? rawStartLine
|
||||||
|
: undefined
|
||||||
|
|
||||||
return {
|
return {
|
||||||
path: normalizedPath,
|
path: normalizedPath,
|
||||||
oldText,
|
oldText,
|
||||||
newText,
|
newText,
|
||||||
unifiedDiff,
|
unifiedDiff,
|
||||||
|
startLine,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -402,11 +425,13 @@ function extractRawInputFileChanges(
|
|||||||
]) ?? ""
|
]) ?? ""
|
||||||
|
|
||||||
if (oldText || newText || changes.length === 0) {
|
if (oldText || newText || changes.length === 0) {
|
||||||
|
const rawSl = rawInputObj._start_line ?? rawInputObj.start_line
|
||||||
changes.push({
|
changes.push({
|
||||||
path: directPath,
|
path: directPath,
|
||||||
oldText,
|
oldText,
|
||||||
newText,
|
newText,
|
||||||
unifiedDiff: undefined,
|
unifiedDiff: undefined,
|
||||||
|
startLine: typeof rawSl === "number" && rawSl > 0 ? rawSl : undefined,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -495,7 +520,8 @@ function mergeFileChanges(
|
|||||||
const oldText = prev.oldText || change.oldText
|
const oldText = prev.oldText || change.oldText
|
||||||
const newText = prev.newText || change.newText
|
const newText = prev.newText || change.newText
|
||||||
const unifiedDiff = prev.unifiedDiff || change.unifiedDiff
|
const unifiedDiff = prev.unifiedDiff || change.unifiedDiff
|
||||||
merged.set(path, { path, oldText, newText, unifiedDiff })
|
const startLine = prev.startLine ?? change.startLine
|
||||||
|
merged.set(path, { path, oldText, newText, unifiedDiff, startLine })
|
||||||
}
|
}
|
||||||
return Array.from(merged.values())
|
return Array.from(merged.values())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import type { MessageTurn } from "./types"
|
import type { ContentBlock, MessageTurn } from "./types"
|
||||||
import { normalizeToolName } from "./tool-call-normalization"
|
import { normalizeToolName } from "./tool-call-normalization"
|
||||||
|
import { estimateChangedLineStats } from "./line-change-stats"
|
||||||
|
import { generateUnifiedDiff } from "./unified-diff-generator"
|
||||||
|
|
||||||
export type FileOperation = "read" | "edit" | "write" | "apply_patch"
|
export type FileOperation = "read" | "edit" | "write" | "apply_patch"
|
||||||
|
|
||||||
@@ -266,32 +268,12 @@ function countDiffLines(text: string): DiffStat {
|
|||||||
return { additions, deletions }
|
return { additions, deletions }
|
||||||
}
|
}
|
||||||
|
|
||||||
function createHunkHeader(oldLineCount: number, newLineCount: number): string {
|
|
||||||
const oldStart = oldLineCount === 0 ? 0 : 1
|
|
||||||
const newStart = newLineCount === 0 ? 0 : 1
|
|
||||||
return `@@ -${oldStart},${oldLineCount} +${newStart},${newLineCount} @@`
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildUnifiedDiff(
|
function buildUnifiedDiff(
|
||||||
filePath: string,
|
filePath: string,
|
||||||
oldText: string,
|
oldText: string,
|
||||||
newText: string
|
newText: string
|
||||||
): string | null {
|
): string | null {
|
||||||
if (!oldText && !newText) return null
|
return generateUnifiedDiff(oldText, newText, filePath)
|
||||||
|
|
||||||
const oldLines = oldText ? oldText.split("\n") : []
|
|
||||||
const newLines = newText ? newText.split("\n") : []
|
|
||||||
|
|
||||||
const lines: string[] = [
|
|
||||||
`--- a/${filePath}`,
|
|
||||||
`+++ b/${filePath}`,
|
|
||||||
createHunkHeader(oldLines.length, newLines.length),
|
|
||||||
]
|
|
||||||
|
|
||||||
for (const line of oldLines) lines.push(`-${line}`)
|
|
||||||
for (const line of newLines) lines.push(`+${line}`)
|
|
||||||
|
|
||||||
return lines.join("\n")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseEditChangeValue(value: unknown): EditChangePreview | null {
|
function parseEditChangeValue(value: unknown): EditChangePreview | null {
|
||||||
@@ -645,8 +627,12 @@ function computeLineDiff(
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
additions += countLines(change.newText)
|
const estimated = estimateChangedLineStats(
|
||||||
deletions += countLines(change.oldText)
|
change.oldText,
|
||||||
|
change.newText
|
||||||
|
)
|
||||||
|
additions += estimated.additions
|
||||||
|
deletions += estimated.deletions
|
||||||
}
|
}
|
||||||
|
|
||||||
return { additions, deletions }
|
return { additions, deletions }
|
||||||
@@ -662,10 +648,7 @@ function computeLineDiff(
|
|||||||
|
|
||||||
if (!oldStr && !newStr) return null
|
if (!oldStr && !newStr) return null
|
||||||
|
|
||||||
return {
|
return estimateChangedLineStats(oldStr, newStr)
|
||||||
additions: countLines(newStr),
|
|
||||||
deletions: countLines(oldStr),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (op === "write") {
|
if (op === "write") {
|
||||||
@@ -774,10 +757,12 @@ export function extractSessionFilesGrouped(
|
|||||||
block.input_preview,
|
block.input_preview,
|
||||||
normalizedPath
|
normalizedPath
|
||||||
)
|
)
|
||||||
|
const toolOutput = findToolResultOutput(turn.blocks, block.tool_use_id)
|
||||||
const diffChunk = buildDiffChunk(
|
const diffChunk = buildDiffChunk(
|
||||||
normalized,
|
normalized,
|
||||||
block.input_preview,
|
block.input_preview,
|
||||||
normalizedPath
|
normalizedPath,
|
||||||
|
toolOutput
|
||||||
)
|
)
|
||||||
|
|
||||||
currentFiles.push({
|
currentFiles.push({
|
||||||
@@ -836,10 +821,12 @@ export function buildSessionFileDiff(
|
|||||||
)
|
)
|
||||||
if (!blockPaths.includes(normalizedTargetPath)) continue
|
if (!blockPaths.includes(normalizedTargetPath)) continue
|
||||||
|
|
||||||
|
const toolOutput = findToolResultOutput(turn.blocks, block.tool_use_id)
|
||||||
const chunk = buildDiffChunk(
|
const chunk = buildDiffChunk(
|
||||||
normalized,
|
normalized,
|
||||||
block.input_preview,
|
block.input_preview,
|
||||||
normalizedTargetPath
|
normalizedTargetPath,
|
||||||
|
toolOutput
|
||||||
)
|
)
|
||||||
if (chunk && chunk.trim().length > 0) chunks.push(chunk.trim())
|
if (chunk && chunk.trim().length > 0) chunks.push(chunk.trim())
|
||||||
}
|
}
|
||||||
@@ -852,10 +839,30 @@ export function buildSessionFileDiff(
|
|||||||
return chunks.join("\n\n")
|
return chunks.join("\n\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Find the tool_result output matching a tool_use_id within the same turn. */
|
||||||
|
function findToolResultOutput(
|
||||||
|
blocks: ContentBlock[],
|
||||||
|
toolUseId: string | null
|
||||||
|
): string | null {
|
||||||
|
if (!toolUseId) return null
|
||||||
|
for (const block of blocks) {
|
||||||
|
if (
|
||||||
|
block.type === "tool_result" &&
|
||||||
|
block.tool_use_id === toolUseId &&
|
||||||
|
block.output_preview &&
|
||||||
|
!block.is_error
|
||||||
|
) {
|
||||||
|
return block.output_preview
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
function buildDiffChunk(
|
function buildDiffChunk(
|
||||||
op: string,
|
op: string,
|
||||||
inputPreview: string | null,
|
inputPreview: string | null,
|
||||||
filePath: string
|
filePath: string,
|
||||||
|
toolOutput?: string | null
|
||||||
): string | null {
|
): string | null {
|
||||||
if (!inputPreview) return null
|
if (!inputPreview) return null
|
||||||
|
|
||||||
@@ -877,6 +884,11 @@ function buildDiffChunk(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Prefer tool output if backend injected a real diff with line numbers
|
||||||
|
if (toolOutput && /^@@ /m.test(toolOutput)) {
|
||||||
|
return toolOutput.trim()
|
||||||
|
}
|
||||||
|
|
||||||
if (!parsed) return null
|
if (!parsed) return null
|
||||||
|
|
||||||
const oldStr =
|
const oldStr =
|
||||||
@@ -884,7 +896,15 @@ function buildDiffChunk(
|
|||||||
const newStr =
|
const newStr =
|
||||||
typeof parsed.new_string === "string" ? parsed.new_string : ""
|
typeof parsed.new_string === "string" ? parsed.new_string : ""
|
||||||
|
|
||||||
return buildUnifiedDiff(filePath, oldStr, newStr)
|
const diff = buildUnifiedDiff(filePath, oldStr, newStr)
|
||||||
|
if (!diff) return null
|
||||||
|
const startLine =
|
||||||
|
typeof parsed._start_line === "number" ? parsed._start_line : 0
|
||||||
|
if (startLine <= 1) return diff
|
||||||
|
return diff.replace(
|
||||||
|
/^@@ -(\d+),(\d+) \+(\d+),(\d+) @@/gm,
|
||||||
|
(_, _o, oc, _n, nc) => `@@ -${startLine},${oc} +${startLine},${nc} @@`
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (op === "write") {
|
if (op === "write") {
|
||||||
|
|||||||
180
src/lib/unified-diff-generator.ts
Normal file
180
src/lib/unified-diff-generator.ts
Normal file
@@ -0,0 +1,180 @@
|
|||||||
|
import { computeLineDiff, type DiffHunk } from "@/components/merge/merge-diff"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maximum product of line counts before falling back to naive diff.
|
||||||
|
* Avoids O(n*m) LCS blowup for very large inputs.
|
||||||
|
*/
|
||||||
|
const LCS_PAIR_BUDGET = 200_000
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a unified diff string from old and new text.
|
||||||
|
*
|
||||||
|
* Uses LCS-based line diff when within budget, falls back to
|
||||||
|
* simple "all deletions then all additions" for very large inputs.
|
||||||
|
*/
|
||||||
|
export function generateUnifiedDiff(
|
||||||
|
oldText: string,
|
||||||
|
newText: string,
|
||||||
|
filePath?: string,
|
||||||
|
contextLines: number = 3
|
||||||
|
): string | null {
|
||||||
|
if (!oldText && !newText) return null
|
||||||
|
if (oldText === newText) return null
|
||||||
|
|
||||||
|
const oldLines = oldText ? splitLines(oldText) : []
|
||||||
|
const newLines = newText ? splitLines(newText) : []
|
||||||
|
|
||||||
|
const path = filePath ?? "file"
|
||||||
|
const header = `--- a/${path}\n+++ b/${path}`
|
||||||
|
|
||||||
|
// Performance gate: fall back to naive diff for large inputs
|
||||||
|
if (oldLines.length * newLines.length > LCS_PAIR_BUDGET) {
|
||||||
|
return buildNaiveDiff(header, oldLines, newLines)
|
||||||
|
}
|
||||||
|
|
||||||
|
const hunks = computeLineDiff(oldLines, newLines)
|
||||||
|
if (hunks.length === 0) return null
|
||||||
|
|
||||||
|
const unifiedHunks = buildUnifiedHunks(oldLines, hunks, contextLines)
|
||||||
|
|
||||||
|
return `${header}\n${unifiedHunks}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function splitLines(text: string): string[] {
|
||||||
|
const lines = text.split("\n")
|
||||||
|
// Remove trailing empty line from trailing newline
|
||||||
|
if (lines.length > 0 && lines[lines.length - 1] === "") {
|
||||||
|
lines.pop()
|
||||||
|
}
|
||||||
|
return lines
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Naive diff: all deletions first, then all additions, with a single hunk header.
|
||||||
|
* Used as fallback when inputs are too large for LCS.
|
||||||
|
*/
|
||||||
|
function buildNaiveDiff(
|
||||||
|
header: string,
|
||||||
|
oldLines: string[],
|
||||||
|
newLines: string[]
|
||||||
|
): string {
|
||||||
|
const oldStart = oldLines.length === 0 ? 0 : 1
|
||||||
|
const newStart = newLines.length === 0 ? 0 : 1
|
||||||
|
const hunkHeader = `@@ -${oldStart},${oldLines.length} +${newStart},${newLines.length} @@`
|
||||||
|
|
||||||
|
const parts = [header, hunkHeader]
|
||||||
|
for (const line of oldLines) parts.push(`-${line}`)
|
||||||
|
for (const line of newLines) parts.push(`+${line}`)
|
||||||
|
return parts.join("\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert DiffHunk[] into unified diff text with context lines and hunk headers.
|
||||||
|
*
|
||||||
|
* Groups nearby hunks that overlap in their context windows into a single
|
||||||
|
* unified hunk, producing output similar to `diff -u`.
|
||||||
|
*/
|
||||||
|
function buildUnifiedHunks(
|
||||||
|
oldLines: string[],
|
||||||
|
hunks: DiffHunk[],
|
||||||
|
contextLines: number
|
||||||
|
): string {
|
||||||
|
// Build "change regions" with context, then merge overlapping ones
|
||||||
|
const regions = hunks.map((hunk) => ({
|
||||||
|
// Context-expanded range in old lines
|
||||||
|
ctxOldStart: Math.max(0, hunk.baseStart - contextLines),
|
||||||
|
ctxOldEnd: Math.min(
|
||||||
|
oldLines.length,
|
||||||
|
hunk.baseStart + hunk.baseCount + contextLines
|
||||||
|
),
|
||||||
|
hunk,
|
||||||
|
}))
|
||||||
|
|
||||||
|
// Merge overlapping regions
|
||||||
|
const merged: {
|
||||||
|
ctxOldStart: number
|
||||||
|
ctxOldEnd: number
|
||||||
|
hunks: DiffHunk[]
|
||||||
|
}[] = []
|
||||||
|
|
||||||
|
for (const region of regions) {
|
||||||
|
const last = merged[merged.length - 1]
|
||||||
|
if (last && region.ctxOldStart <= last.ctxOldEnd) {
|
||||||
|
// Overlapping — extend and add hunk
|
||||||
|
last.ctxOldEnd = Math.max(last.ctxOldEnd, region.ctxOldEnd)
|
||||||
|
last.hunks.push(region.hunk)
|
||||||
|
} else {
|
||||||
|
merged.push({
|
||||||
|
ctxOldStart: region.ctxOldStart,
|
||||||
|
ctxOldEnd: region.ctxOldEnd,
|
||||||
|
hunks: [region.hunk],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render each merged region as a unified hunk
|
||||||
|
const output: string[] = []
|
||||||
|
|
||||||
|
for (const group of merged) {
|
||||||
|
const lines: string[] = []
|
||||||
|
let oldCursor = group.ctxOldStart
|
||||||
|
let newLineCount = 0
|
||||||
|
const oldLineCount = group.ctxOldEnd - group.ctxOldStart
|
||||||
|
|
||||||
|
for (const hunk of group.hunks) {
|
||||||
|
// Context lines before this change
|
||||||
|
while (oldCursor < hunk.baseStart) {
|
||||||
|
lines.push(` ${oldLines[oldCursor]}`)
|
||||||
|
newLineCount++
|
||||||
|
oldCursor++
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deleted lines
|
||||||
|
for (let i = 0; i < hunk.baseCount; i++) {
|
||||||
|
lines.push(`-${oldLines[hunk.baseStart + i]}`)
|
||||||
|
oldCursor++
|
||||||
|
}
|
||||||
|
|
||||||
|
// Added lines
|
||||||
|
for (const newLine of hunk.newLines) {
|
||||||
|
lines.push(`+${newLine}`)
|
||||||
|
newLineCount++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Trailing context
|
||||||
|
while (oldCursor < group.ctxOldEnd) {
|
||||||
|
lines.push(` ${oldLines[oldCursor]}`)
|
||||||
|
newLineCount++
|
||||||
|
oldCursor++
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compute hunk header
|
||||||
|
const oldStart = oldLineCount === 0 ? 0 : group.ctxOldStart + 1
|
||||||
|
const newStart =
|
||||||
|
newLineCount === 0
|
||||||
|
? 0
|
||||||
|
: group.ctxOldStart +
|
||||||
|
1 +
|
||||||
|
computeNewOffset(group.hunks, group.ctxOldStart)
|
||||||
|
|
||||||
|
output.push(
|
||||||
|
`@@ -${oldStart},${oldLineCount} +${newStart},${newLineCount} @@`
|
||||||
|
)
|
||||||
|
output.push(...lines)
|
||||||
|
}
|
||||||
|
|
||||||
|
return output.join("\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compute the offset applied to new-line numbering by hunks before a given position.
|
||||||
|
*/
|
||||||
|
function computeNewOffset(hunks: DiffHunk[], beforeOldLine: number): number {
|
||||||
|
let offset = 0
|
||||||
|
for (const hunk of hunks) {
|
||||||
|
if (hunk.baseStart >= beforeOldLine) break
|
||||||
|
offset += hunk.newLines.length - hunk.baseCount
|
||||||
|
}
|
||||||
|
return offset
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user