From af6d8dd9a82bf9f2d02310130e40793686a230a6 Mon Sep 17 00:00:00 2001 From: xintaofei Date: Tue, 24 Mar 2026 19:29:45 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E5=B9=B6=E8=A1=8C=E5=91=BD?= =?UTF-8?q?=E4=BB=A4=E7=9A=84=E6=89=A7=E8=A1=8C=E7=BB=93=E6=9E=9C=E6=B2=A1?= =?UTF-8?q?=E6=9C=89=E5=AF=B9=E5=BA=94=E5=88=B0=E5=91=BD=E4=BB=A4=E5=9D=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src-tauri/src/parsers/claude.rs | 3 +- src-tauri/src/parsers/codex.rs | 3 +- src-tauri/src/parsers/gemini.rs | 3 +- src-tauri/src/parsers/mod.rs | 88 ++++++++++++- src-tauri/src/parsers/openclaw.rs | 3 +- src-tauri/src/parsers/opencode.rs | 3 +- .../message/content-parts-renderer.tsx | 118 ++++++++++++++---- src/components/message/message-list-view.tsx | 52 ++++---- 8 files changed, 213 insertions(+), 60 deletions(-) diff --git a/src-tauri/src/parsers/claude.rs b/src-tauri/src/parsers/claude.rs index 39430e1..f0df591 100644 --- a/src-tauri/src/parsers/claude.rs +++ b/src-tauri/src/parsers/claude.rs @@ -558,7 +558,8 @@ impl ClaudeParser { let folder_path = cwd.clone(); let folder_name = folder_path.as_ref().map(|p| folder_name_from_path(p)); - let turns = group_into_turns(messages); + let mut turns = group_into_turns(messages); + super::relocate_orphaned_tool_results(&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 b311277..50c6cda 100644 --- a/src-tauri/src/parsers/codex.rs +++ b/src-tauri/src/parsers/codex.rs @@ -771,7 +771,8 @@ impl CodexParser { let folder_path = cwd.clone(); let folder_name = folder_path.as_ref().map(|p| folder_name_from_path(p)); - let turns = group_into_turns(messages); + let mut turns = group_into_turns(messages); + super::relocate_orphaned_tool_results(&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 610e87a..399f37c 100644 --- a/src-tauri/src/parsers/gemini.rs +++ b/src-tauri/src/parsers/gemini.rs @@ -554,7 +554,8 @@ impl GeminiParser { } } - let turns = group_into_turns(messages); + let mut turns = group_into_turns(messages); + super::relocate_orphaned_tool_results(&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 5dc2450..bb16962 100644 --- a/src-tauri/src/parsers/mod.rs +++ b/src-tauri/src/parsers/mod.rs @@ -4,12 +4,13 @@ pub mod gemini; pub mod openclaw; pub mod opencode; +use std::collections::HashMap; use std::sync::OnceLock; use regex::Regex; use crate::models::{ - ConversationDetail, ConversationSummary, MessageTurn, SessionStats, TurnUsage, + ContentBlock, ConversationDetail, ConversationSummary, MessageTurn, SessionStats, TurnUsage, }; #[derive(Debug, thiserror::Error)] @@ -206,6 +207,91 @@ pub fn merge_context_window_stats( } } +/// Relocate orphaned tool_result blocks to the turn that contains their matching tool_use. +/// +/// After `group_into_turns` splits assistant rounds, async tool execution can cause +/// a tool_result to land in a later turn than its corresponding tool_use. +/// This post-processing step moves such orphaned results back. +pub fn relocate_orphaned_tool_results(turns: &mut Vec) { + // Build map: tool_use_id → turn index + let mut tool_use_turn: HashMap = HashMap::new(); + for (idx, turn) in turns.iter().enumerate() { + for block in &turn.blocks { + if let ContentBlock::ToolUse { + tool_use_id: Some(ref id), + .. + } = block + { + tool_use_turn.insert(id.clone(), idx); + } + } + } + + if tool_use_turn.is_empty() { + return; + } + + // Collect (source_turn, target_turn, block) for orphaned results + let mut relocations: Vec<(usize, usize, ContentBlock)> = Vec::new(); + for (turn_idx, turn) in turns.iter().enumerate() { + for block in &turn.blocks { + if let ContentBlock::ToolResult { + tool_use_id: Some(ref id), + .. + } = block + { + if let Some(&target) = tool_use_turn.get(id) { + if target != turn_idx { + relocations.push((turn_idx, target, block.clone())); + } + } + } + } + } + + if relocations.is_empty() { + return; + } + + // Build set of (turn_idx, tool_use_id) to remove + let remove_set: HashMap> = { + let mut map: HashMap> = HashMap::new(); + for (from, _, block) in &relocations { + if let ContentBlock::ToolResult { + tool_use_id: Some(ref id), + .. + } = block + { + map.entry(*from).or_default().push(id.clone()); + } + } + map + }; + + // Remove from source turns + for (&turn_idx, ids) in &remove_set { + turns[turn_idx].blocks.retain(|block| { + if let ContentBlock::ToolResult { + tool_use_id: Some(ref id), + .. + } = block + { + !ids.contains(id) + } else { + true + } + }); + } + + // Append to target turns + for (_, target, block) in relocations { + turns[target].blocks.push(block); + } + + // Remove turns that became empty after relocation + turns.retain(|turn| !turn.blocks.is_empty()); +} + /// 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 3957860..1fcc8df 100644 --- a/src-tauri/src/parsers/openclaw.rs +++ b/src-tauri/src/parsers/openclaw.rs @@ -650,7 +650,8 @@ impl OpenClawParser { let folder_path = cwd.clone(); let folder_name = folder_path.as_ref().map(|p| folder_name_from_path(p)); - let turns = group_into_turns(messages); + let mut turns = group_into_turns(messages); + super::relocate_orphaned_tool_results(&mut turns); let context_window_used_tokens = latest_turn_total_usage_tokens(&turns); let context_window_max_tokens = session_meta diff --git a/src-tauri/src/parsers/opencode.rs b/src-tauri/src/parsers/opencode.rs index 7a43335..af2f526 100644 --- a/src-tauri/src/parsers/opencode.rs +++ b/src-tauri/src/parsers/opencode.rs @@ -187,7 +187,8 @@ impl OpenCodeParser { .ok_or_else(|| ParseError::ConversationNotFound(conversation_id.to_string()))?; let messages = self.load_sqlite_messages(&conn, conversation_id).await?; - let turns = group_into_turns(messages); + let mut turns = group_into_turns(messages); + super::relocate_orphaned_tool_results(&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/message/content-parts-renderer.tsx b/src/components/message/content-parts-renderer.tsx index 3dd15f2..a8737d4 100644 --- a/src/components/message/content-parts-renderer.tsx +++ b/src/components/message/content-parts-renderer.tsx @@ -2012,36 +2012,84 @@ function stripMarkdownCodeFence(text: string): string { return result } -function stripCliExecutionEnvelope(text: string): string { +/** Regex matching metadata lines in CLI execution output envelopes. */ +const CLI_META_LINE_RE = + /^(exit code\s*[:=]|wall time\s*[:=]|chunk id\s*[:=]|original token count\s*[:=]|total output lines\s*[:=]|process exited with code\s)/i + +/** + * Parse a CLI execution envelope, stripping all metadata and the "Output:" + * separator, returning only the actual command output and the wall time. + * + * Handles formats like: + * Chunk ID: 065b2b + * Wall time: 0.05s + * Process exited with code 0 + * Original token count: 27006 + * Output: + * Total output lines: 1134 + * + */ +function parseCliExecutionEnvelope(text: string): { + output: string + wallTime: string | null +} { const lines = text.split("\n") + let wallTime: string | null = null + + // Look for "Output:" separator and extract wall time from header + let outputSepIndex = -1 + for (let i = 0; i < lines.length; i++) { + const trimmed = lines[i].trim() + const wtMatch = trimmed.match(/^wall time\s*:\s*(.+)/i) + if (wtMatch) wallTime = wtMatch[1].trim() + if (/^output:\s*$/i.test(trimmed)) { + outputSepIndex = i + break + } + // Stop scanning if we hit a non-metadata, non-blank line (actual content) + if (!CLI_META_LINE_RE.test(trimmed) && trimmed.length > 0) break + } + + // If "Output:" separator found, skip everything before it plus any + // remaining metadata/blank lines after it + if (outputSepIndex >= 0) { + let start = outputSepIndex + 1 + while (start < lines.length) { + const trimmed = lines[start].trim() + if (CLI_META_LINE_RE.test(trimmed) || trimmed.length === 0) { + start++ + continue + } + break + } + return { output: lines.slice(start).join("\n"), wallTime } + } + + // No "Output:" separator — strip leading metadata lines let index = 0 let sawMeta = false - while (index < lines.length) { const trimmed = lines[index].trim() - if (/^exit code:\s*/i.test(trimmed) || /^wall time:\s*/i.test(trimmed)) { + if (CLI_META_LINE_RE.test(trimmed)) { sawMeta = true - index += 1 + if (!wallTime) { + const wtMatch = trimmed.match(/^wall time\s*:\s*(.+)/i) + if (wtMatch) wallTime = wtMatch[1].trim() + } + index++ continue } if (sawMeta && trimmed.length === 0) { - index += 1 + index++ continue } break } - if (!sawMeta) return text + if (!sawMeta) return { output: text, wallTime: null } - if (index < lines.length && /^output:\s*$/i.test(lines[index].trim())) { - index += 1 - } - - while (index < lines.length && lines[index].trim().length === 0) { - index += 1 - } - - return lines.slice(index).join("\n") + while (index < lines.length && lines[index].trim().length === 0) index++ + return { output: lines.slice(index).join("\n"), wallTime } } // ── Part components ─────────────────────────────────────────────────── @@ -2123,26 +2171,51 @@ const ToolCallPart = memo(function ToolCallPart({ } return null }, [toolNameLower, part.input, part.output, part.errorText]) + const wallTime = useMemo(() => { + const source = part.output ?? part.errorText + if (!source) return null + const normalized = commandOutputFromJsonString(source) ?? source + const match = normalized.match(/^wall time\s*:\s*(.+)/im) + if (!match) return null + const raw = match[1].trim() + // Parse "0.0519 seconds" → "52ms", "1.234 seconds" → "1.2s" + const numMatch = raw.match(/^([\d.]+)\s*s/) + if (!numMatch) return raw + const sec = parseFloat(numMatch[1]) + if (Number.isNaN(sec)) return raw + if (sec < 0.001) return "<1ms" + if (sec < 1) return `${Math.round(sec * 1000)}ms` + if (sec < 60) return `${sec.toFixed(1)}s` + return `${(sec / 60).toFixed(1)}m` + }, [part.output, part.errorText]) const titleSuffix = useMemo(() => { - if (!lineChangeStats) return null + const hasStats = + lineChangeStats && + (lineChangeStats.additions > 0 || lineChangeStats.deletions > 0) + if (!hasStats && !wallTime) return null return ( - {lineChangeStats.additions > 0 && ( + {hasStats && lineChangeStats.additions > 0 && ( {lineChangeStats.additions} )} - {lineChangeStats.deletions > 0 && ( + {hasStats && lineChangeStats.deletions > 0 && ( {lineChangeStats.deletions} )} + {wallTime && ( + + {wallTime} + + )} ) - }, [lineChangeStats]) + }, [lineChangeStats, wallTime]) const icon = useMemo( () => getToolIcon(normalizedToolName, part.input), @@ -2157,9 +2230,7 @@ const ToolCallPart = memo(function ToolCallPart({ ) }, [isCommandTool, part.input, part.output, part.errorText]) const commandOutput = useMemo(() => { - if (!isCommandLikeTool) { - return null - } + if (!isCommandLikeTool) return null const source = typeof part.output === "string" ? part.output @@ -2168,7 +2239,8 @@ const ToolCallPart = memo(function ToolCallPart({ : null if (!source) return null const normalized = commandOutputFromJsonString(source) ?? source - return stripMarkdownCodeFence(stripCliExecutionEnvelope(normalized)) + const envelope = parseCliExecutionEnvelope(normalized) + return stripMarkdownCodeFence(envelope.output) }, [isCommandLikeTool, part.output, part.errorText]) const hasLiveOutput = isRunning && isCommandTool && typeof commandOutput === "string" diff --git a/src/components/message/message-list-view.tsx b/src/components/message/message-list-view.tsx index 1fd5295..727b85d 100644 --- a/src/components/message/message-list-view.tsx +++ b/src/components/message/message-list-view.tsx @@ -231,10 +231,7 @@ export function MessageListView({ // isRoleTransition: role differs from previous turn item if (idx > 0) { const prev = items[idx - 1] - if ( - prev.kind === "turn" && - prev.group.role !== item.group.role - ) { + if (prev.kind === "turn" && prev.group.role !== item.group.role) { item.isRoleTransition = true } } @@ -242,11 +239,7 @@ export function MessageListView({ // showStats: only on the last assistant turn before a non-assistant or end if (item.group.role === "assistant") { const next = items[idx + 1] - if ( - !next || - next.kind !== "turn" || - next.group.role !== "assistant" - ) { + if (!next || next.kind !== "turn" || next.group.role !== "assistant") { item.showStats = true } } @@ -272,29 +265,26 @@ export function MessageListView({ [historicalPlanEntries] ) - const renderThreadItem = useCallback( - (item: ThreadRenderItem) => { - switch (item.kind) { - case "turn": { - const pt = item.isRoleTransition ? 16 : 0 - return ( -
0 ? { paddingTop: pt } : undefined}> - -
- ) - } - case "typing": - return - default: - return null + const renderThreadItem = useCallback((item: ThreadRenderItem) => { + switch (item.kind) { + case "turn": { + const pt = item.isRoleTransition ? 16 : 0 + return ( +
0 ? { paddingTop: pt } : undefined}> + +
+ ) } - }, - [] - ) + case "typing": + return + default: + return null + } + }, []) const emptyState = useMemo( () =>