Skip to content

Commit 3f16475

Browse files
MarkShawn2020claude
andcommitted
feat(chat): display structured content blocks for tool calls and thinking
Parse Claude Code JSONL tool_use, tool_result, and thinking blocks into structured ContentBlock types. Render tool calls as colored badges, tool results as collapsible bordered blocks, and thinking as collapsible italic sections. Original chat view still shows flat text only. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 599c36a commit 3f16475

5 files changed

Lines changed: 231 additions & 2 deletions

File tree

src-tauri/src/lib.rs

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -186,6 +186,19 @@ pub struct Session {
186186
pub usage: Option<SessionUsage>,
187187
}
188188

189+
#[derive(Debug, Serialize, Deserialize)]
190+
#[serde(tag = "type")]
191+
pub enum ContentBlock {
192+
#[serde(rename = "text")]
193+
Text { text: String },
194+
#[serde(rename = "tool_use")]
195+
ToolUse { id: String, name: String, summary: String },
196+
#[serde(rename = "tool_result")]
197+
ToolResult { tool_use_id: String, content: String },
198+
#[serde(rename = "thinking")]
199+
Thinking { thinking: String },
200+
}
201+
189202
#[derive(Debug, Serialize, Deserialize)]
190203
pub struct Message {
191204
pub uuid: String,
@@ -195,6 +208,7 @@ pub struct Message {
195208
pub is_meta: bool, // slash command 展开的内容
196209
pub is_tool: bool, // tool_use 或 tool_result
197210
pub line_number: usize,
211+
pub content_blocks: Option<Vec<ContentBlock>>,
198212
}
199213

200214
#[derive(Debug, Serialize, Deserialize)]
@@ -1053,6 +1067,7 @@ async fn get_session_messages(
10531067
if let Some(msg) = &parsed.message {
10541068
let role = msg.role.clone().unwrap_or_default();
10551069
let (content, is_tool) = extract_content_with_meta(&msg.content);
1070+
let content_blocks = extract_content_blocks(&msg.content);
10561071
let is_meta = parsed.is_meta.unwrap_or(false);
10571072

10581073
if !content.is_empty() {
@@ -1064,6 +1079,7 @@ async fn get_session_messages(
10641079
is_meta,
10651080
is_tool,
10661081
line_number: idx + 1,
1082+
content_blocks,
10671083
});
10681084
}
10691085
}
@@ -1391,6 +1407,103 @@ fn search_chats(
13911407
Ok(results)
13921408
}
13931409

1410+
fn summarize_tool_input(name: &str, input: &serde_json::Value) -> String {
1411+
let obj = match input.as_object() {
1412+
Some(o) => o,
1413+
None => return String::new(),
1414+
};
1415+
match name {
1416+
"Read" | "Write" => obj.get("file_path").and_then(|v| v.as_str()).unwrap_or("").to_string(),
1417+
"Edit" => {
1418+
let path = obj.get("file_path").and_then(|v| v.as_str()).unwrap_or("");
1419+
let old = obj.get("old_string").and_then(|v| v.as_str()).unwrap_or("");
1420+
if old.is_empty() { path.to_string() } else {
1421+
format!("{} ({}...)", path, &old.chars().take(40).collect::<String>())
1422+
}
1423+
}
1424+
"Bash" => obj.get("command").and_then(|v| v.as_str()).unwrap_or("").to_string(),
1425+
"Grep" => obj.get("pattern").and_then(|v| v.as_str()).unwrap_or("").to_string(),
1426+
"Glob" => obj.get("pattern").and_then(|v| v.as_str()).unwrap_or("").to_string(),
1427+
"Task" => obj.get("description").and_then(|v| v.as_str()).unwrap_or("").to_string(),
1428+
"WebFetch" => obj.get("url").and_then(|v| v.as_str()).unwrap_or("").to_string(),
1429+
"WebSearch" => obj.get("query").and_then(|v| v.as_str()).unwrap_or("").to_string(),
1430+
_ => {
1431+
// Try common field names
1432+
for key in &["file_path", "path", "command", "query", "pattern", "url", "description"] {
1433+
if let Some(v) = obj.get(*key).and_then(|v| v.as_str()) {
1434+
return v.to_string();
1435+
}
1436+
}
1437+
String::new()
1438+
}
1439+
}
1440+
}
1441+
1442+
fn extract_tool_result_content(value: &serde_json::Value) -> String {
1443+
match value {
1444+
serde_json::Value::String(s) => s.clone(),
1445+
serde_json::Value::Array(arr) => {
1446+
arr.iter()
1447+
.filter_map(|item| {
1448+
let obj = item.as_object()?;
1449+
if obj.get("type").and_then(|v| v.as_str()) == Some("text") {
1450+
obj.get("text").and_then(|v| v.as_str()).map(String::from)
1451+
} else {
1452+
None
1453+
}
1454+
})
1455+
.collect::<Vec<_>>()
1456+
.join("\n")
1457+
}
1458+
_ => String::new(),
1459+
}
1460+
}
1461+
1462+
fn extract_content_blocks(value: &Option<serde_json::Value>) -> Option<Vec<ContentBlock>> {
1463+
let arr = match value {
1464+
Some(serde_json::Value::Array(arr)) => arr,
1465+
Some(serde_json::Value::String(s)) => {
1466+
return Some(vec![ContentBlock::Text { text: s.clone() }]);
1467+
}
1468+
_ => return None,
1469+
};
1470+
1471+
let blocks: Vec<ContentBlock> = arr
1472+
.iter()
1473+
.filter_map(|item| {
1474+
let obj = item.as_object()?;
1475+
let block_type = obj.get("type").and_then(|v| v.as_str())?;
1476+
match block_type {
1477+
"text" => {
1478+
let text = obj.get("text").and_then(|v| v.as_str())?.to_string();
1479+
if text.is_empty() { None } else { Some(ContentBlock::Text { text }) }
1480+
}
1481+
"tool_use" => {
1482+
let id = obj.get("id").and_then(|v| v.as_str()).unwrap_or("").to_string();
1483+
let name = obj.get("name").and_then(|v| v.as_str()).unwrap_or("").to_string();
1484+
let input = obj.get("input").cloned().unwrap_or(serde_json::Value::Null);
1485+
let summary = summarize_tool_input(&name, &input);
1486+
Some(ContentBlock::ToolUse { id, name, summary })
1487+
}
1488+
"tool_result" => {
1489+
let tool_use_id = obj.get("tool_use_id").and_then(|v| v.as_str()).unwrap_or("").to_string();
1490+
let content = obj.get("content")
1491+
.map(|v| extract_tool_result_content(v))
1492+
.unwrap_or_default();
1493+
Some(ContentBlock::ToolResult { tool_use_id, content })
1494+
}
1495+
"thinking" => {
1496+
let thinking = obj.get("thinking").and_then(|v| v.as_str()).unwrap_or("").to_string();
1497+
if thinking.is_empty() { None } else { Some(ContentBlock::Thinking { thinking }) }
1498+
}
1499+
_ => None,
1500+
}
1501+
})
1502+
.collect();
1503+
1504+
if blocks.is_empty() { None } else { Some(blocks) }
1505+
}
1506+
13941507
fn extract_content_with_meta(value: &Option<serde_json::Value>) -> (String, bool) {
13951508
match value {
13961509
Some(serde_json::Value::String(s)) => (s.clone(), false),

src/types/index.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,12 @@ export interface SessionUsageEntry {
6767
usage: SessionUsage;
6868
}
6969

70+
export type ContentBlock =
71+
| { type: "text"; text: string }
72+
| { type: "tool_use"; id: string; name: string; summary: string }
73+
| { type: "tool_result"; tool_use_id: string; content: string }
74+
| { type: "thinking"; thinking: string };
75+
7076
export interface Message {
7177
uuid: string;
7278
role: string;
@@ -75,6 +81,7 @@ export interface Message {
7581
is_meta: boolean;
7682
is_tool: boolean;
7783
line_number: number;
84+
content_blocks?: ContentBlock[];
7885
}
7986

8087
export interface ChatMessage {
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
import { useState } from "react";
2+
import { CollapsibleContent } from "./CollapsibleContent";
3+
import type { ContentBlock } from "../../types";
4+
5+
interface ContentBlockRendererProps {
6+
blocks: ContentBlock[];
7+
markdown: boolean;
8+
}
9+
10+
const TOOL_COLORS: Record<string, string> = {
11+
Read: "bg-blue-100 text-blue-800",
12+
Write: "bg-green-100 text-green-800",
13+
Edit: "bg-yellow-100 text-yellow-800",
14+
Bash: "bg-orange-100 text-orange-800",
15+
Grep: "bg-purple-100 text-purple-800",
16+
Glob: "bg-purple-100 text-purple-800",
17+
Task: "bg-indigo-100 text-indigo-800",
18+
WebFetch: "bg-cyan-100 text-cyan-800",
19+
WebSearch: "bg-cyan-100 text-cyan-800",
20+
};
21+
22+
function ToolUseBadge({ name, summary }: { name: string; summary: string }) {
23+
const color = TOOL_COLORS[name] || "bg-muted text-muted-foreground";
24+
return (
25+
<div className="flex items-center gap-2 py-1">
26+
<span className={`inline-flex items-center px-2 py-0.5 rounded text-xs font-medium ${color}`}>
27+
{name}
28+
</span>
29+
{summary && (
30+
<span className="text-xs font-mono text-muted-foreground truncate max-w-[500px]">
31+
{summary}
32+
</span>
33+
)}
34+
</div>
35+
);
36+
}
37+
38+
function ToolResultBlock({ content }: { content: string }) {
39+
const [expanded, setExpanded] = useState(false);
40+
const isLong = content.length > 200;
41+
const display = !expanded && isLong ? content.slice(0, 200) + "..." : content;
42+
43+
return (
44+
<div className="border-l-2 border-border pl-3 py-1 my-1">
45+
<pre className="text-xs font-mono text-muted-foreground whitespace-pre-wrap break-words">
46+
{display}
47+
</pre>
48+
{isLong && (
49+
<button
50+
onClick={() => setExpanded(!expanded)}
51+
className="text-xs text-primary hover:text-primary/80 mt-0.5"
52+
>
53+
{expanded ? "Collapse" : "Expand"}
54+
</button>
55+
)}
56+
</div>
57+
);
58+
}
59+
60+
function ThinkingBlock({ thinking }: { thinking: string }) {
61+
const [expanded, setExpanded] = useState(false);
62+
63+
return (
64+
<div className="my-1">
65+
<button
66+
onClick={() => setExpanded(!expanded)}
67+
className="text-xs text-muted-foreground hover:text-foreground italic"
68+
>
69+
{expanded ? "Hide thinking..." : "Thinking..."}
70+
</button>
71+
{expanded && (
72+
<div className="border-l-2 border-border/50 pl-3 mt-1">
73+
<p className="text-xs text-muted-foreground italic whitespace-pre-wrap break-words">
74+
{thinking}
75+
</p>
76+
</div>
77+
)}
78+
</div>
79+
);
80+
}
81+
82+
export function ContentBlockRenderer({ blocks, markdown }: ContentBlockRendererProps) {
83+
return (
84+
<div className="space-y-1">
85+
{blocks.map((block, i) => {
86+
switch (block.type) {
87+
case "text":
88+
return <CollapsibleContent key={i} content={block.text} markdown={markdown} />;
89+
case "tool_use":
90+
return <ToolUseBadge key={i} name={block.name} summary={block.summary} />;
91+
case "tool_result":
92+
return <ToolResultBlock key={i} content={block.content} />;
93+
case "thinking":
94+
return <ThinkingBlock key={i} thinking={block.thinking} />;
95+
}
96+
})}
97+
</div>
98+
);
99+
}

src/views/Chat/MessageView.tsx

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import {
2020
import { useAtom } from "jotai";
2121
import { originalChatAtom, markdownPreviewAtom } from "../../store";
2222
import { CollapsibleContent } from "./CollapsibleContent";
23+
import { ContentBlockRenderer } from "./ContentBlockRenderer";
2324
import { ExportDialog } from "./ExportDialog";
2425
import { useReadableText } from "./utils";
2526
import { useAppConfig } from "../../context";
@@ -170,7 +171,11 @@ export function MessageView({ projectId, projectPath, sessionId, summary: initia
170171
</DropdownMenuContent>
171172
</DropdownMenu>
172173
<p className="text-xs text-muted-foreground-foreground mb-2 uppercase tracking-wide">{msg.role}</p>
173-
<CollapsibleContent content={displayContent} markdown={markdownPreview} />
174+
{msg.content_blocks && !originalChat ? (
175+
<ContentBlockRenderer blocks={msg.content_blocks} markdown={markdownPreview} />
176+
) : (
177+
<CollapsibleContent content={displayContent} markdown={markdownPreview} />
178+
)}
174179
</div>
175180
);
176181
})}

src/views/Chat/ProjectList.tsx

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import { useAppConfig } from "../../context";
1313
import { useReadableText } from "./utils";
1414
import { useInvokeQuery } from "../../hooks";
1515
import { CollapsibleContent } from "./CollapsibleContent";
16+
import { ContentBlockRenderer } from "./ContentBlockRenderer";
1617
import { ProjectLogo } from "../Workspace/ProjectLogo";
1718
import {
1819
DropdownMenu,
@@ -474,7 +475,11 @@ function SessionDetail({ session, onOpen }: { session: Session; onOpen: () => vo
474475
<Copy size={14} />
475476
</button>
476477
<p className="text-xs text-muted-foreground mb-2 uppercase tracking-wide">{msg.role}</p>
477-
<CollapsibleContent content={displayContent} markdown={markdownPreview} />
478+
{msg.content_blocks && !originalChat ? (
479+
<ContentBlockRenderer blocks={msg.content_blocks} markdown={markdownPreview} />
480+
) : (
481+
<CollapsibleContent content={displayContent} markdown={markdownPreview} />
482+
)}
478483
</div>
479484
);
480485
})}

0 commit comments

Comments
 (0)