Warp 块列表图片渲染的 CommonMark Title 解析、悬停工具提示与 Alt 回退:从 markdown_parser 到 Block-List 渲染器的完整实现解析
Warp 块列表图片渲染的 CommonMark Title 解析、悬停工具提示与 Alt 回退:从 markdown_parser 到 Block-List 渲染器的完整实现解析
本篇指南围绕 Warp 开源仓库中 GH849 技术规范展开:AI 块列表(block-list)在 BlocklistMarkdownImages 特性开关下渲染 Markdown 图片时,原先只支持 alt 这一最简形式;本功能为共享解析模型与渲染链路补齐了 CommonMark 图片 Title("…"、'…'、(…) 三种分隔符),让 Title 成为成功渲染图片的悬停工具提示(tooltip),并让作者编写的 Alt 文本在图片加载失败时成为可见回退文本。读完本文,你将掌握这条链路从 crates/markdown_parser 解析层、crates/editor 缓冲往返/HTML 序列化层,到 app/src/ai 块列表渲染层的完整改造逻辑、行为不变量、测试策略与已知风险。
背景:块列表图片渲染的两个缺口
AI 块列表的 Markdown 图片渲染早已通过 FeatureFlag::BlocklistMarkdownImages 上线,整体架构沿用 specs/zachlloyd/ 目录下 inline-markdown-images-in-blocklist 系列的既有设计:图片先经 markdown_parser 解析,承载于 AgentOutputImage,再由 app/src/ai/blocklist/block/view_impl/common.rs 中的一组 section 渲染器绘制。但原有管线存在两个信息缺口(tech.md):
- Title 形式无法解析:任何使用 CommonMark 标准 Title 形式的图片——
alt、alt、alt)——都会解析失败并退化为原始 Markdown 文本,一个完全符合标准的图片却无法渲染。 - Alt 没有用户可见落点:Alt 文本虽然被解析模型捕获,却从未在任何用户可见的位置呈现。图片加载失败(文件缺失、格式不支持、资源加载出错)时用户看到的是原始 Markdown 而不是作者提供的 Alt 回退;成功渲染的图片也没有任何悬停提示。
产品规范(product.md)将此功能定位为既有块列表图片特性的纯增量扩展,不改变无 Title、无 Alt 时的默认路径行为。
核心设计决策:共享模型加字段,链接解析不动
技术规范中最关键的设计约束是:保持链接(link)解析行为完全不变,只为图片开辟独立的 Title 解析路径。因为 parse_link_target 同时被 parse_link 调用,如果直接扩展这个共享辅助函数,会顺带把「链接 Title」解析引入链接路径——而链接 Title(text)被明确列为产品非目标(product invariant 13)。
因此最终的实现方案可以概括为一条主线:
- 在共享图片模型
FormattedImage上新增pub title: Option<String>; - 让
markdown_parser拥有整套图片专用的parse_image_target/parse_image_destination/parse_image_title辅助函数; - 将
title字段贯通FormattedImage、编辑器缓冲BufferBlockItem::Image、AI 层的AgentOutputImage三个数据模型及各自的所有构造/解构点; - 在块列表渲染器中,把 Title 接进 Warp 既有 tooltip 原语,把 Alt 接进加载失败回退分支。
第一层:共享解析模型 FormattedImage 增加 title 字段
模型的落点位于 crates/markdown_parser/src/lib.rs:
#[derive(Debug, Clone, Eq, PartialEq)]
pub struct FormattedImage {
pub alt_text: String,
pub source: String,
/// Optional CommonMark image title, e.g. the `title` in `alt`.
/// Empty titles are normalized to `None` by the parser.
pub title: Option<String>,
}
字段语义约定为:None 表示「没有 Title」;真正为空的 Title(""、''、())在解析阶段统一归一化为 None,这样所有下游消费者只需一次 Option::is_some 判断即可决定是否渲染工具提示或输出 HTML title="…"。
伴随模型变更,lib.rs 中 FormattedTextLine::Image 的行为同步调整但保持向后兼容:
raw_text()仍只输出alt_text\n——Title 不进入纯文本投影;num_lines()仍恒为 1;compute_formatted_text_delta无需特判,因为FormattedImage已派生Eq。
第二层:图片专用的 Title 解析路径
解析层实现位于 crates/markdown_parser/src/markdown_parser.rs,包含一组新函数:
parse_image:整行图片解析入口,允许前置空白,图片必须独占一行(或以空白分隔的多个图片),行尾后结束;parse_image_run_line(公开):解析「整行只含一个或多个 Markdown 图片」的纯图片行,混合内容行(如text img)返回None;parse_image_prefix/parse_image_prefix_internal:消费![、Alt 文本、],然后委托parse_image_target,最后执行title.filter(|t| !t.is_empty())归一化;parse_image_target:图片专用的(source [title])目标解析;parse_image_destination:图片目标地址解析,支持尖括号定界(<…>)与带括号配平的非定界形式;parse_image_title_leading_whitespace:destination 与 Title 之间的分隔空白,必须至少一个空格或 Tab;parse_image_title:三种 CommonMark Title 分隔符的解析。
parse_image_target 的组合逻辑
fn parse_image_target<'a, E>(input: &'a str) -> IResult<&'a str, (String, Option<String>), E> {
let (input, _) = tag("(")(input)?;
let (input, _) = space0(input)?;
let (input, source) = parse_image_destination(input)?;
let (input, saw_whitespace) = parse_image_title_leading_whitespace(input)?;
let (input, title) = if saw_whitespace {
match parse_image_title::<E>(input) {
Ok((rest, title)) => {
let (rest, _) = space0(rest)?;
(rest, Some(title))
}
Err(_) => (input, None),
}
} else {
(input, None)
};
let (input, _) = tag(")")(input)?;
Ok((input, (source, title)))
}
几个值得注意的实现细节:
- destination 与 Title 之间必须存在至少一个空格/Tab(
parse_image_title_leading_whitespace用space1)。这是本特性的有意收窄:destination 与 Title 之间出现行尾换行时不支持,整个构造回退为纯文本(product invariant 2)。 - 只有看到空白后才尝试解析 Title;Title 解析失败(如未闭合)时按
None继续,最终由外层关闭)——若)也缺失,则整行回退为纯文本,与今天畸形图片alt,之后由调用方确认行尾或 EOF。
parse_image_destination 的边界处理
非尖括号定界的 destination 在首个空白处结束;遇到未配平的 ) 即终止(此时由 target 层接管关闭括号);(/) 支持括号深度配平;控制字符与行尾在 destination 内视为硬错误;转义序列(parse_escape)始终合法并解码为字面字符。
parse_image_title 的三种分隔符与转义规则
let closing = match opening {
'"' => '"',
'\'' => '\'',
'(' => ')',
_ => return Err(...),
};
- 接受
"…"、'…'、(…)三种定界形式,要求匹配的关闭定界符; - 遵循 CommonMark 转义规则:
\"、\'、\)解码为对应字面字符; - Title 跨行未闭合直接整体回退:
parse_line_ending在关闭定界符出现前命中即报错,从而保证产品 invariant 5(畸形图片整体回退纯文本)不被打破; - 只有「真正为空」的 Title 才归一化为
None;非空 Title 内部的首尾空白按字面保留(" "视为非空,不归一化,invariant 4)。
parse_link_target 保持不变
链接专用解析 parse_link_target 未被触碰,继续只拥有链接 destination 语法。这是本功能与「链接 Title」解耦的关键:图片路径完全走独立的 parse_image_* 家族。
第三层:编辑器缓冲往返与 HTML 序列化
FormattedImage 的 title 字段需要贯通编辑器 crate。关键落点:
- crates/editor/src/content/text.rs 中的
BufferBlockItem::Image { alt_text, source, title }:as_markdown与as_rich_format_text在 Title 存在时以双引号形式重序列化,to_formatted_text_line原样转发字段,BufferText::BlockItem的 Debug 输出在 Title 存在时包含它(img_{alt}_{source}_{title}),以便缓冲调试输出区分带 Title 与不带 Title 的图片; - text.rs 中的共享序列化辅助
format_image_markdown:
pub fn format_image_markdown(alt_text: &str, source: &str, title: Option<&str>) -> String {
match title.filter(|t| !t.is_empty()) {
Some(title) => {
let escaped = title.replace('\\', "\\\\").replace('"', "\\\"");
format!("{alt_text}")
}
None => format!("{alt_text}"),
}
}
这是全仓库 Title 重序列化的规范化出口:以双引号为统一定界符(与 CommonMark 规范输出一致),并转义字面 \ 与 ",保证 Markdown 往返(markdown → buffer → markdown)不丢失 Title 内容(invariant 8/9)。
- crates/editor/src/content/markdown.rs 的 HTML 导出:
<img>标签的属性数组默认只含src与alt,仅当解析出的 Title 非空时追加title属性,且 Title 值继续走既有 HTML 序列化/属性路径,使"、<、>等字符被转义而非原样拼接(invariant 9)。
编辑器对行内图片自身的渲染(core.rs、edit.rs、render/element/image.rs、render/model/ 下各解构点)不在本特性范围内,只需接受新字段即可(.. 模式或显式未使用绑定),依赖 Rust 对具名结构体字段的穷尽解构检查在编译期兜底。
第四层:AI 块列表层的字段贯通与解析收敛
在 app/src/ai/agent/mod.rs 中,AgentOutputImage 同样获得 title 字段:
pub struct AgentOutputImage {
pub alt_text: String,
pub source: String,
/// Optional CommonMark image title preserved from `alt`.
/// Empty titles are normalized to `None` by the shared markdown parser.
pub title: Option<String>,
pub markdown_source: String,
pub layout: AgentOutputImageLayout,
}
app/src/ai/agent/util.rs 中的三个辅助函数形成闭环:
image_section(image, layout)构造AIAgentTextSection::Image,把image.title直接转发进AgentOutputImage;markdown_source_for_image委托format_image_markdown(&image.alt_text, &image.source, image.title.as_deref()),使markdown_source在带 Title 时序列化为alt的双引号规范形式;flush_plain_text_sections不再自行维护第二套图片解析器,而是统一委托共享的parse_image_run_line,再根据返回的图片数量决定 Block(独占行)还是 Inline(行内多图)布局——所有图片行解析逻辑收敛到markdown_parser一处。
此外 app/src/ai/agent_sdk/driver/output.rs 的图片 section 管线需与新字段保持对齐(字段等价性)。
第五层:渲染——Title 工具提示与 Alt 失败回退
渲染层是用户可感知的部分,集中在 app/src/ai/blocklist/block/view_impl/common.rs。
工具提示的封装方式
render_visual_markdown_block(inline 图片与 Mermaid 的共享渲染辅助)在 VisualMarkdownBlockOptions 中新增两个可选字段:tooltip: Option<String> 与 tooltip_mouse_state: Option<MouseStateHandle>。当 tooltip 为 Some(non_empty) 时,用 appearance.ui_builder().tool_tip_on_element(tooltip, mouse_state, content, ParentAnchor::TopMiddle, ChildAnchor::BottomMiddle, vec2f(0., -2.)) 包裹渲染内容——-2px 的负 Y 偏移让工具提示底边与图片顶边保持一丝缝隙。Mermaid 无 CommonMark Title,调用点传 tooltip: None,行为完全不变。
MouseStateHandle 跨帧持久化——工具提示能显示的物理前提
tool_tip_on_element 内部把元素包进 Hoverable::new(mouse_state_handle, …),而 Hoverable 只在 state.is_hovered() 返回 true 时渲染工具提示。如果每一帧都传入全新的 MouseStateHandle::default(),悬停状态每帧被重置,is_hovered() 永远返回 false,工具提示永远不出现——这正是此前图片工具提示失效的根因。
因此实现要求:为每个 AIAgentTextSection::Image section 在 AIBlockStateHandles 上预分配一个稳定的 MouseStateHandle(新字段 image_section_tooltip_handles: Vec<MouseStateHandle>,位于 app/src/ai/blocklist/block.rs,与 normal_response_code_snippet_buttons、table_section_handles 并列),并在 block.rs 的 per-section fold 中填充。渲染时该 slice 通过 TextSectionsProps 随既有 code/table 句柄 slice 一并下发;渲染器以新的 starting_image_section_index 计数器按源顺序推进——分组渲染按 image_group.images.len() 递增,单图回退路径每次 +1。只有确实没有稳定句柄的调用点(如可折叠 reasoning 路径)才回退到 MouseStateHandle::default()。
加载失败回退:Alt 优先,Markdown 兜底
fn image_fallback_text(image: &AgentOutputImage) -> String {
if image.alt_text.is_empty() {
image.markdown_source.clone()
} else {
image.alt_text.clone()
}
}
render_visual_markdown_fallback 保持「哑」辅助定位,只负责把传入文本渲染成 Text 元素;Alt 与 Markdown 源的选择决策由各 section 渲染器恰好计算一次后传入(inline 图片行、分组块图片、单图回退三条路径各自独立计算)。这对应 product invariant 7:Alt 非空时回退 Alt,Alt 为空时才回退原始 Markdown(保持既有行为),且失败项独立回退、邻居图片继续渲染。
复制与导出:Title 不丢失、统一双引号规范
- 右键复制:成功渲染图片的右键复制继续把
AgentOutputImage::markdown_source原样写入剪贴板;由于 section 载荷已携带 Title 感知的 Markdown 序列化,复制内容自动保留 alt/source/title,且统一为双引号规范形式alt(invariant 8)。 - HTML 导出:
<img>在 Title 存在时输出alt="…" title="…",否则只输出alt="…",特殊字符经属性转义路径处理(invariant 9)。 - 纯文本:Title 不作为可见字幕、徽标或兄弟文本出现在任何布局中(inline 行、块级分组或未来布局),悬停工具提示是本特性中 Title 唯一的用户可见面(invariant 10)。
特性门控与行为矩阵
Title 后缀由共享 markdown_parser 无条件接受;块列表的工具提示与 Alt 回退行为继续由既有 FeatureFlag::BlocklistMarkdownImages 门控,不新增特性开关(tech.md 第 7 节)。当开关关闭时,图片 section 仍回退到 markdown_source,此时文本包含 Title 后缀(invariant 12)。因为 Title 解析本身不设门控,未来任何消费 FormattedImage 的非块列表调用方都能免费获得 Title 字段。
产品规范的 13 条不变量与实现/测试的对应关系可归纳如下:
| 不变量 | 行为要点 | 实现/测试落点 |
|---|---|---|
| 1 | 无 Title 形式零回归 | 解析器回归测试 alt |
| 2 | 解析 alt;destination 与 Title 间至少一个空格/Tab;跨行分隔不支持 |
parse_image_target + 解析器测试 |
| 3 | 支持 "…"、'…'、(…) 三种分隔符 |
markdown_parser_tests.rs 三组用例 |
| 4 | 空 Title 等价无 Title;转义匹配定界符;未闭合/跨行整体回退 | parse_image_title + 测试 |
| 5 | 畸形图片(无法解析/缺 ))整体回退纯文本 |
parse_image_target/parse_image_destination 错误分支 |
| 6 | 成功渲染 + 非空 Title → 悬停工具提示;空/缺 Title → 无工具提示 | common_tests.rs 渲染器测试 |
| 7 | 加载失败:Alt 非空回退 Alt,否则回退 Markdown | image_fallback_text + common_tests.rs |
| 8 | 右键复制保留 alt/source/title,统一双引号 | format_image_markdown 规范化 |
| 9 | HTML 导出条件性输出 title="…",特殊字符转义 |
markdown.rs |
| 10 | Title 不渲染为可见字幕/徽标 | 渲染器仅 tooltip 一处暴露 |
| 11 | 流式与恢复的块行为一致 | 集成测试 test_restored_ai_block_renders_mermaid_and_local_images 扩展 |
| 12 | BlocklistMarkdownImages 门控不变;关闭时含 Title 后缀回退 |
common.rs 渲染入口门控检查 |
| 13 | 相邻构造不受影响(普通括号文本、带 Title 链接、代码块内图片语法) | 链接回归测试 + 手动验证 |
测试与验证策略
单元测试(tech.md 第 5 节):
markdown_parser测试覆盖三种定界符、空 Title 归一化、转义关闭定界符、未闭合 Title 回退、Title 内嵌行尾回退,以及alt无 Title 回归守卫;- 编辑器
text_tests.rs/edit_tests.rs验证alt在 markdown →BufferBlockItem::Image→ markdown 往返中存活(invariant 8/9); - HTML 序列化测试确认带 Title 时输出
title="…"、None/空时省略; app/src/ai/agent/util_tests.rs覆盖带 Title 的独立块级图片、带 Title 的行内多图 run、空 Title 归一化、未闭合 Title 回退;common_tests.rs覆盖:成功渲染携带给定工具提示字符串、None/空 Title 不挂工具提示、失败渲染 Alt 非空回退 Alt、Alt 为空回退 Markdown、恢复块与新鲜解析块采用同一决策。
集成测试:扩展 crates/integration/src/test/agent_mode.rs 的恢复 AI 块测试(或同级兄弟用例),注入含「带 Title 行内图 + 带 Title 块级图 + 无 Title 图」的合成响应,通过既有截图/观察路径断言:带 Title 图正常渲染、无 Title 图渲染不变、文件被删除的块级图在 Alt 非空时回退到 Alt 字符串而非原始 Markdown。
手动验证清单(可直接照做):
- 构造 dogfood AI 响应包含
A dog,确认图片渲染且悬停显示 "Rex, my dog" 工具提示(invariant 6); - 依次触发
"…"、'…'、(…)三种定界形式,确认工具提示文本与作者书写的 Title 一致(invariant 3); - 触发空 Title(
""),确认不出现工具提示(invariant 4/6); - 将图片指向缺失文件,确认行内回退在 Alt 非空时显示 Alt、Alt 为空时显示原始 Markdown(invariant 7);
- 右键复制带 Title 图片并粘贴到纯文本编辑器,确认剪贴板保留 alt/source/title 且定界符归一为双引号(invariant 8);
- 经既有导出入口将块导出为 HTML,确认
<img>在带 Title 时同时含alt与title属性、无 Title 时仅含alt、Title 属性值中的特殊字符被转义(invariant 9); - 从历史记录恢复同一 AI 会话,确认工具提示与回退行为与初次渲染一致(invariant 11);
- 本地关闭
BlocklistMarkdownImages,确认带 Title 图片以原始 Markdown(含 Title 后缀)渲染(invariant 12); - 编写
something ("not a title")普通文本、带 Title 的链接text、以及包含alt的围栏代码块,确认三者均不触发图片渲染或工具提示(invariant 13)。
风险与缓解
| 风险 | 说明 | 缓解 |
|---|---|---|
| 图片解析改动意外波及链接解析 | parse_link_target 同时被 parse_link 调用,直接扩展共享辅助会静默给链接路径加上 Title 解析(非目标,invariant 13) |
parse_link_target 保持不动,仅图片路径走新的 parse_image_* 家族;增加链接回归测试断言带 Title 的链接渲染行为不变 |
| 字段等价性改动波及编辑器与 Agent SDK | FormattedImage 与 BufferBlockItem::Image 在多个文件中被解构,漏掉调用点虽只产生编译错误而非行为 bug,但爆炸半径大 |
使用具名结构体字段(而非元组变体),依赖 Rust 穷尽解构检查在编译期兜底;PR 描述中附上「Relevant code」全文件清单便于评审逐项核对 |
| 无 Title 默认路径的工具提示回归 | 若新包装在 tooltip 为 None 时仍挂载,悬停无 Title 图片可能显示空工具提示或抢占焦点 |
调用点分支判断——仅当 tooltip: Some(non_empty_string) 时才用工具提示辅助包裹;由「成功渲染 + None/空 Title 不挂工具提示」单元测试覆盖 |
后续工作(明确超出本次范围)
- 链接 Title(
text):保持现状,图片专用 target 解析器为后续按需实现留下局部空间; - 引用式图片(
![alt][ref]):继续不支持; - Lightbox 内展示 Title 与 Alt:共享全屏 lightbox 遮罩中的呈现可作为后续增强。
本功能从解析模型、解析器、缓冲往返、HTML 序列化、AI 块列表 section 模型到渲染层的完整实现均可在当前仓库源码中直接验证,参考入口依次为 crates/markdown_parser/src/lib.rs、crates/markdown_parser/src/markdown_parser.rs、crates/editor/src/content/text.rs、crates/editor/src/content/markdown.rs、app/src/ai/agent/mod.rs、app/src/ai/agent/util.rs、app/src/ai/blocklist/block/view_impl/common.rs 与 app/src/ai/blocklist/block.rs。