Skip to content

隐藏模式原理:为什么宽度计算如此重要

学完你能做什么

  • 理解 OpenCode 隐藏模式的工作原理
  • 知道为什么普通格式化工具在隐藏模式下会对齐错位
  • 掌握插件的宽度计算算法(三步走)
  • 了解 Bun.stringWidth 的作用

你现在的困境

你用 OpenCode 写代码,AI 生成了一个漂亮的表格:

markdown
| 字段 | 类型 | 说明 |
|--- | --- | ---|
| **name** | string | 用户名 |
| age | number | 年龄 |

在源码视图里看着挺整齐。但切到预览模式,表格却错位了:

| 字段     | 类型   | 说明   |
|--- | --- | ---|
| name | string | 用户名 |    ← 怎么短了?
| age      | number | 年龄   |

问题出在哪?隐藏模式

什么是隐藏模式

OpenCode 默认开启隐藏模式(Concealment Mode),它会在渲染时隐藏 Markdown 语法符号:

源码隐藏模式下显示
**粗体**粗体(4 个字符)
*斜体*斜体(4 个字符)
~~删除线~~删除线(6 个字符)
`代码`代码(4 个字符 + 背景色)

隐藏模式的好处

让你专注于内容本身,而不是被一堆 *** 符号干扰视线。

为什么普通格式化工具会出问题

普通的表格格式化工具计算宽度时,会把 **name** 当作 8 个字符:

** n a m e ** = 8 字符

但在隐藏模式下,用户看到的是 name,只有 4 个字符。

结果就是:格式化工具按 8 字符对齐,用户看到的却是 4 字符,表格自然就错位了。

核心思路:计算"显示宽度"而非"字符长度"

这个插件的核心思路是:计算用户实际看到的宽度,而不是源码的字符数

算法分三步:

第 1 步:保护代码块(代码块里的符号不剥离)
第 2 步:剥离 Markdown 符号(**、*、~~ 等)
第 3 步:用 Bun.stringWidth 计算最终宽度

跟我做:理解三步算法

第 1 步:保护代码块

为什么

行内代码(用反引号包裹)里的 Markdown 符号是"字面量",用户会看到 **bold** 这 8 个字符,而不是 bold 这 4 个字符。

所以在剥离 Markdown 符号之前,要先把代码块内容"藏起来"。

源码实现

typescript
// 第 1 步:提取并保护行内代码
const codeBlocks: string[] = []
let textWithPlaceholders = text.replace(/`(.+?)`/g, (match, content) => {
  codeBlocks.push(content)
  return `\x00CODE${codeBlocks.length - 1}\x00`
})

工作原理

输入处理后codeBlocks 数组
`**bold**`\x00CODE0\x00["**bold**"]
`a` and `b`\x00CODE0\x00 and \x00CODE1\x00["a", "b"]

\x00CODE0\x00 这种特殊占位符替换代码块,后面剥离 Markdown 符号时就不会误伤它们。

第 2 步:剥离 Markdown 符号

为什么

隐藏模式下,**粗体** 显示为 粗体*斜体* 显示为 斜体。计算宽度时要把这些符号去掉。

源码实现

typescript
// 第 2 步:剥离非代码部分的 Markdown 符号
let visualText = textWithPlaceholders
let previousText = ""

while (visualText !== previousText) {
  previousText = visualText
  visualText = visualText
    .replace(/\*\*\*(.+?)\*\*\*/g, "$1") // ***粗斜体*** → 文本
    .replace(/\*\*(.+?)\*\*/g, "$1")     // **粗体** → 粗体
    .replace(/\*(.+?)\*/g, "$1")         // *斜体* → 斜体
    .replace(/~~(.+?)~~/g, "$1")         // ~~删除线~~ → 删除线
    .replace(/!\[([^\]]*)\]\(([^)]+)\)/g, "$1")     // ![alt](url) → alt
    .replace(/\[([^\]]+)\]\(([^)]+)\)/g, "$1 ($2)") // [text](url) → text (url)
}

