feat(transcript): 把 asktool 工具行特化为「回答摘要卡」
状态:待实现(未开工)
设计来源:asktool-row-demos/index.html 方案 A(回答摘要卡),已确认两项细节:选项默认全部可见、行头文案为 提问 · N 个问题 · 已作答 n / N
目标结构:asktool-row-demos/target-structure.html
建议决策号:D650(当前最大为 D649)
建议分支:feat/asktool-transcript-card
1. 现状(可复现)
一次 asktool 调用在对话列表(转录)里长这样:
调用 {"questions":[{"question":"你希望我优先优化哪一部分?","options":[{"label":"转录列表性能","descripti…
└─ 输出
└─ <pre>你希望我优先优化哪一部分?:工具行视觉\n---\n验收时想看到哪些内容?:本地 demo 预览、截图对比</pre>
└─ questions
└─ <pre>{ "questions": [ … 完整 JSON … ] }</pre>
链路与根因(均已在当前 main 上核对):
| 环节 |
位置 |
行为 |
| 工具语义 |
packages/agent-runtime/src/runtime.ts:473 ASK_TOOL_NAME = "asktool" |
工具名固定为 asktool |
| 参数形状 |
packages/shared/src/types/agent.ts:238 |
{ questions: [{ question, options: Array<string | {label, description}>, multiSelect? }] } |
| 结果文本 |
packages/shared/src/types/agent.ts:270 formatAskToolOutput() |
${question}:${answers[i]?.join("、") ?? ""},多题用 \n---\n 连接;null = 该题被跳过或整单被拒绝 |
| 动作归类 |
apps/desktop/src/lib/tool-display.ts:96 getToolAction() |
asktool 落到 "use" → 行头文案 chat.toolUsed = “调用”(features/chat/transcript/shared.tsx:326) |
| 行头摘要 |
apps/desktop/src/lib/tool-display.ts:177 getToolSummary() |
SUMMARY_KEYS.use 不含 questions → 回退 JSON.stringify(args) 压成单行、截断 220 字 |
| 展开体 |
apps/desktop/src/lib/tool-presentation.ts:694 buildToolPresentation() |
resultBlocks() 无 asktool 分支 → 落到 codeBlock("output", payload)(同文件 :680);且 wantArgs 因 action === "use" 为真,追加 recordBlocks(remaining, "input") → 数组里的对象再被 safeJson 成 JSON 代码块(同文件 :423) |
| 渲染 |
apps/desktop/src/features/chat/transcript/ToolRow.tsx:173 HostToolRow → apps/desktop/src/components/ToolDetails.tsx |
通用 “输出 / 输入” 两块 |
| 已有友好卡 |
apps/desktop/src/components/AskToolCard.tsx + docs/spec/04-ux/11-asktool-question-card.md |
输入框上方的待答卡已经友好;转录里的历史行没有对应表达 |
问题归纳:
- 折叠态读不出“它问了我什么、我答了什么”,只有 220 字的 JSON 前缀。
- 展开态是两段 JSON + 一段用
\n---\n 拼的文本,问题/选项/我的回答混在一起,看不出答的是哪一题。
- “未作答 / 跳过 / 全部拒绝”在结果文本里都是空串,渲染后完全一样,无法区分。
- 行头图标是通用扳手(
ToolActionIcon("use")),与“提问”语义无关。
2. 目标
转录里的 asktool 行变成一张只读的「回答摘要卡」:
- 折叠态:
提问 · 2 个问题 · 已作答 2 / 2(一眼知道问了几题、答了几题)。
- 展开态:逐题一行 —— 题号 + 问题文本 + 我的回答(✓)+ 该题的全部选项(选中项高亮),自定义答案单独标注来源。
- 不丢信息:解析失败、老消息、导入消息一律回退到现状呈现。
3. 已确认的设计决策
| 编号 |
决策 |
理由 |
| A-1 |
展开体采用回答摘要卡(逐题一行),不使用选项卡片网格 |
与转录其它工具行同节奏,长会话里安静;选项仍在同一行内可见 |
| A-2 |
选项默认全部可见(不做“其他选项 N 个”折叠) |
回答要有上下文:能看见当时还有哪些选择 |
| A-3 |
行头文案 提问 · {{total}} 个问题 · 已作答 {{answered}} / {{total}};未作答时 · 未作答;运行中 · 等待回答 |
语序即结论,不需要额外 chip |
| A-4 |
只读:转录卡不提供再次提交 |
唯一提交面仍是 AskToolCard(输入框停靠区),避免二次提交与状态歧义 |
| A-5 |
解析失败 → 回退现状(输出 + questions 两个代码块) |
不引入“信息黑洞”风险 |
| A-6 | 卡片卡内滚动兜底:.asktool-history { max-height: min(320px, 45vh); overflow: auto; overscroll-behavior: contain; scrollbar-gutter: stable } | 超长内容不许把转录撑成几屏;.tool-row-content(Bash 输出)已有 260px 上限的先例,问答卡给 320px |
| A-7 | 单个超长文本(> 2000 字)不裁剪、不分页,改用限高 + 渐隐遮罩 + 可滚动 + 键盘可聚焦(tabindex="0" + role="group" + aria-label) | 信息必须完整;LargeTextPreview 是分页组件(chat.largeTextPage = “第 N / M 部分”),为几十 KB 的 Bash 输出设计,问答卡里分页阅读是错的 |
| A-8 | 选项不设数量上限(题干“全部可见”的自然结论),高度交给 A-6 的卡内滚动 | 一旦设上限就必须隐藏信息,与 A-5/R5“不丢信息”冲突;极端数量(单题 > 50 项)先观察,出现真实问题再加收口 |
| A-9 | 窄宽(≤ 720px):选项允许换行、卡片内边距收到 8px 10px;工作面板最窄 320px 下不得出现横向滚动 | 工作面板宽度可拖到 320px,长英文/URL 类选项极易溢出 |
4. 详细设计
4.1 新增纯函数模块 apps/desktop/src/lib/ask-tool-history.ts
import type { AskToolQuestion } from "@pi-desktop/shared";
import type { ToolPresentationMessage } from "./tool-presentation";
/** asktool 行:题目恒来自调用参数,答案恒来自结果文本。 */
export type AskToolHistory = {
questions: AskToolQuestion[];
/** 与 questions 等长;null = 该题被跳过或整单被拒绝。 */
answers: Array<string[] | null>;
/** 调用仍在进行(结果文本还没到)。 */
pending: boolean;
};
export function isAskToolName(toolName?: string): boolean;
export function askToolHistory(message: ToolPresentationMessage): AskToolHistory | null;
/** 行头文案选择(纯函数返回 i18n key + 参数,翻译留在渲染层)。 */
export function askToolHeadline(history: AskToolHistory, status?: string):
| { key: "chat.askToolRow.summaryPending"; params: { total: number } }
| { key: "chat.askToolRow.summaryUnanswered"; params: { total: number } }
| { key: "chat.askToolRow.summaryAnswered"; params: { total: number; answered: number } };
解析规则(每条都要有对应用例):
- 名字判定:复用
tool-display.ts 里已有的 bareToolName()(需从私有函数改为导出),去掉 provider / plugin 前缀后等于 asktool 才算;因此 plugin:asktool 之类不会被误判,plugin_asktool 也不会命中非 asktool 工具。
- 题目:读
message.toolArgs.questions,逐条用 normalizeAskToolOption()(packages/shared/src/types/agent.ts:211,已存在)归一化选项,丢弃非法项;有效题目 ≥ 1 才继续,否则返回 null(例如流式中的半截 JSON)。
- 结果文本:
envelopeTextOf(message)(tool-presentation.ts:178,已存在,能同时处理裸字符串与 {content, details} 信封)→ 取不到就 pending = true,answers 全 null。
- 逐题匹配:文本先
replace(/\r\n/g, "\n"),按 \n---\n 切成段;对第 i 题,必须存在一段以 `${question}:` 开头(startsWith,不能 split(":") —— 题目文本本身允许含全角冒号),取其后的子串作为答案。
- 子串为空 →
answers[i] = null(跳过 / 拒绝)。
- 否则
answers[i] = 子串.split("、")(与 formatAskToolOutput 的 join("、") 严格对称)。
- 任何一题匹配不到 → 整体返回
null(宁可回退,也不要渲染半截卡片)。
- 长度校验:
answers.length === questions.length,否则 null。
- 自定义答案无需额外字段:
!questions[i].options.some(o => label(o) === value) 即为自定义(渲染层判定,避免解析器承担展示语义)。
4.2 展示块 apps/desktop/src/lib/tool-presentation.ts
ToolBlockRole 增加 "ask"。
ToolBlock 增加一个成员:
| (BlockBase & {
kind: "ask";
questions: AskToolQuestion[];
answers: Array<string[] | null>;
})
buildToolPresentation():在现有 resultBlocks() 调用之前/之后加早返回(与 delegate 的 mapped 思路一致):
const ask = isAskToolName(message.toolName) ? askToolHistory(message) : null;
if (ask) return [{ kind: "ask", role: "ask", questions: ask.questions, answers: ask.answers }];
这条早返回同时关掉了 wantArgs(action === "use")追加的裸 JSON —— 这正是本次的核心收益。解析为 null 时完全走原路径,零行为变化。
hasToolDetails() 不需要改:ask 块非空即可展开,caret 自然出现。
4.3 行头 apps/desktop/src/features/chat/transcript/ToolRow.tsx
在 HostToolRow 里,与 lifecycle / roster 同一层:
const askHistory = isAskToolName(message.toolName) ? askToolHistory(message) : null;
const askHead = askHistory ? askToolHeadline(askHistory, status) : null;
const rawName = ...; // 不变
const summary = askHead ? t(askHead.key, askHead.params) : lifecycle ? rosterSummary : argSummary;
const headLabel = askHead ? t("chat.askToolRow.name") : actionLabel; // "提问" 取代 “调用”
- 图标、
aria-label、status-* class、ToolChips、折叠/展开机制全部不动:图标保持 ToolActionIcon("use")(本次不引入新图标,避免连带影响其它 use 类工具)。
argSummary 仍然计算,只是 asktool 行不使用它,其它工具行零影响。
- 不给 ask 行加 chip(A-3 的文案已表达状态),避免一个状态说两遍。
4.4 卡片组件 apps/desktop/src/components/AskToolHistoryCard.tsx(新增)
放在 components/ 而不是 features/chat/transcript/:它由 components/ToolDetails.tsx 渲染,与 PermissionCard 共用同一层;但不复用 AskToolCard.tsx(那是待答交互卡,含提交状态机)。
export function AskToolHistoryCard({
questions,
answers,
}: { questions: AskToolQuestion[]; answers: Array<string[] | null> }) { … }
packages/shared 已有 askToolOptionLabel / askToolOptionDescription,直接用来归一化展示。
- 问题文本复用
AskToolRichText(components/AskToolRichText.ts):composer 卡已经允许题目/选项里的 Markdown,转录卡必须同样渲染,否则同一个问题两处显示不同。
- 全部为静态节点(
<ol>/<li>/<p>/<span>),没有按钮、没有 aria-pressed/role="radio" —— 只读语义,键盘只需要折叠开关。
4.5 apps/desktop/src/components/ToolDetails.tsx
BLOCK_LABEL_KEYS 增加 ask: "chat.askToolRow.heading"。
blockCopyText() 增加 case "ask":返回 formatAskToolOutput(questions, answers.map(a => a ?? []))(packages/shared 已有函数),即“复制后能贴回给模型”的那份文本。
BlockBody() 增加 case "ask": return <AskToolHistoryCard ... />。
4.6 样式 apps/desktop/src/styles/asktool-history.css(新增,由组件 import)
只用既有 --ds-* 令牌,不新增颜色常量(pnpm --filter @pi-desktop/desktop lint 会跑 scripts/check-style-tokens.mjs)。取色与 composer 卡的已选态严格一致:
- 卡片底
--ds-tile;选项底 --ds-tile-deep
- 选中项
color-mix(in oklab, var(--ds-accent) 10%, var(--ds-tile-deep)),文字升到 --ds-text-primary
- 勾标记 15×15、
--radius-2xs,选中底色 color-mix(in oklab, var(--ds-accent) 18%, transparent)
- 自定义答案 chip:
1px dashed var(--ds-border-strong),选中时改实线
- 字号梯级:问题
--text-md(13px)/medium,回答与选项 --text-sm-plus(12.5px),来源标记与题号 --text-xs(11px)
- 完整可复制 CSS 见
target-structure.html 的第 3 节
4.7 i18n(9 个 locale:de, en, es, fr, ko, pt-BR, tr, zh-CN, zh-TW)
packages/i18n/src/locales/<locale>/index.ts 的 chat 段新增 askToolRow:
| key |
zh-CN |
en |
chat.askToolRow.name |
提问 |
Asked |
chat.askToolRow.summaryAnswered |
{{total}} 个问题 · 已作答 {{answered}} / {{total}} |
{{total}} questions · answered {{answered}} / {{total}} |
chat.askToolRow.summaryUnanswered |
{{total}} 个问题 · 未作答 |
{{total}} questions · unanswered |
chat.askToolRow.summaryPending |
{{total}} 个问题 · 等待回答 |
{{total}} questions · waiting for an answer |
chat.askToolRow.heading |
问答 |
Questions |
chat.askToolRow.kindSingle |
单选 |
Single choice |
chat.askToolRow.kindMulti |
多选 |
Multiple choice |
chat.askToolRow.answerCustom |
输入的其他答案 |
Typed answer |
chat.askToolRow.answerSkipped |
已跳过 |
Skipped |
chat.askToolRow.answerDeclined |
已全部拒绝 |
Declined |
chat.askToolRow.answerPending |
等待回答 |
Waiting |
- 复制按钮沿用现有
chat.copy / chat.copied。
- en 若要求严格复数,按仓库既有
queued_one / queued_other 的写法补 _one / _other 变体(其余语言可只用单形)。
4.8 测试 apps/desktop/test/asktool-transcript-card.test.mjs(新增)
按仓库既有模式(node --test + helpers/ts-import-hooks.mjs 直接 import TS,renderToStaticMarkup 渲染组件,参考 test/plan-history.test.mjs、test/asktool-rich-options.test.mjs):
| # |
用例 |
断言 |
| T1 |
两题全答(含一个自定义答案) |
askToolHistory() 返回 2 题;answers[0] = 自定义文本;answers[1] = 两个选项值 |
| T2 |
第二题跳过 |
answers[1] === null;头部走 summaryAnswered(answered = 1) |
| T3 |
整单拒绝 |
两个 null → 头部走 summaryUnanswered;卡片回答行显示 answerDeclined |
| T4 |
题目文本内含全角冒号 |
仍能正确切出答案(startsWith 匹配,不 split(":")) |
| T5 |
结果文本被截断 / 与题目对不上 |
返回 null,且 buildToolPresentation() 回到 输出 + input 两个块(回归保护) |
| T6 |
toolName: "Read" / "plugin:other" |
isAskToolName() 为假,其它工具行零变化 |
| T7 |
运行中(无结果文本,questions 已完整) |
pending === true,头部 summaryPending,卡片回答行显示 answerPending |
| T8 |
渲染 |
卡片里 .asktool-history-item 数 == 题目数;.asktool-history-option.is-selected 数 == 答案值总数;展开体 不含 "questions" 裸 JSON;<pre> 数与题目数不相等(确认没有走代码块路径) |
| T9 |
只读语义 |
卡片内 querySelector("button, input, [role=radio], [role=checkbox]") 为空 |
| T10 |
超长题目(> 2000 字) |
isLongText() 为真;该题文本节点带 is-long + tabindex="0" + role="group" + aria-label;文本内容完整(textContent.length 等于原串长度,证明没有截断);padding-bottom: 18px,滚到底后最后一行不被渐隐遮住(可用 Range.getBoundingClientRect().bottom 与容器 bottom - 18 比较) |
| T11 |
30 个选项的题 |
30 个 .asktool-history-option 全部渲染(不设上限),且 .asktool-history 的 scrollHeight > clientHeight、scrollWidth <= clientWidth(只有纵向滚动) |
| T12 |
长 token 不溢出 |
选项/题目含 80 字符无空格英文串时,scrollWidth <= clientWidth(overflow-wrap: anywhere 生效) |
| T13 |
窄宽 320px |
容器宽度设 320px 后 .asktool-history 仍无横向滚动;选项 white-space 计算值为 normal(命中 @media (max-width: 720px)) |
4.9 规格与决策(行为可见变更,必须同步)
docs/spec/04-ux/11-asktool-question-card.md + docs/zh-CN/spec/04-ux/11-asktool-question-card.md:新增「转录中的问答卡」一节 —— 只读、逐题一行、选项默认全部可见、行头文案、pending/skipped/declined 三种措辞、解析失败回退。
docs/spec/08-meta/decisions-log.md(英文表格式,追加一行 | D650 | … |)+ docs/zh-CN/spec/08-meta/decisions-log.md(中文日期式小节)。
- 可选:
packages/shared/src/changelog*.ts(9 个语言文件)在进入发布版本时追一行 UI 改进文案。
4.10 超长内容展示(验收重点)
「友好卡片」最容易在长度上翻车:一屏一行工具行变成一屏一张卡。规则如下(全部为纯 CSS,不引入新的 JS 状态):
/* 1) 卡内滚动兜底:内容再长,转录行高度也有上限。 */
.asktool-history {
max-height: min(320px, 45vh);
overflow: auto;
overscroll-behavior: contain; /* 滚动不穿透到转录 */
scrollbar-gutter: stable; /* 出滚动条时不跳宽 */
}
/* 2) 单个超长文本:限高 + 底部渐隐,聚焦后可用键盘滚动读完。
* padding-bottom 必须 >= 渐隐带高度(18px),否则滚到底时最后一行被遮罩压住。 */
.asktool-history-question.is-long,
.asktool-history-answer-text.is-long {
max-height: 12rem;
overflow: auto;
padding-bottom: 18px;
-webkit-mask-image: linear-gradient(to bottom, #000 calc(100% - 18px), transparent);
mask-image: linear-gradient(to bottom, #000 calc(100% - 18px), transparent);
}
/* 3) 长 token(URL / 长英文串 / 无空格中文混排)必须能断行。 */
.asktool-history-question,
.asktool-history-answer-text,
.asktool-history-option { overflow-wrap: anywhere; }
/* 4) 窄宽:选项换行 + 收边距。 */
@media (max-width: 720px) {
.asktool-history { padding: 8px 10px 10px; }
.asktool-history-option { white-space: normal; }
}
判定 .is-long 的阈值:文本 length > 2000 或含 ≥ 8 个换行 —— 放在 AskToolHistoryCard 内部的一个纯函数里(isLongText(text)),便于单测;阈值收紧/放宽只改一处。
配套说明(写进规格,避免后人“顺手优化”掉):
- 不做
line-clamp 截断:正文一律完整渲染,长度问题只由“限高 + 滚动”解决;截断会违反 R5。
- 不引入
LargeTextPreview:它是分页组件(apps/desktop/src/components/LargeTextPreview.tsx,chat.largeTextPage / largeTextPagination 系列文案),面向 Bash 的几十 KB 输出;问答分页会让“第 1/3 部分”出现在一个问题中间。
- 不在卡内做
<details> 展开:保持“零提交控件 + 零状态”,长内容用滚动读完;<details> 会带来键盘焦点顺序与只读语义的额外争议。
- 运行中不自动滚动:
ToolRow 里现有的自动滚到底只作用于 .tool-row-content(status === "running"),与本卡的手动滚动互不影响;历史卡一旦自动滚动会打断用户阅读。
- 多题不设上限:题与题之间的分隔线(
border-top)保证长列表里仍能分辨每题边界。
- 渐隐带必须留出等高
padding-bottom:mask-image 把底部 18px 渐变到透明,若滚动容器没有 padding-bottom: 18px,滚到底时最后一行会停在渐隐带里(读到半行就没了)。这不是可选的“美化参数”,而是可事后回归的正确性条件 —— 见 R12 / T10,反例与正例并在 asktool-row-demos/target-structure.html §7.2 对照。
- 渐隐带只在
.is-long 上出现:普通长度的问答(占绝大多数)不加遮罩,避免“短文本也被淡掉尾巴”的观感。
- 滚动容器不嵌套陷阱:
.asktool-history 与 .is-long 各有一层滚动,滚轮在 .is-long 上先滚它自己、到底后由 overscroll-behavior: contain 交给父层(不会滚到转录外层)。
5. 验收标准
6. 影响面与风险
| 风险 |
等级 |
应对 |
结果文本解析脆弱(题目含 :、、 混用、截断) |
中 |
startsWith 前缀匹配 + 长度校验 + 任一题失败即整体回退;T4/T5 覆盖 |
| 运行中参数是半截 JSON |
低 |
有效题目 < 1 直接回退到现有 JSON 摘要;≥1 时头部显示 “等待回答” |
| 超长选项文本撑高卡片 |
低 |
选项 chip 允许换行、不加 max-height;问题/选项 Markdown 复用 AskToolRichText 的既有安全边界(不产生链接/图片/原始 HTML) |
| 子智能体内被调用的 asktool 行 |
低 |
同一渲染路径,自动生效;Task 拓扑行(variant === "topology")不进入特化分支,保持 host row |
插件声明的 toolCard 槽位 |
低 |
ToolRow 里 useSlotEntryForKey("toolCard", toolName) 判断在 host row 之前,保持不动;asktool 不是插件工具,不受影响 |
| 与 composer 卡重复/冲突 |
低 |
转录卡严格只读,不持有 draft 状态,不发 resolveAsk |
7. 非目标
- 不改工具协议、参数/结果形状、IPC、事件、存储(纯渲染层特化)。
- 不改
AskToolCard.tsx(输入框停靠区)的交互与视觉。
- 不改其它工具行的动作归类、图标、摘要策略。
- 不为 asktool 增加专用图标(如需另开 issue)。
- 不适配
Task 拓扑行 / 权限卡。
8. 参考
- 视觉参考:
asktool-row-demos/index.html(页签「A · 问答摘要卡」;「现状(对照)」页签即本文 §1)
- 目标结构:
asktool-row-demos/target-structure.html
- 既有规格:
docs/spec/04-ux/11-asktool-question-card.md
- 设计评审产物(本地产物,未入库,实现者可按需入库或忽略):
asktool-row-demos/index.html:四方案对比 demo(A/B/C/D + 现状对照;A 为最终选择)
asktool-row-demos/target-structure.html:目标 DOM 结构 + 可复制 CSS + 状态矩阵 + 超长内容压力用例;§7.2 是「渐隐吃掉最后一行」的错误 / 正确对照(两卡内容相同、都已被脚本滚到最底,左卡 clearance = -16px、右卡 +1px),可作为实现时的可视判据。
- 若要让它们随实现一起入库,建议提交到
docs/design/asktool-transcript-card/。
- 既有实现:
apps/desktop/src/components/AskToolCard.tsx、apps/desktop/src/lib/tool-presentation.ts、apps/desktop/src/components/ToolDetails.tsx、apps/desktop/src/features/chat/transcript/ToolRow.tsx
feat(transcript): 把 asktool 工具行特化为「回答摘要卡」
1. 现状(可复现)
一次 asktool 调用在对话列表(转录)里长这样:
链路与根因(均已在当前
main上核对):packages/agent-runtime/src/runtime.ts:473ASK_TOOL_NAME = "asktool"asktoolpackages/shared/src/types/agent.ts:238{ questions: [{ question, options: Array<string | {label, description}>, multiSelect? }] }packages/shared/src/types/agent.ts:270formatAskToolOutput()${question}:${answers[i]?.join("、") ?? ""},多题用\n---\n连接;null= 该题被跳过或整单被拒绝apps/desktop/src/lib/tool-display.ts:96getToolAction()asktool落到"use"→ 行头文案chat.toolUsed= “调用”(features/chat/transcript/shared.tsx:326)apps/desktop/src/lib/tool-display.ts:177getToolSummary()SUMMARY_KEYS.use不含questions→ 回退JSON.stringify(args)压成单行、截断 220 字apps/desktop/src/lib/tool-presentation.ts:694buildToolPresentation()resultBlocks()无 asktool 分支 → 落到codeBlock("output", payload)(同文件:680);且wantArgs因action === "use"为真,追加recordBlocks(remaining, "input")→ 数组里的对象再被safeJson成 JSON 代码块(同文件:423)apps/desktop/src/features/chat/transcript/ToolRow.tsx:173HostToolRow→apps/desktop/src/components/ToolDetails.tsxapps/desktop/src/components/AskToolCard.tsx+docs/spec/04-ux/11-asktool-question-card.md问题归纳:
\n---\n拼的文本,问题/选项/我的回答混在一起,看不出答的是哪一题。ToolActionIcon("use")),与“提问”语义无关。2. 目标
转录里的 asktool 行变成一张只读的「回答摘要卡」:
提问 · 2 个问题 · 已作答 2 / 2(一眼知道问了几题、答了几题)。3. 已确认的设计决策
提问 · {{total}} 个问题 · 已作答 {{answered}} / {{total}};未作答时· 未作答;运行中· 等待回答AskToolCard(输入框停靠区),避免二次提交与状态歧义输出+questions两个代码块)| A-6 | 卡片卡内滚动兜底:
.asktool-history { max-height: min(320px, 45vh); overflow: auto; overscroll-behavior: contain; scrollbar-gutter: stable }| 超长内容不许把转录撑成几屏;.tool-row-content(Bash 输出)已有 260px 上限的先例,问答卡给 320px || A-7 | 单个超长文本(> 2000 字)不裁剪、不分页,改用限高 + 渐隐遮罩 + 可滚动 + 键盘可聚焦(
tabindex="0"+role="group"+aria-label) | 信息必须完整;LargeTextPreview是分页组件(chat.largeTextPage= “第 N / M 部分”),为几十 KB 的 Bash 输出设计,问答卡里分页阅读是错的 || A-8 | 选项不设数量上限(题干“全部可见”的自然结论),高度交给 A-6 的卡内滚动 | 一旦设上限就必须隐藏信息,与 A-5/R5“不丢信息”冲突;极端数量(单题 > 50 项)先观察,出现真实问题再加收口 |
| A-9 | 窄宽(
≤ 720px):选项允许换行、卡片内边距收到8px 10px;工作面板最窄 320px 下不得出现横向滚动 | 工作面板宽度可拖到 320px,长英文/URL 类选项极易溢出 |4. 详细设计
4.1 新增纯函数模块
apps/desktop/src/lib/ask-tool-history.ts解析规则(每条都要有对应用例):
tool-display.ts里已有的bareToolName()(需从私有函数改为导出),去掉 provider / plugin 前缀后等于asktool才算;因此plugin:asktool之类不会被误判,plugin_asktool也不会命中非 asktool 工具。message.toolArgs.questions,逐条用normalizeAskToolOption()(packages/shared/src/types/agent.ts:211,已存在)归一化选项,丢弃非法项;有效题目 ≥ 1 才继续,否则返回null(例如流式中的半截 JSON)。envelopeTextOf(message)(tool-presentation.ts:178,已存在,能同时处理裸字符串与{content, details}信封)→ 取不到就pending = true,answers全null。replace(/\r\n/g, "\n"),按\n---\n切成段;对第 i 题,必须存在一段以`${question}:`开头(startsWith,不能split(":")—— 题目文本本身允许含全角冒号),取其后的子串作为答案。answers[i] = null(跳过 / 拒绝)。answers[i] = 子串.split("、")(与formatAskToolOutput的join("、")严格对称)。null(宁可回退,也不要渲染半截卡片)。answers.length === questions.length,否则null。!questions[i].options.some(o => label(o) === value)即为自定义(渲染层判定,避免解析器承担展示语义)。4.2 展示块
apps/desktop/src/lib/tool-presentation.tsToolBlockRole增加"ask"。ToolBlock增加一个成员:buildToolPresentation():在现有resultBlocks()调用之前/之后加早返回(与delegate的mapped思路一致):这条早返回同时关掉了
wantArgs(action === "use")追加的裸 JSON —— 这正是本次的核心收益。解析为null时完全走原路径,零行为变化。hasToolDetails()不需要改:ask 块非空即可展开,caret 自然出现。4.3 行头
apps/desktop/src/features/chat/transcript/ToolRow.tsx在
HostToolRow里,与lifecycle/roster同一层:aria-label、status-*class、ToolChips、折叠/展开机制全部不动:图标保持ToolActionIcon("use")(本次不引入新图标,避免连带影响其它use类工具)。argSummary仍然计算,只是 asktool 行不使用它,其它工具行零影响。4.4 卡片组件
apps/desktop/src/components/AskToolHistoryCard.tsx(新增)放在
components/而不是features/chat/transcript/:它由components/ToolDetails.tsx渲染,与PermissionCard共用同一层;但不复用AskToolCard.tsx(那是待答交互卡,含提交状态机)。packages/shared已有askToolOptionLabel/askToolOptionDescription,直接用来归一化展示。AskToolRichText(components/AskToolRichText.ts):composer 卡已经允许题目/选项里的 Markdown,转录卡必须同样渲染,否则同一个问题两处显示不同。<ol>/<li>/<p>/<span>),没有按钮、没有aria-pressed/role="radio"—— 只读语义,键盘只需要折叠开关。4.5
apps/desktop/src/components/ToolDetails.tsxBLOCK_LABEL_KEYS增加ask: "chat.askToolRow.heading"。blockCopyText()增加case "ask":返回formatAskToolOutput(questions, answers.map(a => a ?? []))(packages/shared已有函数),即“复制后能贴回给模型”的那份文本。BlockBody()增加case "ask": return <AskToolHistoryCard ... />。4.6 样式
apps/desktop/src/styles/asktool-history.css(新增,由组件 import)只用既有
--ds-*令牌,不新增颜色常量(pnpm --filter @pi-desktop/desktop lint会跑scripts/check-style-tokens.mjs)。取色与 composer 卡的已选态严格一致:--ds-tile;选项底--ds-tile-deepcolor-mix(in oklab, var(--ds-accent) 10%, var(--ds-tile-deep)),文字升到--ds-text-primary--radius-2xs,选中底色color-mix(in oklab, var(--ds-accent) 18%, transparent)1px dashed var(--ds-border-strong),选中时改实线--text-md(13px)/medium,回答与选项--text-sm-plus(12.5px),来源标记与题号--text-xs(11px)target-structure.html的第 3 节4.7 i18n(9 个 locale:
de, en, es, fr, ko, pt-BR, tr, zh-CN, zh-TW)packages/i18n/src/locales/<locale>/index.ts的chat段新增askToolRow:chat.askToolRow.namechat.askToolRow.summaryAnswered{{total}} 个问题 · 已作答 {{answered}} / {{total}}{{total}} questions · answered {{answered}} / {{total}}chat.askToolRow.summaryUnanswered{{total}} 个问题 · 未作答{{total}} questions · unansweredchat.askToolRow.summaryPending{{total}} 个问题 · 等待回答{{total}} questions · waiting for an answerchat.askToolRow.headingchat.askToolRow.kindSinglechat.askToolRow.kindMultichat.askToolRow.answerCustomchat.askToolRow.answerSkippedchat.askToolRow.answerDeclinedchat.askToolRow.answerPendingchat.copy/chat.copied。queued_one/queued_other的写法补_one/_other变体(其余语言可只用单形)。4.8 测试
apps/desktop/test/asktool-transcript-card.test.mjs(新增)按仓库既有模式(
node --test+helpers/ts-import-hooks.mjs直接 import TS,renderToStaticMarkup渲染组件,参考test/plan-history.test.mjs、test/asktool-rich-options.test.mjs):askToolHistory()返回 2 题;answers[0]= 自定义文本;answers[1]= 两个选项值answers[1] === null;头部走summaryAnswered(answered = 1)null→ 头部走summaryUnanswered;卡片回答行显示answerDeclinedstartsWith匹配,不split(":"))null,且buildToolPresentation()回到输出 + input两个块(回归保护)toolName: "Read"/"plugin:other"isAskToolName()为假,其它工具行零变化pending === true,头部summaryPending,卡片回答行显示answerPending.asktool-history-item数 == 题目数;.asktool-history-option.is-selected数 == 答案值总数;展开体 不含"questions"裸 JSON;<pre>数与题目数不相等(确认没有走代码块路径)querySelector("button, input, [role=radio], [role=checkbox]")为空isLongText()为真;该题文本节点带is-long+tabindex="0"+role="group"+aria-label;文本内容完整(textContent.length等于原串长度,证明没有截断);padding-bottom: 18px,滚到底后最后一行不被渐隐遮住(可用Range.getBoundingClientRect().bottom与容器bottom - 18比较).asktool-history-option全部渲染(不设上限),且.asktool-history的scrollHeight > clientHeight、scrollWidth <= clientWidth(只有纵向滚动)scrollWidth <= clientWidth(overflow-wrap: anywhere生效).asktool-history仍无横向滚动;选项white-space计算值为normal(命中@media (max-width: 720px))4.9 规格与决策(行为可见变更,必须同步)
docs/spec/04-ux/11-asktool-question-card.md+docs/zh-CN/spec/04-ux/11-asktool-question-card.md:新增「转录中的问答卡」一节 —— 只读、逐题一行、选项默认全部可见、行头文案、pending/skipped/declined三种措辞、解析失败回退。docs/spec/08-meta/decisions-log.md(英文表格式,追加一行| D650 | … |)+docs/zh-CN/spec/08-meta/decisions-log.md(中文日期式小节)。packages/shared/src/changelog*.ts(9 个语言文件)在进入发布版本时追一行 UI 改进文案。4.10 超长内容展示(验收重点)
「友好卡片」最容易在长度上翻车:一屏一行工具行变成一屏一张卡。规则如下(全部为纯 CSS,不引入新的 JS 状态):
判定
.is-long的阈值:文本length > 2000或含 ≥ 8 个换行 —— 放在AskToolHistoryCard内部的一个纯函数里(isLongText(text)),便于单测;阈值收紧/放宽只改一处。配套说明(写进规格,避免后人“顺手优化”掉):
line-clamp截断:正文一律完整渲染,长度问题只由“限高 + 滚动”解决;截断会违反 R5。LargeTextPreview:它是分页组件(apps/desktop/src/components/LargeTextPreview.tsx,chat.largeTextPage/largeTextPagination系列文案),面向 Bash 的几十 KB 输出;问答分页会让“第 1/3 部分”出现在一个问题中间。<details>展开:保持“零提交控件 + 零状态”,长内容用滚动读完;<details>会带来键盘焦点顺序与只读语义的额外争议。ToolRow里现有的自动滚到底只作用于.tool-row-content(status === "running"),与本卡的手动滚动互不影响;历史卡一旦自动滚动会打断用户阅读。border-top)保证长列表里仍能分辨每题边界。padding-bottom:mask-image把底部 18px 渐变到透明,若滚动容器没有padding-bottom: 18px,滚到底时最后一行会停在渐隐带里(读到半行就没了)。这不是可选的“美化参数”,而是可事后回归的正确性条件 —— 见 R12 / T10,反例与正例并在asktool-row-demos/target-structure.html§7.2 对照。.is-long上出现:普通长度的问答(占绝大多数)不加遮罩,避免“短文本也被淡掉尾巴”的观感。.asktool-history与.is-long各有一层滚动,滚轮在.is-long上先滚它自己、到底后由overscroll-behavior: contain交给父层(不会滚到转录外层)。5. 验收标准
提问 · 2 个问题 · 已作答 2 / 2(状态不同则按 A-3 的三种变体)。aria-label与工具行其它行保持同构。pnpm --filter @pi-desktop/desktop typecheck、pnpm -r --if-present test、pnpm lint、pnpm --filter @pi-desktop/desktop test全绿;apps/desktop/test/asktool-transcript-card.test.mjs通过。de/en/es/fr/ko/pt-BR/tr/zh-CN/zh-TW无遗漏(漏 key 会在zh-CN之外显示英文 fallback)。.asktool-history高度不超过min(320px, 45vh),超出部分在卡内滚动(overscroll-behavior: contain)。padding-bottom≥ 渐隐带高度(18px),滚到底时最后一行不被遮罩压住。6. 影响面与风险
:、、混用、截断)startsWith前缀匹配 + 长度校验 + 任一题失败即整体回退;T4/T5 覆盖AskToolRichText的既有安全边界(不产生链接/图片/原始 HTML)Task拓扑行(variant === "topology")不进入特化分支,保持 host rowtoolCard槽位ToolRow里useSlotEntryForKey("toolCard", toolName)判断在 host row 之前,保持不动;asktool 不是插件工具,不受影响resolveAsk7. 非目标
AskToolCard.tsx(输入框停靠区)的交互与视觉。Task拓扑行 / 权限卡。8. 参考
asktool-row-demos/index.html(页签「A · 问答摘要卡」;「现状(对照)」页签即本文 §1)asktool-row-demos/target-structure.htmldocs/spec/04-ux/11-asktool-question-card.mdasktool-row-demos/index.html:四方案对比 demo(A/B/C/D + 现状对照;A 为最终选择)asktool-row-demos/target-structure.html:目标 DOM 结构 + 可复制 CSS + 状态矩阵 + 超长内容压力用例;§7.2 是「渐隐吃掉最后一行」的错误 / 正确对照(两卡内容相同、都已被脚本滚到最底,左卡clearance = -16px、右卡+1px),可作为实现时的可视判据。docs/design/asktool-transcript-card/。apps/desktop/src/components/AskToolCard.tsx、apps/desktop/src/lib/tool-presentation.ts、apps/desktop/src/components/ToolDetails.tsx、apps/desktop/src/features/chat/transcript/ToolRow.tsx