Files
codeg/src-tauri/src/parsers/cline.rs
xintaofei 9f82fdf350 feat(ui): add dedicated Agent subagent rendering with nested tool call display
Render Agent/Explore/Plan tool calls in a visually distinct collapsible
container with colored left border, replacing the generic tool card. Parse
subagent JSONL transcripts from {sessionId}/subagents/ to extract and
display the actual tool calls (Bash, Read, Grep, etc.) the subagent
executed, reusing the existing ToolCallPart for consistent appearance.

- Add AgentToolCallPart component with collapsible body, prompt section,
  execution stats, and nested tool call list via render prop injection
- Add AgentExecutionStats and AgentToolCall types (Rust + TypeScript)
- Parse toolUseResult.agentId to locate and read subagent JSONL files
- Validate agentId against path traversal before filesystem access
- Pass agentStats through adapter for both ID-matched and positional
  tool result pairing
- Strip agentStats in nested render to prevent recursive Agent expansion
- Add i18n keys for agent UI labels across all 10 languages
2026-04-16 21:32:25 +08:00

637 lines
22 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
use std::fs;
use std::path::PathBuf;
use chrono::{DateTime, TimeZone, Utc};
use serde::Deserialize;
use crate::models::{
AgentType, ContentBlock, ConversationDetail, ConversationSummary, MessageTurn, TurnRole,
TurnUsage,
};
use super::{compute_session_stats, folder_name_from_path, truncate_str, AgentParser, ParseError};
// ---------------------------------------------------------------------------
// On-disk JSON structures
// ---------------------------------------------------------------------------
/// One entry in `~/.cline/data/state/taskHistory.json`.
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct TaskHistoryEntry {
id: String,
ts: i64,
task: Option<String>,
#[allow(dead_code)]
tokens_in: Option<u64>,
#[allow(dead_code)]
tokens_out: Option<u64>,
#[allow(dead_code)]
total_cost: Option<f64>,
cwd_on_task_initialization: Option<String>,
#[serde(default)]
model_id: Option<String>,
}
/// `task_metadata.json` we only need `model_usage`.
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct TaskMetadata {
#[serde(default)]
model_usage: Vec<ModelUsageEntry>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct ModelUsageEntry {
model_id: Option<String>,
#[allow(dead_code)]
model_provider_id: Option<String>,
}
/// One message in `api_conversation_history.json`.
#[derive(Debug, Deserialize)]
struct ApiMessage {
role: String,
#[serde(default)]
content: serde_json::Value,
ts: Option<i64>,
#[serde(default, rename = "modelInfo")]
model_info: Option<ApiModelInfo>,
#[serde(default)]
metrics: Option<ApiMetrics>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct ApiModelInfo {
model_id: Option<String>,
}
#[derive(Debug, Deserialize)]
struct ApiMetrics {
tokens: Option<ApiTokenMetrics>,
}
#[derive(Debug, Deserialize)]
struct ApiTokenMetrics {
#[serde(default)]
prompt: Option<u64>,
#[serde(default)]
completion: Option<u64>,
#[serde(default)]
cached: Option<u64>,
}
// ---------------------------------------------------------------------------
// Parser
// ---------------------------------------------------------------------------
fn cline_data_dir() -> PathBuf {
if let Ok(custom) = std::env::var("CLINE_DIR") {
let trimmed = custom.trim();
if !trimmed.is_empty() {
return PathBuf::from(trimmed);
}
}
dirs::home_dir()
.unwrap_or_else(|| PathBuf::from("."))
.join(".cline")
.join("data")
}
fn ts_to_datetime(ts: i64) -> DateTime<Utc> {
Utc.timestamp_millis_opt(ts).single().unwrap_or_default()
}
pub struct ClineParser;
impl ClineParser {
pub fn new() -> Self {
Self
}
}
impl AgentParser for ClineParser {
fn list_conversations(&self) -> Result<Vec<ConversationSummary>, ParseError> {
let history_path = cline_data_dir().join("state").join("taskHistory.json");
if !history_path.exists() {
return Ok(vec![]);
}
let raw = fs::read_to_string(&history_path)?;
let entries: Vec<TaskHistoryEntry> = serde_json::from_str(&raw)?;
let mut summaries = Vec::new();
for entry in entries {
let tasks_dir = cline_data_dir().join("tasks").join(&entry.id);
if !tasks_dir.exists() {
continue;
}
// Read model from task_metadata.json or taskHistory entry
let model = entry.model_id.clone().or_else(|| {
let meta_path = tasks_dir.join("task_metadata.json");
fs::read_to_string(meta_path)
.ok()
.and_then(|raw| serde_json::from_str::<TaskMetadata>(&raw).ok())
.and_then(|meta| {
meta.model_usage
.first()
.and_then(|u| u.model_id.clone())
})
});
let folder_path = entry.cwd_on_task_initialization.clone();
let folder_name = folder_path.as_deref().map(folder_name_from_path);
let title = entry
.task
.as_deref()
.map(|t| truncate_str(t.trim(), 100));
// Count messages from api_conversation_history.json
let api_path = tasks_dir.join("api_conversation_history.json");
let message_count = fs::read_to_string(&api_path)
.ok()
.and_then(|raw| serde_json::from_str::<Vec<serde_json::Value>>(&raw).ok())
.map(|msgs| msgs.len() as u32)
.unwrap_or(0);
// started_at from task id (which is a timestamp), ended_at from ts
let started_at = ts_to_datetime(entry.id.parse::<i64>().unwrap_or(entry.ts));
let ended_at = if entry.ts > 0 {
Some(ts_to_datetime(entry.ts))
} else {
None
};
summaries.push(ConversationSummary {
id: entry.id,
agent_type: AgentType::Cline,
folder_path,
folder_name,
title,
started_at,
ended_at,
message_count,
model,
git_branch: None,
});
}
Ok(summaries)
}
fn get_conversation(&self, conversation_id: &str) -> Result<ConversationDetail, ParseError> {
let tasks_dir = cline_data_dir().join("tasks").join(conversation_id);
if !tasks_dir.exists() {
return Err(ParseError::ConversationNotFound(
conversation_id.to_string(),
));
}
let api_path = tasks_dir.join("api_conversation_history.json");
if !api_path.exists() {
return Err(ParseError::ConversationNotFound(
conversation_id.to_string(),
));
}
let raw = fs::read_to_string(&api_path)?;
let messages: Vec<ApiMessage> = serde_json::from_str(&raw)?;
// Read metadata for model/cwd
let meta_path = tasks_dir.join("task_metadata.json");
let metadata = fs::read_to_string(&meta_path)
.ok()
.and_then(|raw| serde_json::from_str::<TaskMetadata>(&raw).ok());
let default_model = metadata
.as_ref()
.and_then(|m| m.model_usage.first())
.and_then(|u| u.model_id.clone());
// Read taskHistory for cwd and title
let history_path = cline_data_dir().join("state").join("taskHistory.json");
let history_entry = fs::read_to_string(&history_path)
.ok()
.and_then(|raw| serde_json::from_str::<Vec<TaskHistoryEntry>>(&raw).ok())
.and_then(|entries| entries.into_iter().find(|e| e.id == conversation_id));
let folder_path = history_entry
.as_ref()
.and_then(|e| e.cwd_on_task_initialization.clone());
let folder_name = folder_path.as_deref().map(folder_name_from_path);
let title = history_entry
.as_ref()
.and_then(|e| e.task.as_deref())
.map(|t| truncate_str(t.trim(), 100));
let mut turns: Vec<MessageTurn> = Vec::new();
let mut turn_counter = 0u32;
for msg in &messages {
let ts = msg.ts.unwrap_or(0);
let timestamp = if ts > 0 {
ts_to_datetime(ts)
} else {
Utc::now()
};
let model = msg
.model_info
.as_ref()
.and_then(|info| info.model_id.clone())
.or_else(|| default_model.clone());
let usage = msg.metrics.as_ref().and_then(|m| {
m.tokens.as_ref().map(|t| TurnUsage {
input_tokens: t.prompt.unwrap_or(0),
output_tokens: t.completion.unwrap_or(0),
cache_creation_input_tokens: 0,
cache_read_input_tokens: t.cached.unwrap_or(0),
})
});
match msg.role.as_str() {
"assistant" => {
let blocks = parse_content_blocks(&msg.content);
if blocks.is_empty() {
continue;
}
turn_counter += 1;
turns.push(MessageTurn {
id: format!("{}-{}", conversation_id, turn_counter),
role: TurnRole::Assistant,
blocks,
timestamp,
usage,
duration_ms: None,
model,
});
}
"user" => {
// Cline packs tool results, user feedback, and automated
// messages into role:"user". Split them into proper turns.
let parsed = parse_user_message_parts(&msg.content);
// Emit tool-result blocks as a system turn so they attach
// to the preceding assistant tool_use.
if !parsed.tool_results.is_empty() {
turn_counter += 1;
turns.push(MessageTurn {
id: format!("{}-{}", conversation_id, turn_counter),
role: TurnRole::System,
blocks: parsed.tool_results,
timestamp,
usage: None,
duration_ms: None,
model: None,
});
}
// Emit real user text (feedback / initial task) as a user turn.
if !parsed.user_blocks.is_empty() {
turn_counter += 1;
turns.push(MessageTurn {
id: format!("{}-{}", conversation_id, turn_counter),
role: TurnRole::User,
blocks: parsed.user_blocks,
timestamp,
usage: None,
duration_ms: None,
model: None,
});
}
}
_ => continue,
}
}
let started_at = turns
.first()
.map(|t| t.timestamp)
.unwrap_or_else(Utc::now);
let ended_at = turns.last().map(|t| t.timestamp);
let session_stats = compute_session_stats(&turns);
let summary = ConversationSummary {
id: conversation_id.to_string(),
agent_type: AgentType::Cline,
folder_path,
folder_name,
title,
started_at,
ended_at,
message_count: turns.len() as u32,
model: default_model,
git_branch: None,
};
Ok(ConversationDetail {
summary,
turns,
session_stats,
})
}
}
// ---------------------------------------------------------------------------
// Content block parsing
// ---------------------------------------------------------------------------
/// Result of splitting a Cline `role:"user"` message.
struct UserMessageParts {
/// Tool result blocks (e.g. `[read_file for ...] Result:`)
tool_results: Vec<ContentBlock>,
/// Real user content (initial task text or `<feedback>` text)
user_blocks: Vec<ContentBlock>,
}
/// Cline puts tool results, feedback, and automated prompts all into
/// `role:"user"` messages. This function splits them apart.
fn parse_user_message_parts(content: &serde_json::Value) -> UserMessageParts {
let texts = collect_text_parts(content);
let mut tool_results = Vec::new();
let mut user_blocks = Vec::new();
for text in texts {
let cleaned = strip_environment_details(&text);
if cleaned.is_empty() {
continue;
}
// Tool result pattern: `[tool_name ...] Result:`
if is_tool_result_text(&cleaned) {
let (tool_name, output, is_error) = parse_tool_result_text(&cleaned);
tool_results.push(ContentBlock::ToolResult {
tool_use_id: None,
output_preview: Some(truncate_str(&output, 2000)),
is_error,
agent_stats: None,
});
// If the tool result also contains <feedback>, extract it
if let Some(feedback) = extract_feedback(&text) {
let fb = feedback.trim();
if !fb.is_empty() {
user_blocks.push(ContentBlock::Text {
text: fb.to_string(),
});
}
}
// After extracting tool result, also check for non-feedback user
// text following the result (e.g. "The user has provided feedback...")
// — we intentionally skip these automated bridging messages.
let _ = tool_name;
continue;
}
// Pure feedback without tool result prefix
if let Some(feedback) = extract_feedback(&cleaned) {
let fb = feedback.trim();
if !fb.is_empty() {
user_blocks.push(ContentBlock::Text {
text: fb.to_string(),
});
}
continue;
}
// Regular user text (initial task, etc.)
user_blocks.push(ContentBlock::Text { text: cleaned });
}
UserMessageParts {
tool_results,
user_blocks,
}
}
/// Collect all text strings from a content value (string or array of text blocks).
fn collect_text_parts(content: &serde_json::Value) -> Vec<String> {
match content {
serde_json::Value::String(s) => vec![s.clone()],
serde_json::Value::Array(arr) => arr
.iter()
.filter_map(|item| {
let t = item.get("type").and_then(|v| v.as_str()).unwrap_or("");
if t == "text" || t.is_empty() {
item.get("text")
.and_then(|v| v.as_str())
.map(String::from)
.or_else(|| {
if t.is_empty() {
item.as_str().map(String::from)
} else {
None
}
})
} else {
None
}
})
.collect(),
_ => vec![],
}
}
/// Check if text looks like a Cline tool result: `[tool_name ...] Result:`
fn is_tool_result_text(text: &str) -> bool {
let trimmed = text.trim_start();
trimmed.starts_with('[')
&& trimmed.contains("] Result:")
}
/// Parse `[tool_name for 'arg'] Result:\ncontent` into (tool_name, output, is_error).
fn parse_tool_result_text(text: &str) -> (String, String, bool) {
let trimmed = text.trim_start();
// Extract tool name from [tool_name ...] or [tool_name] prefix
let tool_name = trimmed
.strip_prefix('[')
.and_then(|s| {
s.find([']', ' '])
.map(|i| s[..i].to_string())
})
.unwrap_or_default();
let is_error = trimmed.contains("[ERROR]") || trimmed.contains("Error:");
// Extract the content after "Result:\n"
let output = trimmed
.find("] Result:")
.map(|i| {
let after = &trimmed[i + "] Result:".len()..];
after.trim().to_string()
})
.unwrap_or_default();
// Strip automated bridging text that follows some results
let output = strip_automated_bridging(&output);
(tool_name, output, is_error)
}
/// Remove automated bridging messages that Cline appends after tool results.
fn strip_automated_bridging(text: &str) -> String {
let mut result = text.to_string();
// Remove "The user has provided feedback..." bridging
if let Some(pos) = result.find("The user has provided feedback") {
result = result[..pos].to_string();
}
// Remove "(This is an automated message...)" blocks
if let Some(pos) = result.find("(This is an automated message") {
result = result[..pos].to_string();
}
// Remove "# Next Steps" blocks
if let Some(pos) = result.find("# Next Steps") {
result = result[..pos].to_string();
}
result.trim().to_string()
}
/// Extract text from `<feedback>...</feedback>` tags.
fn extract_feedback(text: &str) -> Option<String> {
let start = text.find("<feedback>")?;
let inner_start = start + "<feedback>".len();
let end = text.find("</feedback>")?;
if end > inner_start {
Some(text[inner_start..end].to_string())
} else {
None
}
}
fn parse_content_blocks(content: &serde_json::Value) -> Vec<ContentBlock> {
match content {
serde_json::Value::String(text) => {
let cleaned = strip_environment_details(text);
if cleaned.is_empty() {
vec![]
} else {
vec![ContentBlock::Text { text: cleaned }]
}
}
serde_json::Value::Array(arr) => {
let mut blocks = Vec::new();
for item in arr {
let block_type = item.get("type").and_then(|v| v.as_str()).unwrap_or("");
match block_type {
"text" => {
if let Some(text) = item.get("text").and_then(|v| v.as_str()) {
let cleaned = strip_environment_details(text);
if !cleaned.is_empty() {
blocks.push(ContentBlock::Text { text: cleaned });
}
}
}
"tool_use" => {
let tool_name = item
.get("name")
.and_then(|v| v.as_str())
.unwrap_or("unknown")
.to_string();
let tool_use_id =
item.get("id").and_then(|v| v.as_str()).map(String::from);
let input_preview = item.get("input").map(|v| {
let s = v.to_string();
truncate_str(&s, 2000)
});
blocks.push(ContentBlock::ToolUse {
tool_use_id,
tool_name,
input_preview,
});
}
"tool_result" => {
let tool_use_id = item
.get("tool_use_id")
.and_then(|v| v.as_str())
.map(String::from);
let is_error = item
.get("is_error")
.and_then(|v| v.as_bool())
.unwrap_or(false);
let output_preview = item
.get("content")
.and_then(|v| v.as_str())
.map(|s| truncate_str(s, 500));
blocks.push(ContentBlock::ToolResult {
tool_use_id,
output_preview,
is_error,
agent_stats: None,
});
}
"thinking" => {
if let Some(text) = item.get("thinking").and_then(|v| v.as_str()) {
if !text.is_empty() {
blocks.push(ContentBlock::Thinking {
text: text.to_string(),
});
}
}
}
_ => {}
}
}
blocks
}
_ => vec![],
}
}
/// Strip Cline's `<environment_details>...</environment_details>` blocks and
/// `<task>...</task>` wrappers from user messages to keep content clean.
fn strip_environment_details(text: &str) -> String {
let mut result = text.to_string();
// Remove <environment_details>...</environment_details>
while let Some(start) = result.find("<environment_details>") {
if let Some(end) = result.find("</environment_details>") {
let end = end + "</environment_details>".len();
result = format!("{}{}", &result[..start], &result[end..]);
} else {
// Unclosed tag — remove from start to end
result = result[..start].to_string();
}
}
// Remove <task>...</task> wrappers, keeping inner content
while let Some(start) = result.find("<task>") {
let tag_end = start + "<task>".len();
if let Some(close) = result.find("</task>") {
let inner = result[tag_end..close].to_string();
let after = &result[close + "</task>".len()..];
result = format!("{}{}{}", &result[..start], inner, after);
} else {
break;
}
}
// Remove task_progress RECOMMENDED blocks
while let Some(start) = result.find("# task_progress RECOMMENDED") {
// Find the end: next section or end of string
let rest = &result[start..];
let end = rest
.find("\n<")
.or_else(|| rest.find("\n#"))
.map(|i| start + i)
.unwrap_or(result.len());
result = format!("{}{}", &result[..start], &result[end..]);
}
// Remove [ERROR] automated retry messages
if result.contains("[ERROR] You did not use a tool") {
return String::new();
}
result.trim().to_string()
}