DeepSeek Harness 渲染决策复盘:TUI 通用工具卡片为什么改用 Markdown 主题渲染
本决策记录来自 DeepSeek Harness 仓库的 .agents/notes 历史档案,讲述的是 TUI(终端界面)中"通用工具卡片"(generic card)结果内容的 Markdown 渲染问题:工具展示器(presenter)会在通用卡片里写入 Markdown——包括用于后台任务确认和执行错误的围栏 console 代码块——而旧的纯文本渲染会把围栏标记原样暴露出来,导致工具卡片与同一条 transcript(对话文本记录)中的助手消息、用户消息视觉风格不一致。读完本篇,你能理解该决策的取舍逻辑(先渲染、后截断)、三个被否决的替代方案及其原因,以及当前仓库中仍保留的渲染意图(render intent)契约与头尾截断算法如何承接这一设计。
需要注意一个时效前提:该决策记录的状态为 implemented(2026-07-23 落地,2026-08-04 归档),但仓库后续在 TUI 包移除决策 中删除了 packages/ui/tui。因此本文描述的是已归档的历史设计决策,其中"共享 Markdown 主题"的具体行为是决策记录所固定的事实;而渲染意图联合类型、通用/终端/diff 卡片契约、头尾行数限制等中性机制,在删除 TUI 之后仍然存在于当前代码中。
问题:围栏标记裸露在终端上
决策原文(2026-07-23-tui-generic-card-markdown.md,中文版本)对问题的陈述是:工具展示器可以把 Markdown 写入通用卡片内容,其中典型来源是围栏 ```console 输出,用于后台任务确认(acknowledgement)和执行错误场景。这类内容如果按纯文本渲染:
- 围栏标记(
```行)会作为可见文本暴露给用户; - 工具卡片与同一 transcript 中的助手内容、用户内容显示不一致——后者本来就是 Markdown 渲染的,而工具卡片却退化成原始文本。
要理解"通用卡片"在这个体系里的位置,可以对照当前仓库的呈现契约 packages/core/tools/src/presentation.ts。ToolResultView 是一个以 card 字段打标签的联合类型,UI 侧按 card 字段做 switch 分发:
// packages/core/tools/src/presentation.ts#L140
export type ToolResultView =
| GenericResultView
| TerminalResultView
| DiffResultView
| SearchResultView
| ReadResultView
| WebResultView
其中 GenericResultView 就是"默认完成卡片",结构如下(content 承载 UI 面向的结果内容,省略则渲染原始结果文本):
// packages/core/tools/src/presentation.ts#L146-L155
export interface GenericResultView {
card: 'generic'
/** Replacement title for the completed call. Omit to keep the pending-state title. */
title?: string
/**
* UI-facing result content (harness ContentBlocks), reformatted from
* the model-facing result. Omit to let the UI render the raw result content.
*/
content?: ContentBlock[]
}
也就是说,任何一个没有专属卡片能力的工具(或 UI 未注册该工具专属视图时)都会回落到通用卡片;而通用卡片的 content 恰恰是最可能包含展示器撰写的 Markdown(含围栏代码块)的通道。这正是问题发生的位置。
决策:先 Markdown 渲染,再应用头尾行数限制
决策的核心内容分三层:
1. 通用卡片的"结果内容"走共享 Markdown 主题
TUI 先用共享的 Markdown 主题渲染通用卡片的结果内容(result content),再应用卡片自身的头尾行数限制(head-and-tail line limit)。共享主题的三条具体行为由决策记录固定:
- 隐藏围栏语法——
```分隔行不再作为可见文本出现; - 保留可选的语言标签——如
```console中的console仍然显示,作为代码块的语言/来源标识; - 围栏正文按代码配色——块内文本使用代码样式而非正文样式着色。
这三条共同保证了:后台任务确认和执行错误里的 ```console 块,在终端上的呈现方式与同一条 transcript 中助手消息里的代码块一致。
2. 渲染先于截断
"Rendering precedes truncation" 是这个决策里最容易被忽略、但影响最远的顺序约束:
- 收起状态(collapsed)卡片的行数统计和截断边界描述的是可见的终端行(rendered terminal rows),而不是 Markdown 源文本行(source rows)。
- 如果反过来先按源文本行截断,一个
```console块可能被从中间劈开(留下没有配对的围栏),且"卡片显示的行数"与"卡片实际占用的行数"不一致。
截断算法本身在仓库中是纯函数,当前实现在 packages/client/ui-primitives/src/head-tail-cap.ts:
// packages/client/ui-primitives/src/head-tail-cap.ts#L23-L27
export function headTailCap(total: number, maxLines: number, expanded: boolean): HeadTailCap {
const hidden = total - maxLines
const headLines = Math.ceil(maxLines / 2)
return { hidden, capped: hidden > 0 && !expanded, headLines, tailLines: maxLines - headLines }
}
语义上:hidden 为超出上限的隐藏行数(<= 0 表示未隐藏),headLines = ceil(maxLines / 2),tailLines 为头部取走后剩余的配额,expanded 为真时完全解除上限。注意它的参数 total 是"行数"——"渲染先于截断"原则要求调用方传入的是渲染后的可见行数,而不是 Markdown 源文本的行数。这一职责划分(纯算术函数只算指标,调用方自己切分行)也符合该模块"调用方用自己的 headLines/tailLines 切片、以便在之上叠加各自关注点"的设计注释。
3. 终端卡片与 diff 卡片保持专门的纯文本渲染器
决策明确划定了边界:只有通用卡片的结果内容走 Markdown 主题。终端卡片(terminal card)和 diff 卡片保留各自的专门纯文本渲染器,原因是终端输出和 diff 有专用格式,且可能包含必须按字面显示的 Markdown 标点(例如 shell 输出里的 #、*、-、[bracket] 若被当作标题、列表、链接语法解释,语义就会被破坏)。
与此配套的一条边界同样重要:通用卡片的原始输入(rawInput)仍按字面显示。从源码契约看(presentation.ts),rawInput 是"在详情/展开视图中显示的显著输入",字符串原样渲染、对象渲染为 pretty JSON。决策记录给出的理由是:rawInput 代表的是工具参数,而不是展示器撰写的行文(presenter-authored prose)——参数值(路径、命令、查询串)被当 Markdown 解释反而有害。
当前仓库仍能看到"终端意图、通用回落"这条契约链。TerminalCallView 的注释(presentation.ts#L77-L83)写明:有能力的 UI 渲染终端卡片,无能力的 UI 回落到 body 为围栏命令输出的通用卡片;TerminalResultView 的注释(presentation.ts#L157-L162)则补充:无能力 UI 会得到一个由 BRIDGE 从 output 派生的围栏 ```console 回落,工具自身不做二次编码。这正是决策记录中"围栏 console 输出"的主要生产方之一,也解释了为什么通用卡片的 Markdown 渲染路径必须能正确处理围栏。
备选方案与拒绝理由
决策记录列出了三个被否决的替代方案,其拒绝理由体现了该仓库的分层原则(展示器不依赖 UI 行为、UI 不特化某个生产方、字面语义不可被 Markdown 解释污染):
| 备选方案 | 拒绝理由 |
|---|---|
| 在 Bash 展示器中剥除围栏 | 只修复一个生产方,其他工具产生的通用卡片 Markdown 仍不会被渲染;还会让展示器依赖 TUI 的行为,破坏分层 |
| 把所有工具卡片都按 Markdown 渲染 | 终端输出和 diff 有专门格式,且可能包含必须保持字面显示的 Markdown 标点 |
| 在 Markdown 渲染之前应用收起状态卡片的行数限制 | 按源文本行截断可能把围栏块从中间截断,还会让可见行数与卡片使用的行数不一致 |
这三条否决理由与"决策"一节形成了完整的对照:修复点必须落在消费端(TUI 的渲染路径)且必须按渲染意图(card 类型)分流,Markdown 只解释"展示器撰写的行文",不解释"参数与字面输出"。
后果:统一 Markdown 词汇,按渲染意图分流
决策记录的 Consequences 部分固定了四条长期影响:
- 同一套词汇与净化路径:通用工具卡片与对话内容从此使用同一套 Markdown 词汇和净化(sanitization)路径。这意味着 Markdown 安全处理只需维护一份,工具卡片与对话内容天然一致。
- 标点语义变化:通用卡片中的 Markdown 标点会被解释,而不再总是按字面显示。这是一个有意的行为变更——它换取了视觉一致性,代价是通用卡片不再是纯文本容器。
- 字面输出的逃生通道:需要字面终端输出的工具,应使用终端卡片这一渲染意图(
card: 'terminal'),而不是把输出塞进通用卡片。渲染意图联合类型(ToolResultView的card字段)就是这条逃生通道的类型化表达。 - 测试覆盖:聚焦的 TUI 测试固定了三个行为点——隐藏的围栏、保留的语言标签、正文文本;无密钥(keyless)的终端状态快照通过组装后的 TUI transcript 端到端覆盖该行为。
仓库中的后续:TUI 移除后,哪些部分活了下来
理解这一决策在当前仓库中的适用边界,需要结合一条后续记录:Remove the TUI package(2026-08-04 落地)。该决策删除了 packages/ui/tui 包及其终端渲染器、快照 fixtures、补丁依赖与 SDK 脚手架,且明确说明归档的 TUI 实现笔记(包括本文对应的这份)是"历史冻结记录,不再是受支持包或应用清单的权威"。因此:
- "共享 Markdown 主题隐藏围栏、保留语言标签、代码配色"这一具体渲染行为,属于已删除的 TUI 实现,在当前代码库中不可再直接验证,本文按决策记录的表述引用;
- 仍然活在当前仓库中的部分是决策所依托的中性契约:
card打标签的渲染意图联合与 generic/terminal/diff 分工(packages/core/tools/src/presentation.ts)、头尾截断纯函数(packages/client/ui-primitives/src/head-tail-cap.ts)、以及"无能力 UI 回落到围栏console通用卡片"的 bridge 契约。现存产品界面(Web、ACP、JSON-RPC、一次性 CLI)中,Markdown 与围栏的处理依然延续着"展示器行文走 Markdown、参数与终端输出保字面"的同一套分流原则;Web 侧的围栏高亮行为甚至有专门的端到端 fixture,例如 apps/web/tests/streaming-fence-highlight.e2e.ts 与 mid-stream.expected.md。
小结
这份 2026-07-23 的决策记录给出的是一个可复用的界面渲染方法论,而不仅是一次 TUI 修复:
- 修复点放在消费端(UI 渲染路径),而不是让每个生产方(工具展示器)各自适配终端行为;
- 按渲染意图分流:展示器撰写的行文(通用卡片结果内容)走共享 Markdown 主题与净化路径;工具参数(rawInput)与专用格式(终端输出、diff)保持字面;
- 先渲染、后截断:所有行数统计与头尾边界基于可见终端行计算,截断纯函数(
headTailCap)只消费行数,由调用方保证传入的是渲染后的行数; - 行为变更必须写清代价:Markdown 标点从"总是字面"变为"被解释",同时提供渲染意图层面的逃生通道(终端卡片)。
TUI 包后来被整体移除,但上述契约中的中性部分(呈现意图联合、头尾截断、围栏回落)仍然构成 DeepSeek Harness 工具卡片体系的现行基础;对这份历史决策的复述与路径对照,可作为理解当前 presentation.ts 与 ui-primitives 卡片行为的设计出处参考。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0622
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00