From 8bd19738d02853bfa87c5ed9d06d4674885705de Mon Sep 17 00:00:00 2001 From: xintaofei Date: Sat, 28 Mar 2026 14:04:19 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96=E6=B6=88=E6=81=AF=E9=87=8C?= =?UTF-8?q?=E8=AF=BB/=E5=86=99=E5=86=85=E5=AE=B9=E6=98=BE=E7=A4=BA?= =?UTF-8?q?=E6=A0=B7=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src-tauri/src/parsers/claude.rs | 126 ++++++ src-tauri/src/parsers/codex.rs | 1 + src-tauri/src/parsers/gemini.rs | 1 + src-tauri/src/parsers/mod.rs | 113 +++++- src-tauri/src/parsers/openclaw.rs | 9 +- src-tauri/src/parsers/opencode.rs | 1 + src/components/ai-elements/tool.tsx | 6 +- src/components/diff/unified-diff-preview.tsx | 265 +++--------- src/components/files/file-workspace-panel.tsx | 3 +- .../message/content-parts-renderer.tsx | 384 +++++++----------- src/i18n/messages/ar.json | 1 + src/i18n/messages/de.json | 1 + src/i18n/messages/en.json | 1 + src/i18n/messages/es.json | 1 + src/i18n/messages/fr.json | 1 + src/i18n/messages/ja.json | 1 + src/i18n/messages/ko.json | 1 + src/i18n/messages/pt.json | 1 + src/i18n/messages/zh-CN.json | 1 + src/i18n/messages/zh-TW.json | 1 + src/lib/session-files.ts | 23 +- src/lib/unified-diff-generator.ts | 180 ++++++++ 22 files changed, 660 insertions(+), 462 deletions(-) create mode 100644 src/lib/unified-diff-generator.ts diff --git a/src-tauri/src/parsers/claude.rs b/src-tauri/src/parsers/claude.rs index 34df1ab..2061311 100644 --- a/src-tauri/src/parsers/claude.rs +++ b/src-tauri/src/parsers/claude.rs @@ -574,6 +574,131 @@ 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()); + + // For read tools, structurize with start_line from tool_input.offset + let output_preview = + if tool_name == "read" || tool_name == "Read" { + let start_line = value + .get("tool_input") + .and_then(|i| i.get("offset")) + .and_then(|o| o.as_u64()) + .map(|o| o + 1) + .unwrap_or(1); + output_text.map(|text| { + serde_json::json!({ + "start_line": start_line, + "content": text + }) + .to_string() + }) + } else { + output_text + }; + + // Find matching ToolUse in the last assistant message and use its ID + let matching_id = messages + .iter() + .rev() + .find(|m| matches!(m.role, MessageRole::Assistant)) + .and_then(|m| { + m.content.iter().rev().find_map(|b| { + if let ContentBlock::ToolUse { + tool_use_id: Some(ref id), + .. + } = b + { + Some(id.clone()) + } else { + 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 +708,7 @@ impl ClaudeParser { let mut turns = group_into_turns(messages); super::relocate_orphaned_tool_results(&mut turns); + super::structurize_read_tool_output(&mut turns); let context_window_used_tokens = latest_claude_context_window_used_tokens(&turns); let context_window_max_tokens = claude_context_window_max_tokens_for_model(model.as_deref()); diff --git a/src-tauri/src/parsers/codex.rs b/src-tauri/src/parsers/codex.rs index 50c6cda..a71ba9d 100644 --- a/src-tauri/src/parsers/codex.rs +++ b/src-tauri/src/parsers/codex.rs @@ -773,6 +773,7 @@ impl CodexParser { let mut turns = group_into_turns(messages); super::relocate_orphaned_tool_results(&mut turns); + super::structurize_read_tool_output(&mut turns); let mut session_stats = super::compute_session_stats(&turns); session_stats = merge_codex_total_usage_stats(session_stats, latest_total_usage, latest_total_tokens); diff --git a/src-tauri/src/parsers/gemini.rs b/src-tauri/src/parsers/gemini.rs index 399f37c..9e69a87 100644 --- a/src-tauri/src/parsers/gemini.rs +++ b/src-tauri/src/parsers/gemini.rs @@ -556,6 +556,7 @@ impl GeminiParser { let mut turns = group_into_turns(messages); super::relocate_orphaned_tool_results(&mut turns); + super::structurize_read_tool_output(&mut turns); summary.message_count = turns.len() as u32; summary.id = conversation_id.to_string(); let context_window_used_tokens = super::latest_turn_total_usage_tokens(&turns); diff --git a/src-tauri/src/parsers/mod.rs b/src-tauri/src/parsers/mod.rs index bb16962..56f3377 100644 --- a/src-tauri/src/parsers/mod.rs +++ b/src-tauri/src/parsers/mod.rs @@ -4,7 +4,7 @@ pub mod gemini; pub mod openclaw; pub mod opencode; -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use std::sync::OnceLock; use regex::Regex; @@ -292,6 +292,117 @@ pub fn relocate_orphaned_tool_results(turns: &mut Vec) { 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 = 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::() { + 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"}`. +fn strip_numbered_lines(text: &str) -> Option { + 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(), + ) +} + /// Extract the last path component as the folder name. pub fn folder_name_from_path(path: &str) -> String { path.rsplit(['/', '\\']).next().unwrap_or(path).to_string() diff --git a/src-tauri/src/parsers/openclaw.rs b/src-tauri/src/parsers/openclaw.rs index 1fcc8df..aaf2876 100644 --- a/src-tauri/src/parsers/openclaw.rs +++ b/src-tauri/src/parsers/openclaw.rs @@ -652,6 +652,7 @@ impl OpenClawParser { let folder_name = folder_path.as_ref().map(|p| folder_name_from_path(p)); let mut turns = group_into_turns(messages); super::relocate_orphaned_tool_results(&mut turns); + super::structurize_read_tool_output(&mut turns); let context_window_used_tokens = latest_turn_total_usage_tokens(&turns); let context_window_max_tokens = session_meta @@ -983,9 +984,15 @@ fn extract_assistant_content(value: &serde_json::Value) -> Vec { .and_then(|n| n.as_str()) .unwrap_or("unknown") .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 s = a.to_string(); - truncate_str(&s, 500) + truncate_str(&s, max_len) }); blocks.push(ContentBlock::ToolUse { tool_use_id, diff --git a/src-tauri/src/parsers/opencode.rs b/src-tauri/src/parsers/opencode.rs index af2f526..df799b4 100644 --- a/src-tauri/src/parsers/opencode.rs +++ b/src-tauri/src/parsers/opencode.rs @@ -189,6 +189,7 @@ impl OpenCodeParser { let messages = self.load_sqlite_messages(&conn, conversation_id).await?; let mut turns = group_into_turns(messages); super::relocate_orphaned_tool_results(&mut turns); + super::structurize_read_tool_output(&mut turns); let context_window_used_tokens = super::latest_turn_total_usage_tokens(&turns); let context_window_max_tokens = super::infer_context_window_max_tokens(summary.model.as_deref()); diff --git a/src/components/ai-elements/tool.tsx b/src/components/ai-elements/tool.tsx index 45852f7..ebbe131 100644 --- a/src/components/ai-elements/tool.tsx +++ b/src/components/ai-elements/tool.tsx @@ -22,6 +22,7 @@ import { useTranslations } from "next-intl" import { isValidElement } from "react" import { CodeBlock } from "./code-block" +import { UnifiedDiffPreview } from "@/components/diff/unified-diff-preview" import { MessageResponse } from "./message" export type ToolProps = ComponentProps @@ -380,9 +381,10 @@ export const ToolOutput = ({ {output} ) + } else if (lang === "diff") { + Output = } else { - const language = detectOutputLanguage(output) - Output = + Output = } } diff --git a/src/components/diff/unified-diff-preview.tsx b/src/components/diff/unified-diff-preview.tsx index 97efd07..e294489 100644 --- a/src/components/diff/unified-diff-preview.tsx +++ b/src/components/diff/unified-diff-preview.tsx @@ -1,13 +1,9 @@ "use client" -import { useCallback, useEffect, useMemo, useRef } from "react" -import dynamic from "next/dynamic" -import type { editor as MonacoEditorNs } from "monaco-editor" +import { useMemo } from "react" import { useTranslations } from "next-intl" import { useFolderContext } from "@/contexts/folder-context" -import { defineMonacoThemes, useMonacoThemeSync } from "@/lib/monaco-themes" import { cn } from "@/lib/utils" -import "@/lib/monaco-local" type RowMarker = "none" | "added" | "deleted" | "modified" type DiffFileMode = "modified" | "added" | "deleted" | "renamed" @@ -67,14 +63,18 @@ interface WorkingFile { hunks: WorkingHunk[] } -interface HunkPreviewLine { - text: string - marker: RowMarker +const ROW_CLASS: Record = { + none: "", + 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"), { - ssr: false, -}) +const SIGN_CLASS: Record = { + "+": "text-green-700 dark:text-green-400", + "-": "text-red-700 dark:text-red-400", + " ": "text-muted-foreground/50", +} function normalizePath(raw: string): string | null { const trimmed = raw.trim().replace(/^"|"$/g, "") @@ -190,13 +190,11 @@ function classifyRows(rows: RawDiffRow[]): ParsedDiffRow[] { addEnd += 1 } - const delRows = rows.slice(index, delEnd) - const addRows = rows.slice(delEnd, addEnd) - const modifiedPairs = Math.min(delRows.length, addRows.length) - - for (const [delta, row] of delRows.entries()) { + for (let d = index; d < delEnd; d++) { + const row = rows[d] + if (!row) continue parsed.push({ - type: delta < modifiedPairs ? "modified" : "deleted", + type: "deleted", text: row.text, sign: "-", 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({ - type: delta < modifiedPairs ? "modified" : "added", + type: "added", text: row.text, sign: "+", oldLine: row.oldLine, @@ -440,171 +440,53 @@ function toDisplayPath(filePath: string, folderPath: string | null): string { return normalizedPath } -function countHunkChanges(hunk: ParsedDiffHunk): { - additions: number - deletions: number -} { - 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 rowMarker(row: ParsedDiffRow): RowMarker { + if (row.type === "added") return "added" + if (row.type === "deleted") return "deleted" + return "none" } -function buildHunkPreviewLines(rows: ParsedDiffRow[]): { - lines: HunkPreviewLine[] -} { - const lines: HunkPreviewLine[] = rows.map((row) => { - 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(null) - const decorationsRef = useRef([]) - - 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 = [] - }, - [] - ) - +function HunkLines({ rows }: { rows: ParsedDiffRow[] }) { return ( - { - editorRef.current = editor - applyDecorations() - }} - path={`inmemory://session-hunk/${encodeURIComponent(modelId)}`} - value={renderedContent} - language="plaintext" - theme={theme} - loading={ -
- {t("loadingHunk")} -
- } - options={{ - readOnly: true, - minimap: { enabled: false }, - automaticLayout: true, - fontSize: 12, - lineNumbers: "off", - lineDecorationsWidth: 10, - glyphMargin: false, - wordWrap: "off", - scrollBeyondLastLine: false, - renderLineHighlight: "none", - contextmenu: false, - folding: false, - scrollbar: { - alwaysConsumeMouseWheel: false, - }, - padding: { top: 6, bottom: 6 }, - }} - /> +
+ {rows.map((row, i) => { + const marker = rowMarker(row) + return ( +
+ + {row.oldLine ?? ""} + + + {row.newLine ?? ""} + + + {row.sign === " " ? "" : row.sign} + + {row.text} +
+ ) + })} +
) } export function UnifiedDiffPreview({ diffText, - modelId, className, }: { diffText: string + /** @deprecated No longer used — kept for API compat */ modelId?: string className?: string }) { const t = useTranslations("Folder.diffPreview") const { folder } = useFolderContext() const files = useMemo(() => parseUnifiedDiff(diffText), [diffText]) - const theme = useMonacoThemeSync() if (!diffText.trim()) { return ( @@ -621,8 +503,8 @@ export function UnifiedDiffPreview({ if (files.length === 0) { return ( -
-
+      
+
           {diffText}
         
@@ -630,14 +512,14 @@ export function UnifiedDiffPreview({ } return ( -
+
{files.map((file) => (
-
+
{t(modeKey(file.mode))} @@ -657,41 +539,10 @@ export function UnifiedDiffPreview({
-
- {file.hunks.map((hunk, index) => { - const hunkStats = countHunkChanges(hunk) - - return ( -
-
- {t("hunkLabel", { index: index + 1 })} - - - +{hunkStats.additions} - - - -{hunkStats.deletions} - - -
-
- -
-
- ) - })} +
+ {file.hunks.map((hunk) => ( + + ))}
))} diff --git a/src/components/files/file-workspace-panel.tsx b/src/components/files/file-workspace-panel.tsx index 768112c..7d81137 100644 --- a/src/components/files/file-workspace-panel.tsx +++ b/src/components/files/file-workspace-panel.tsx @@ -1270,8 +1270,7 @@ export function FileWorkspacePanel() { )}
) diff --git a/src/components/message/content-parts-renderer.tsx b/src/components/message/content-parts-renderer.tsx index 7961c18..2002ebc 100644 --- a/src/components/message/content-parts-renderer.tsx +++ b/src/components/message/content-parts-renderer.tsx @@ -1,5 +1,4 @@ import { memo, useMemo, useState, type ReactNode } from "react" -import type { BundledLanguage } from "shiki" import type { AdaptedContentPart } from "@/lib/adapters/ai-elements-adapter" import type { MessageRole } from "@/lib/types" import { normalizeToolName } from "@/lib/tool-call-normalization" @@ -17,6 +16,8 @@ import { } from "@/components/ai-elements/tool" import { Terminal } from "@/components/ai-elements/terminal" import { CodeBlock } from "@/components/ai-elements/code-block" +import { UnifiedDiffPreview } from "@/components/diff/unified-diff-preview" +import { generateUnifiedDiff } from "@/lib/unified-diff-generator" import { Reasoning, ReasoningTrigger, @@ -374,62 +375,6 @@ function num(obj: Record, key: string): number | undefined { return typeof v === "number" ? v : undefined } -/** Guess shiki language from file path extension. */ -const EXT_LANG_MAP: Record = { - 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 ApplyPatchFile = { @@ -1215,115 +1160,46 @@ function localizeDerivedToolTitle( /** Edit tool: file path + unified diff view */ function EditToolInput({ input }: { input: Record }) { - const t = useTranslations("Folder.chat.contentParts") const filePath = str(input, "file_path") const oldString = str(input, "old_string") ?? "" const newString = str(input, "new_string") ?? "" - const replaceAll = input.replace_all === true const diffCode = useMemo(() => { - const parts: string[] = [] - if (oldString) { - for (const line of oldString.split("\n")) { - parts.push(`- ${line}`) - } - } - if (newString) { - for (const line of newString.split("\n")) { - parts.push(`+ ${line}`) - } - } - return parts.join("\n") - }, [oldString, newString]) + return ( + generateUnifiedDiff(oldString, newString, filePath ?? undefined) ?? "" + ) + }, [oldString, newString, filePath]) - return ( -
-
- - - {filePath ?? t("unknown")} - - {replaceAll && ( - - {t("replaceAll")} - - )} -
- {diffCode && } -
- ) + return diffCode ? : null } -/** Edit tool (changes payload): file list + summary + combined diff view */ +/** Edit tool (changes payload): combined diff view */ function EditChangesToolInput({ changes }: { changes: EditChangePreview[] }) { - const t = useTranslations("Folder.chat.contentParts") - const { additions, deletions, diffCode } = useMemo(() => { - let additions = 0 - let deletions = 0 + const diffCode = useMemo(() => { const diffParts: string[] = [] for (const change of changes) { if (change.unifiedDiff && change.unifiedDiff.trim().length > 0) { diffParts.push(change.unifiedDiff.trim()) 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 } - const oldLines = change.oldText ? change.oldText.split("\n") : [] - const newLines = change.newText ? change.newText.split("\n") : [] - - deletions += oldLines.length - additions += newLines.length - - diffParts.push(`--- ${change.path}`) - diffParts.push(`+++ ${change.path}`) - for (const line of oldLines) { - diffParts.push(`-${line}`) + const generated = generateUnifiedDiff( + change.oldText, + change.newText, + change.path + ) + if (generated) { + diffParts.push(generated) + diffParts.push("") } - for (const line of newLines) { - diffParts.push(`+${line}`) - } - diffParts.push("") } - return { - additions, - deletions, - diffCode: diffParts.join("\n").trim(), - } + return diffParts.join("\n").trim() }, [changes]) - return ( -
-
- {t("filesCount", { count: changes.length })} - {additions > 0 && +{additions}} - {deletions > 0 && -{deletions}} -
-
- {changes.slice(0, 8).map((change, index) => ( -
- - {t("update")} - - - {change.path} - -
- ))} - {changes.length > 8 && ( -
- {t("moreFiles", { count: changes.length - 8 })} -
- )} -
- {diffCode && } -
- ) + return diffCode ? : null } /** Bash / exec_command: terminal-style command display */ @@ -1358,13 +1234,60 @@ function BashToolInput({ input }: { input: Record }) { ) } +/** + * 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 ( +
+ {lines.map((line, i) => ( +
+ + {startLine + i} + + {line} +
+ ))} +
+ ) +} + /** Read / Write / NotebookEdit: file-focused display */ function FileToolInput({ toolName, input, + output, }: { toolName: string input: Record + output?: string | null }) { const t = useTranslations("Folder.chat.contentParts") const name = toolName.toLowerCase() @@ -1377,48 +1300,52 @@ function FileToolInput({ const pages = str(input, "pages") const cellType = str(input, "cell_type") const editMode = str(input, "edit_mode") + const isRead = name === "read" || name === "read file" - const lang = filePath - ? guessLangFromPath(filePath) - : ("log" as BundledLanguage) + const badges: string[] = [] + if (offset != null) badges.push(t("offset", { offset })) + 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 ( -
- {filePath && ( -
- {name === "read" || name === "read file" ? ( - - ) : ( - - )} - - {filePath} +
+
+ + {isRead ? "READ" : "WRITE"} + + + {filePath ?? t("unknown")} + + {badges.length > 0 && ( + + {badges.map((b) => ( + {b} + ))} + )} +
+ {displayContent && ( +
+
)} - {(offset != null || limit != null || pages) && ( -
- {offset != null && {t("offset", { offset })}} - {limit != null && {t("limit", { limit })}} - {pages && {t("pages", { pages })}} -
- )} - {(cellType || editMode) && ( -
- {editMode && {t("mode", { mode: editMode })}} - {cellType && {t("cell", { cell: cellType })}} -
- )} - {(name === "write" || name === "notebookedit") && - (content || newSource) && - (lang === "markdown" || lang === "mdx" ? ( -
- {content ?? newSource ?? ""} -
- ) : ( - - ))} -
+ ) } @@ -1629,49 +1556,7 @@ function TodoWriteToolInput({ input }: { input: Record }) { } function ApplyPatchToolInput({ input }: { input: string }) { - const t = useTranslations("Folder.chat.contentParts") - const { files, additions, deletions } = useMemo( - () => parseApplyPatchInput(input), - [input] - ) - const opClass: Record = { - 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 ( -
-
- {t("filesCount", { count: files.length })} - {additions > 0 && +{additions}} - {deletions > 0 && -{deletions}} -
- {files.length > 0 && ( -
- {files.slice(0, 8).map((file, index) => ( -
- - {file.op} - - - {file.path} - -
- ))} - {files.length > 8 && ( -
- {t("moreFiles", { count: files.length - 8 })} -
- )} -
- )} - -
- ) + return } // ── Switch mode (plan) input ────────────────────────────────────────── @@ -1794,20 +1679,41 @@ function GenericToolInput({ input }: { input: string }) { // ── Dispatcher ─────────────────────────────────────────────────────── +function isTruncatedInput(input: string): boolean { + return input.endsWith('..."') || input.endsWith("...") +} + function StructuredToolInput({ toolName, input, + output, }: { toolName: string input: string + output?: string | null }) { + const t = useTranslations("Folder.chat.contentParts") const name = toolName.toLowerCase() const parsed = tryParseJson(input) + const truncated = + (name === "edit" || name === "write" || name === "apply_patch") && + isTruncatedInput(input) + + const truncationBanner = truncated ? ( +
+ {t("inputTruncated")} +
+ ) : null if (name === "apply_patch") { const patchInput = extractApplyPatchTextFromUnknownInput(input, parsed) ?? input - return + return ( + <> + {truncationBanner} + + + ) } if (name === "bash" || name === "exec_command") { @@ -1831,16 +1737,31 @@ function StructuredToolInput({ if (name === "edit") { const patchInput = extractApplyPatchTextFromUnknownInput(input, parsed) if (patchInput) { - return + return ( + <> + {truncationBanner} + + + ) } if (parsed) { const changesPayload = extractEditChangesPayload(parsed) if (changesPayload.length > 0) { - return + return ( + <> + {truncationBanner} + + + ) } } if (isCanonicalEditPayload(parsed)) { - return + return ( + <> + {truncationBanner} + + + ) } return } @@ -1852,7 +1773,7 @@ function StructuredToolInput({ name === "write" || name === "notebookedit" ) - return + return if (name === "glob" || name === "grep") return if (name === "webfetch" || name === "websearch") @@ -2275,12 +2196,18 @@ const ToolCallPart = memo(function ToolCallPart({ displayCommand, isRunning, ]) + const isFileTool = + toolNameLower === "read" || + toolNameLower === "read file" || + toolNameLower === "write" || + toolNameLower === "notebookedit" const shouldHideDuplicateResult = (toolNameLower === "edit" || toolNameLower === "apply_patch" || toolNameLower === "switch_mode" || toolNameLower === "enterplanmode" || - toolNameLower === "exitplanmode") && + toolNameLower === "exitplanmode" || + isFileTool) && !part.errorText const open = (isRunning && (isCommandTool || hasLiveOutput)) || manualOpen @@ -2299,6 +2226,7 @@ const ToolCallPart = memo(function ToolCallPart({ )} {(toolNameLower === "task" || toolNameLower === "agent") && diff --git a/src/i18n/messages/ar.json b/src/i18n/messages/ar.json index 190137a..5536116 100644 --- a/src/i18n/messages/ar.json +++ b/src/i18n/messages/ar.json @@ -1485,6 +1485,7 @@ "showingTailOutput": "يتم عرض نهاية المخرجات أثناء البث لتحسين الأداء.", "result": "النتيجة", "unknown": "غير معروف", + "inputTruncated": "Input was truncated — diff may be incomplete.", "replaceAll": "استبدال الكل", "filesCount": "الملفات: {count}", "update": "تحديث", diff --git a/src/i18n/messages/de.json b/src/i18n/messages/de.json index 52ba47f..47a3511 100644 --- a/src/i18n/messages/de.json +++ b/src/i18n/messages/de.json @@ -1485,6 +1485,7 @@ "showingTailOutput": "Zur besseren Performance wird während des Streamings nur die Endausgabe angezeigt.", "result": "Ergebnis", "unknown": "unbekannt", + "inputTruncated": "Input was truncated — diff may be incomplete.", "replaceAll": "ALLES ERSETZEN", "filesCount": "Dateien: {count}", "update": "aktualisieren", diff --git a/src/i18n/messages/en.json b/src/i18n/messages/en.json index f833d87..177b76f 100644 --- a/src/i18n/messages/en.json +++ b/src/i18n/messages/en.json @@ -1485,6 +1485,7 @@ "showingTailOutput": "Showing tail output while streaming for performance.", "result": "Result", "unknown": "unknown", + "inputTruncated": "Input was truncated — diff may be incomplete.", "replaceAll": "REPLACE ALL", "filesCount": "Files: {count}", "update": "update", diff --git a/src/i18n/messages/es.json b/src/i18n/messages/es.json index e231eb0..56e75f8 100644 --- a/src/i18n/messages/es.json +++ b/src/i18n/messages/es.json @@ -1485,6 +1485,7 @@ "showingTailOutput": "Mostrando la salida final durante el streaming para mejorar el rendimiento.", "result": "Resultado", "unknown": "desconocido", + "inputTruncated": "Input was truncated — diff may be incomplete.", "replaceAll": "REEMPLAZAR TODO", "filesCount": "Archivos: {count}", "update": "actualizar", diff --git a/src/i18n/messages/fr.json b/src/i18n/messages/fr.json index a095030..7cda82d 100644 --- a/src/i18n/messages/fr.json +++ b/src/i18n/messages/fr.json @@ -1485,6 +1485,7 @@ "showingTailOutput": "Affichage de la fin de la sortie pendant le streaming pour de meilleures performances.", "result": "Résultat", "unknown": "inconnu", + "inputTruncated": "Input was truncated — diff may be incomplete.", "replaceAll": "TOUT REMPLACER", "filesCount": "Fichiers : {count}", "update": "mettre à jour", diff --git a/src/i18n/messages/ja.json b/src/i18n/messages/ja.json index 05e5831..09f00ea 100644 --- a/src/i18n/messages/ja.json +++ b/src/i18n/messages/ja.json @@ -1485,6 +1485,7 @@ "showingTailOutput": "パフォーマンスのため、ストリーミング中は末尾出力を表示しています。", "result": "結果", "unknown": "不明", + "inputTruncated": "Input was truncated — diff may be incomplete.", "replaceAll": "すべて置換", "filesCount": "ファイル: {count}", "update": "更新", diff --git a/src/i18n/messages/ko.json b/src/i18n/messages/ko.json index 34f73aa..e338452 100644 --- a/src/i18n/messages/ko.json +++ b/src/i18n/messages/ko.json @@ -1485,6 +1485,7 @@ "showingTailOutput": "성능을 위해 스트리밍 중에는 출력의 끝부분만 표시합니다.", "result": "결과", "unknown": "알 수 없음", + "inputTruncated": "Input was truncated — diff may be incomplete.", "replaceAll": "모두 바꾸기", "filesCount": "파일: {count}", "update": "업데이트", diff --git a/src/i18n/messages/pt.json b/src/i18n/messages/pt.json index 0dbbe38..f6fe5c7 100644 --- a/src/i18n/messages/pt.json +++ b/src/i18n/messages/pt.json @@ -1485,6 +1485,7 @@ "showingTailOutput": "Mostrando a saída final durante o streaming para melhor desempenho.", "result": "Resultado", "unknown": "desconhecido", + "inputTruncated": "Input was truncated — diff may be incomplete.", "replaceAll": "SUBSTITUIR TUDO", "filesCount": "Arquivos: {count}", "update": "atualizar", diff --git a/src/i18n/messages/zh-CN.json b/src/i18n/messages/zh-CN.json index c419abe..18a5c3b 100644 --- a/src/i18n/messages/zh-CN.json +++ b/src/i18n/messages/zh-CN.json @@ -1485,6 +1485,7 @@ "showingTailOutput": "为保证性能,流式输出时仅显示尾部内容。", "result": "结果", "unknown": "未知", + "inputTruncated": "输入已截断,diff 可能不完整。", "replaceAll": "全部替换", "filesCount": "文件:{count}", "update": "更新", diff --git a/src/i18n/messages/zh-TW.json b/src/i18n/messages/zh-TW.json index 6c1f01a..270c5d7 100644 --- a/src/i18n/messages/zh-TW.json +++ b/src/i18n/messages/zh-TW.json @@ -1485,6 +1485,7 @@ "showingTailOutput": "為確保效能,串流輸出時僅顯示尾端內容。", "result": "結果", "unknown": "未知", + "inputTruncated": "Input was truncated — diff may be incomplete.", "replaceAll": "全部替換", "filesCount": "檔案:{count}", "update": "更新", diff --git a/src/lib/session-files.ts b/src/lib/session-files.ts index 7883af0..1e60315 100644 --- a/src/lib/session-files.ts +++ b/src/lib/session-files.ts @@ -1,5 +1,6 @@ import type { MessageTurn } from "./types" import { normalizeToolName } from "./tool-call-normalization" +import { generateUnifiedDiff } from "./unified-diff-generator" export type FileOperation = "read" | "edit" | "write" | "apply_patch" @@ -266,32 +267,12 @@ function countDiffLines(text: string): DiffStat { 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( filePath: string, oldText: string, newText: string ): string | null { - if (!oldText && !newText) return null - - 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") + return generateUnifiedDiff(oldText, newText, filePath) } function parseEditChangeValue(value: unknown): EditChangePreview | null { diff --git a/src/lib/unified-diff-generator.ts b/src/lib/unified-diff-generator.ts new file mode 100644 index 0000000..7039171 --- /dev/null +++ b/src/lib/unified-diff-generator.ts @@ -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 +}