为什么用 while 循环?

处理嵌套语法。比如 ***粗斜体***

第 1 轮:***粗斜体*** → **粗斜体**(剥离最外层 ***)
第 2 轮:**粗斜体** → *粗斜体*(剥离 **)
第 3 轮:*粗斜体* → 粗斜体(剥离 *)
第 4 轮:粗斜体 = 粗斜体(无变化,退出循环)
图片和链接的处理
  • 图片 ![alt](url):OpenCode 只显示 alt 文本,所以替换为 alt
  • 链接 [text](url):显示为 text (url),保留 URL 信息

第 3 步:恢复代码块 + 计算宽度

为什么

代码块内容要放回去,然后用 Bun.stringWidth 计算最终的显示宽度。

源码实现

typescript
// 第 3 步:恢复代码块内容
visualText = visualText.replace(/\x00CODE(\d+)\x00/g, (match, index) => {
  return codeBlocks[parseInt(index)]
})

return Bun.stringWidth(visualText)

为什么用 Bun.stringWidth?

Bun.stringWidth 能正确计算:

字符类型示例字符数显示宽度
ASCIIabc33
中文你好24(每个占 2 格)
Emoji😀12(占 2 格)
零宽字符a\u200Bb32(零宽字符不占位)

普通的 text.length 只能数字符个数,无法处理这些特殊情况。

完整示例

假设单元格内容是:**`code`** and *text*

第 1 步:保护代码块

输入:**`code`** and *text*
输出:**\x00CODE0\x00** and *text*
codeBlocks = ["code"]

第 2 步:剥离 Markdown 符号

第 1 轮:**\x00CODE0\x00** and *text* → \x00CODE0\x00 and text
第 2 轮:无变化,退出

第 3 步:恢复代码块 + 计算宽度

恢复后:code and text
宽度:Bun.stringWidth("code and text") = 13

最终,插件按 13 字符的宽度来对齐这个单元格,而不是源码的 22 字符。

检查点

完成本课后,你应该能回答:

  • [ ] 隐藏模式会隐藏哪些符号?(答:***~~ 等 Markdown 语法符号)
  • [ ] 为什么要先保护代码块?(答:代码块里的符号是字面量,不应被剥离)
  • [ ] 为什么用 while 循环剥离符号?(答:处理嵌套语法,如 ***粗斜体***
  • [ ] Bun.stringWidthtext.length 好在哪?(答:能正确计算中文、Emoji、零宽字符的显示宽度)

踩坑提醒

常见误解

误解:代码块里的 ** 也会被剥离

事实:不会。插件会先用占位符保护代码块内容,剥离完其他部分的符号后再恢复。

所以 `**bold**` 的宽度是 8(**bold**),不是 4(bold)。

本课小结

步骤作用关键代码
保护代码块防止代码块内的符号被误剥离text.replace(/\(.+?)`/g, ...)`
剥离 Markdown计算隐藏模式下的实际显示内容多轮正则替换
计算宽度处理中文、Emoji 等特殊字符Bun.stringWidth()

下一课预告

下一课我们学习 表格规范

你会学到:

  • 什么样的表格能被格式化
  • 表格验证的 4 条规则
  • 如何避免"无效表格"错误

附录:源码参考

点击展开查看源码位置

更新时间:2026-01-26

功能文件路径行号
显示宽度计算入口index.ts151-159
代码块保护index.ts168-173
Markdown 符号剥离index.ts175-188
代码块恢复index.ts190-193
Bun.stringWidth 调用index.ts195

关键函数

  • calculateDisplayWidth():带缓存的宽度计算入口
  • getStringWidth():核心算法,剥离 Markdown 符号并计算显示宽度

关键常量

  • \x00CODE{n}\x00:代码块占位符格式