Skip to content

Commit dc9c938

Browse files
author
王璨
committed
feat(ui): unify tool result formatting, add content-aware
rendering & session delete reset - Add formatToolResultForUI() as single choke-point for Live + History paths - Content-aware formatting: JSON pretty-print, code fences for bash/grep/glob/read_file - ToolCard renders results via Markdown component with syntax-highlighted code blocks - Delete current session from sidebar resets ChatView to welcome page - Session list broadcasts on session:deleted event to all connected clients
1 parent a76fc1c commit dc9c938

22 files changed

Lines changed: 725 additions & 1 deletion

File tree

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
schema: spec-driven
2+
created: 2026-06-18
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
## Context
2+
3+
`formatToolResultForUI``src/ui/shared/tool-result-formatter.ts`)是 Live 和 History 两条路径共享的扼流点,当前只做截断(`write_file` 摘要提取 + 默认 600 字符)。前端 `ToolCard``result` 字符串以纯文本渲染(`font-mono whitespace-pre-wrap`),不做任何格式化。
4+
5+
同时,`MessageBubble` 中的 assistant 正文已通过 `<Markdown>`(基于 `react-markdown` + `remarkGfm`)完整渲染,支持代码块、表格、链接等。ToolCard 应与之一致。
6+
7+
## Goals / Non-Goals
8+
9+
**Goals:**
10+
- 工具结果按内容类型智能格式化:JSON → pretty-print + code fence,bash/grep/read_file → code fence,Markdown → 透传
11+
- ToolCard 通过已有 `<Markdown>` 组件渲染结果,获得代码高亮、表格、链接等能力
12+
- 不引入新前端依赖
13+
14+
**Non-Goals:**
15+
- 不改变 ToolCard 的容器结构(卡片尺寸、边框、圆角、工具名头部)
16+
- 不改变 MessageBubble 布局或 session 主界面结构
17+
- 不改变 agent 看到的原始 tool result(只影响 UI 展示路径)
18+
- 不做语法级别的 JS/TS 高亮(那是语言级的 feature,`react-markdown` + code fence 已足够)
19+
20+
## Decisions
21+
22+
### Decision 1: 格式化逻辑放在扼流点,不在 ToolCard
23+
24+
**Choice:** `formatToolResultForUI` 输出时直接包含 markdown code fence;前端 ToolCard 只负责 `<Markdown>` 渲染。
25+
26+
**Rationale:** 扼流点是所有工具结果到 UI 的必经之路。在这里做格式化,Live 和 History 两条路径自动受益。如果放在 ToolCard(前端),History 路径的 `rebuildDisplayMessages` 输出也要额外处理。
27+
28+
**Alternatives considered:**
29+
- ToolCard 内做检测 + 渲染:逻辑重复,History 路径需单独处理。且前端引入 JSON 检测/pretty-print 会膨胀组件职责。
30+
- 新增后端中间层:过度设计,扼流点本身就是最合适的插入点。
31+
32+
### Decision 2: JSON 检测用 JSON.parse 试探
33+
34+
**Choice:** 检测 `rawText.trim()` 是否以 `{` / `[` 开头,然后 `JSON.parse`。成功则 pretty-print + 包 ```` ```json ````。失败则按其他规则处理。
35+
36+
**Rationale:** 简单的 heuristic 就能覆盖绝大多数情况(grep --json、glob --json、JSON API 响应)。偶尔误判为 JSON 也不产生灾难后果(最多包个空 code fence)。
37+
38+
**Alternatives considered:**
39+
- 正则检测 JSON:不够可靠,JSON 内可以含字符串 `"{"`
40+
- 不做 JSON 检测,所有内容统一包 ```:bash 结果包 ```sh 是对的,但纯 JSON 用 ``` 没有语言标注,语法高亮缺失
41+
- 正则 `/^\s*[\{\[]/` 判断:加 `JSON.parse` 只是多一个 try,成本极低,且能排除"以 { 开头的非 JSON 内容"
42+
43+
### Decision 3: ToolCard 换用 <Markdown> 渲染
44+
45+
**Choice:** 将 ToolCard 结果体从 `<span>→ {displayText}</span>` 改为 `<Markdown className="text-xs">{displayText}</Markdown>`。去掉 `font-mono`,让 Markdown 组件自行控制等宽代码块。
46+
47+
**Rationale:** `<Markdown>` 已在 MessageBubble 中使用,是经过验证的成熟组件。ToolCard 结果体本身就是一个 markdown 内容区域,用同样的渲染方式保持一致性。
48+
49+
**Alternatives considered:**
50+
- 保持纯文本 + 前端 JSON.pretty-print:需要额外引入 `prism``highlight.js`,增加 bundle size,且与 MessageBubble 的渲染方式不一致
51+
- 只在有 code fence 时用 Markdown,纯文本保持原样:分支逻辑增加了 ToolCard 复杂度,且 `<Markdown>` 对纯文本的渲染也是安全透传
52+
53+
### Decision 4: 工具特定格式化规则
54+
55+
**Choice:**`formatToolResultForUI``switch` 中按 toolName 分派,最后统一做 JSON 检测兜底。
56+
57+
| 工具 | 格式化规则 |
58+
|------|-----------|
59+
| `write_file` / `overwrite_file` | 保持现有摘要提取(extractWriteSummary) |
60+
| `bash` | 内容包 ```` ```sh ``` ````,保持截断 |
61+
| `grep` | JSON 检测 → pretty-print + ```` ```json ``` ````;非 JSON → ```` ``` ``` ```` |
62+
| `glob` | 同上 |
63+
| `read_file` | 内容包 ```` ``` ``` ````(可后续扩展语言检测),保持截断 |
64+
| 其他 | JSON 检测 → pretty-print + ```` ```json ``` ````;否则保持现有默认截断 |
65+
66+
## Risks / Trade-offs
67+
68+
- **Risk:** JSON.parse 在大字符串上可能耗时。**Mitigation:** grep/glob 的 JSON 输出通常较小(已截断到 600),且 `JSON.parse` 在 V8 中极快。
69+
- **Risk:** `write_file` 摘要中意外包含 markdown 语法字符(如 `_``*`)被错误渲染。**Mitigation:** 当前摘要只含 "Written X bytes to path" 和 "New file version: fv_xxx"——不含 markdown 特殊字符。如果后续摘要内容变化,用 escape 处理。
70+
- **Trade-off:** NDJSON(每行一个 JSON 对象)只能取首行。**Mitigation:** 大多数工具结果本身已截断到 600 字符,NDJSON 场景影响极小。
71+
- **Risk:** 某些工具结果本身就包含 ` ``` ` 字符,会破坏 markdown code fence。**Mitigation:** 罕见且当前 600 字符截断已大幅降低概率。可在 code fence 前后加 `\n` 作为天然分隔。
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
## Why
2+
3+
工具结果在 ToolCard 中全部以纯文本(`font-mono whitespace-pre-wrap`)渲染。JSON 是一坨压缩字符串,Markdown 不解析,bash 输出没有代码块包裹——用户看到的是一堵灰墙。而同一消息气泡里的 assistant 正文已经通过 `<Markdown>` 组件完整渲染了,ToolCard 落后了。
4+
5+
`unify-tool-result-formatting` 已经把截断逻辑收敛到 `formatToolResultForUI` 这个扼流点,现在是时候在这个基础之上让工具结果**有格式**
6+
7+
## What Changes
8+
9+
- **增强** `formatToolResultForUI` — 在截断之外,增加内容感知的格式化:JSON 检测 + pretty-print + code fence、bash/read_file 包 code fence
10+
- **修改** `ToolCard.tsx` — 结果体从纯 `<span>` 改为 `<Markdown>` 渲染,去掉 `font-mono` 让 Markdown 控制排版
11+
- **不改** ToolCard 的容器结构、MessageBubble 布局、session 主界面
12+
13+
## Capabilities
14+
15+
### New Capabilities
16+
- `tool-result-content-rendering`: 工具结果按内容类型智能格式化(JSON pretty-print、代码块包裹、Markdown 透传),ToolCard 通过 Markdown 组件渲染结果文本
17+
18+
### Modified Capabilities
19+
- `web-frontend`: ToolCard 的结果渲染方式从纯文本变为 Markdown 渲染
20+
21+
## Impact
22+
23+
- `src/ui/shared/tool-result-formatter.ts`**修改**,增加 JSON 检测 + 内容感知格式化逻辑
24+
- `web/src/components/ToolCard.tsx`**修改**,结果体换用 `<Markdown>` 组件
25+
- `tests/ui/tool-result-formatter.test.ts`**修改**,补充新格式化行为的测试用例
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
## ADDED Requirements
2+
3+
### Requirement: Content-aware tool result formatting
4+
`formatToolResultForUI` SHALL detect the content type of tool result text and apply appropriate formatting before it reaches the frontend. The function SHALL produce markdown-ready output so that ToolCard can render it through the shared `<Markdown>` component.
5+
6+
#### Scenario: JSON detected and pretty-printed
7+
- **WHEN** a tool result string starts with `{` or `[` and `JSON.parse` succeeds
8+
- **THEN** the function SHALL `JSON.stringify` the parsed value with 2-space indentation
9+
- **AND** SHALL wrap the result in a markdown code fence with `json` language tag (```` ```json ````)
10+
11+
#### Scenario: bash tool result wrapped in code fence
12+
- **WHEN** a `bash` tool completes with output text
13+
- **THEN** the function SHALL wrap the result in a markdown code fence with `sh` language tag (```` ```sh ````)
14+
- **AND** SHALL preserve the existing truncation behavior (600 char default)
15+
16+
#### Scenario: grep tool result with JSON output
17+
- **WHEN** a `grep` tool completes and the result is valid JSON
18+
- **THEN** the function SHALL pretty-print and wrap as ```` ```json ````
19+
- **AND** SHALL preserve the existing truncation behavior
20+
21+
#### Scenario: grep tool result with plain text output
22+
- **WHEN** a `grep` tool completes and the result is not valid JSON
23+
- **THEN** the function SHALL wrap the result in a markdown code fence without language tag (```` ``` ````)
24+
- **AND** SHALL preserve the existing truncation behavior
25+
26+
#### Scenario: glob tool result formatted
27+
- **WHEN** a `glob` tool completes
28+
- **THEN** the function SHALL apply the same JSON detection and code fence wrapping as `grep`
29+
30+
#### Scenario: read_file tool result wrapped in code fence
31+
- **WHEN** a `read_file` tool completes with file content
32+
- **THEN** the function SHALL wrap the result in a markdown code fence without language tag (```` ``` ````)
33+
- **AND** SHALL preserve the existing truncation behavior
34+
35+
#### Scenario: write_file / overwrite_file summary preserved
36+
- **WHEN** a `write_file` or `overwrite_file` tool completes
37+
- **THEN** the function SHALL continue to use the existing `extractWriteSummary` behavior
38+
- **AND** SHALL NOT apply additional code fence wrapping
39+
40+
#### Scenario: Unknown tool with JSON result
41+
- **WHEN** an unrecognized tool produces a result that is valid JSON
42+
- **THEN** the function SHALL pretty-print and wrap as ```` ```json ````
43+
- **AND** SHALL preserve the existing 600-char truncation behavior
44+
45+
#### Scenario: Unknown tool with non-JSON result
46+
- **WHEN** an unrecognized tool produces a result that is not JSON
47+
- **THEN** the function SHALL preserve the existing 600-char truncation behavior without code fence wrapping
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
## MODIFIED Requirements
2+
3+
### Requirement: Conversation view
4+
The frontend SHALL display a scrollable conversation area showing user messages, assistant responses with streaming text, thinking blocks with elapsed time indicators, and tool call results, all using the warm flat design system styling. Message state management SHALL use the shared `conversationReducer` from `@dscode/shared/reducer` instead of inline event handling logic. The elapsed time display SHALL be derived from `turnStartRef` (set by `handleSend` or `loader { state: "show" }`) rather than the `processing` state flag. When an assistant message has no text content, the content area SHALL render nothing instead of a "(no content)" placeholder. The conversation scroll container SHALL use `min-height: 0` (Tailwind `min-h-0`) to allow proper flexbox constraint and prevent overflow clipping of large content. Auto-scroll to the bottom SHALL only occur when the user's scroll position is at or near the bottom of the container (within 64px threshold). When the user has manually scrolled away from the bottom, auto-scroll SHALL be suppressed until the user scrolls back to the bottom.
5+
6+
#### Scenario: User message display
7+
- **WHEN** user submits a message
8+
- **THEN** the message appears right-aligned with a warm amber accent background and warm white text, using `border-radius: 12px` bubble shape with subtle fade-up entrance animation (disabled under `prefers-reduced-motion`)
9+
10+
#### Scenario: Streaming assistant response
11+
- **WHEN** the server sends `text_delta` events
12+
- **THEN** the assistant message bubble updates incrementally via `conversationReducer`, using the warm surface background, `1px solid` border, and warm text colors
13+
14+
#### Scenario: Thinking block display with timer
15+
- **WHEN** the server sends `thinking_delta` events
16+
- **THEN** the thinking content appears in a collapsible `<details>` block (open during streaming) with muted warm styling and a subtle left border accent, and the summary shows `Thinking... (Xs)` where X is the elapsed seconds since `turnStartRef` was set by `handleSend`
17+
18+
#### Scenario: Tool call display
19+
- **WHEN** the server sends `tool_start` and `tool_end` events
20+
- **THEN** each tool call appears as an inline flat card with `border-radius: 8px`, `1px solid` warm border, tool name in monospace with amber accent, and muted pastel success/error indicators
21+
- **AND** the tool result text SHALL be rendered through the shared `<Markdown>` component, supporting code blocks with syntax hints, tables, links, and inline code formatting
22+
23+
#### Scenario: Tool result rendered as Markdown
24+
- **WHEN** a tool call card displays its result text
25+
- **THEN** the result body SHALL use the `<Markdown>` component instead of plain text
26+
- **AND** code fences within the result SHALL render as styled code blocks with monospace font
27+
28+
#### Scenario: Empty assistant content renders nothing
29+
- **WHEN** an assistant message has no text content (empty string), with or without thinking and tools
30+
- **THEN** the message bubble content area renders nothing; no "(no content)" placeholder is displayed
31+
32+
#### Scenario: Large tool call result does not break scrolling
33+
- **WHEN** a tool call (e.g., write_file) returns a result that is larger than the viewport height
34+
- **THEN** the ChatView scroll container remains constrained to the viewport and the user can scroll to the bottom of the conversation to see all content, including the message input and the tail of the assistant bubble
35+
- **AND** the tool result body SHALL retain `max-h-40 overflow-y-auto` as a scroll-defense container
36+
37+
#### Scenario: Auto-scroll during streaming is instant — only when at bottom
38+
- **WHEN** the conversation has a streaming message (hasStreaming is true) AND the user's scroll position is within 64px of the container bottom
39+
- **THEN** auto-scroll to the bottom uses `scrollIntoView({ behavior: "instant" })` instead of smooth behavior
40+
41+
#### Scenario: Auto-scroll when idle is smooth — only when at bottom
42+
- **WHEN** the conversation has no streaming message (hasStreaming is false) AND the user's scroll position is within 64px of the container bottom
43+
- **THEN** auto-scroll to the bottom uses `scrollIntoView({ behavior: "smooth" })` for a polished user experience
44+
45+
#### Scenario: Auto-scroll suppressed when user scrolls away
46+
- **WHEN** the user has manually scrolled more than 64px away from the container bottom AND a state change would normally trigger auto-scroll (messages update, processing toggle, permission prompt)
47+
- **THEN** auto-scroll does NOT fire; the user's current scroll position is preserved
48+
49+
#### Scenario: Auto-scroll re-engages on return to bottom
50+
- **WHEN** the user manually scrolls back to within 64px of the container bottom
51+
- **THEN** subsequent state changes resume auto-scrolling to the bottom
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
## 1. 增强 `formatToolResultForUI` 内容感知格式化
2+
3+
- [x] 1.1 添加 `isJSON(text)` helper — 检测 `{` / `[` 开头 + `JSON.parse` 成功
4+
- [x] 1.2 添加 `formatCodeBlock(text, lang?)` helper — 将文本包入 ```` ```lang ```` code fence
5+
- [x] 1.3 扩展 `switch` 分支:`bash````` ```sh `````grep` → JSON 检测 + code fence,`glob` → 同 grep,`read_file````` ``` ````
6+
- [x] 1.4 在 `default` 分支添加 JSON 检测兜底:有效 JSON → ```` ```json ```` + pretty-print
7+
- [x] 1.5 确保 `write_file` / `overwrite_file` 不受影响(仍走 `extractWriteSummary`
8+
9+
## 2. 更新测试
10+
11+
- [x] 2.1 添加 JSON 检测 + pretty-print 的测试用例
12+
- [x] 2.2 添加 `bash` / `grep` / `glob` / `read_file` code fence 测试用例
13+
- [x] 2.3 添加 `default` JSON 兜底测试用例
14+
- [x] 2.4 确保 `write_file` 现有测试仍然通过
15+
- [x] 2.5 运行 `npx vitest run` 确认全部通过
16+
17+
## 3. ToolCard 改用 Markdown 渲染
18+
19+
- [x] 3.1 在 `ToolCard.tsx` 中 import `<Markdown>` 组件
20+
- [x] 3.2 将结果体 `displayText` 的渲染从纯 `<span>` 改为 `<Markdown className="text-xs">`
21+
- [x] 3.3 去掉结果容器的 `font-mono break-all whitespace-pre-wrap`(Markdown 自行处理排版)
22+
- [x] 3.4 保留 `max-h-40 overflow-y-auto` 作为滚动防御
23+
24+
## 4. 验证
25+
26+
- [x] 4.1 `npx tsc --noEmit -p tsconfig.json` 通过
27+
- [x] 4.2 `npx vitest run` 通过
28+
- [x] 4.3 手动验证:执行 `bash` 工具,确认结果有代码块样式
29+
- [x] 4.4 手动验证:执行 `grep` 工具(JSON 输出),确认 JSON 被 pretty-print + 语法高亮
30+
- [x] 4.5 手动验证:加载历史 session,确认 History 路径也正确格式化
31+
- [x] 4.6 手动验证:`write_file` 结果仍显示摘要(不受影响)
32+
- [x] 4.7 手动验证:ToolCard 卡片布局和 session 主界面结构未变化
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
schema: spec-driven
2+
created: 2026-06-17

0 commit comments

Comments
 (0)