在对话里按下 Ctrl+F
一、起因:三分钟前的结论,翻不回去
我改一个插件,agent 在第五轮告诉我:配置写在 dsh.profile.bundles 里,重启后才会重新组装。这句话当时我扫过去了。半小时后配置不生效,我想回去看它到底怎么说的——往上滚,滚了十几屏,中间夹着四次工具调用、两段 diff、一堆日志。
浏览器 Ctrl+F 输入 dsh,只找到屏幕上的那几处。往上滚,又找到几处。再往上滚,找不到的变多了:已经在屏幕上被回收的节点,浏览器搜索根本看不见。
那天的结论很朴素:对话不是网页,用网页的搜索去搜它,注定漏。
于是有了这个插件:一个覆盖在会话上的搜索面板,Ctrl+F 打开,搜的是整段对话——包括折叠的步骤、未渲染的工具参数、以及你早就滚出屏幕的那些轮回。
二、它是什么

一个面板,没有侧边栏,没有设置页。输入框下面是四组合并出来的信息:
- 匹配数:
156 处匹配; - 合并数:
已合并 3 条同文; - 排除数:
已排除 4842 处(这些命中在工具调用内容里,默认不列,点一下就能展开); - 范围:
全部 / 你 / 助手三个来源筛选。
再往下是按轮次分组的结果列表,每条前面带来源标签(你 / 助手 / 思考 / 工具调用 / 工具输出 / 系统)和轮次(第 N 轮)。点一条跳过去,命中的词在页面上直接高亮。
三、为什么不用浏览器自带的 Ctrl+F
浏览器搜索有个前提:字必须在 DOM 里。现代会话界面恰好不满足这个前提:
- 折叠的步骤,不点开就不渲染;
- 工具调用的参数,只有展开的那一截在 DOM 里;
- 虚拟列表滚出屏幕的节点会被回收;
- 思考内容经常是默认收起的。
所以浏览器搜的从来不是“对话”,是“当前屏幕上的一段 HTML”。
插件的做法换了一层:不读 DOM,读会话的 store。store 里存的是每条消息的原始文本,跟它有没有被渲染、被折叠、被回收无关。折叠的步骤里那个词,照样能搜到——搜到之后再负责把它展开、滚过去、标出来。
四、数据从哪来
入口只有一处:对话 store 的 canonical order 数组,逐条 nodeStore.get(key) 取出节点。
拿到节点以后,把它拆成“一段一段可搜索的文本”:
1 | function collectParts(node) { |
角色是三层推导出来的,不是猜的:
| 判断层 | 取值 | 归到哪个来源标签 |
|---|---|---|
| node kind | user / steering | 你 |
| node kind | assistant-step | 助手 |
| node kind | system-prompt / compaction / turn-error | 系统 |
| block kind | reasoning | 思考 |
| block kind | tool-call / tool-result | 工具调用 / 工具输出 |
| raw key | args / toolCall / toolResult | 工具调用 |
拆完做两个投影,这是整个插件最核心的一个决定:
| 投影 | 包含什么 | 用在哪 |
|---|---|---|
full | 全部片段,含 dsh-ui 卡片 JSON 的可读前缀 | 全量模式、计算“已排除” |
prose | 只有「你」和「助手」的正文 | 默认模式 |
默认搜 prose。因为一次对话里工具输出能占八成,如果全塞进结果列表,搜“配置”会得到一屏 JSON 片段,真正有用的那句人话沉在下面。
五、一次搜索的五段路
主链路是五段,细节都在旁边:
- 取片段:
order里每条节点拆成{ text, role }; - 建匹配器:
buildMatcher返回一个字面量匹配器或正则匹配器; - 跑两遍:一遍全量求总数,一遍正文求行号,差值是“已排除 N 处”;
- 合并:
dedupeHits按规范化后的片段分桶,同文只留最靠前一条; - 上屏:按轮次分组,带来源筛选,点一条跳转并高亮。
“跑两遍”听上去浪费,但它换来一个诚实:面板上那个“已排除 4842 处”是真的算出来的,不是估的。用户看到 156 条结果时会怀疑“是不是漏了”,这句话就是答案。
六、四个真实的坑
这个插件的 NOTES.md 有 41 KB,大部分是 bug 记录。挑四个最值得说的。
① 搜页面上的原文,却提示“没有匹配”。 这是最难受的一类:我明明看见那句话,复制进去,零结果。原因是索引侧存的是节点源码——转义的 \" 和渲染后的 " 不是同一个字符;弯引号 “” 与直引号 "" 也不是;全角空格和半角空格更不是。修复是两边都加一层归一化:deescape() 还原转义,foldQuotes() 把弯引号折成直引号、全角空格折成半角、破折号折成 -。关键是 foldQuotes() 必须长度不变,否则偏移量全错、高亮框到别处去。修复前 0 处,修复后 52 处。
② 工具内容里的命中,看起来像重复结果。 同一个词在正文出现一次、在工具参数里出现一次,列表里就是两条几乎一样的行。改成:正文有命中时默认只列正文,列表上方给一个按钮「另有 N 条在工具调用内容里 · 显示」;只在工具里出现的词照常列出——不能因为怕吵就把结果吞了。
③ 卡片标题搜不到。 dsh-ui 卡片的标题在数据里是 JSON 源码({"title":"..."}),不是一段可读文本。修法是 proseProjection() 从 JSON 里抽字符串,抽的时候过两道筛:长度 ≥ 6、且含中日韩字符或空格,再排掉 gap、items、callout 这类纯键名。同时用 looksLikeSource() 判“这段像不像源码”——强信号是 +Q+、StaticText、data-chat-、=>,出现两个弱信号({"、":"、\"、function ()也算。像源码的收进开关后面,不再污染默认列表。
④ 点不同结果,跳到同一个地方。 元凶是“改投兄弟节点”:跳转时如果找不到精确的节点 key,就往兄弟节点上找,结果好几条都落到同一个位置。修法是允许失败——找不到就按文本内容兜底定位;命中的是一条折叠步骤时,直接标注「已跳到所在回合」,不假装精确。
七、搜索语法只有三条
| 开关 | 作用 | 注意 |
|---|---|---|
Aa | 区分大小写 | 默认不区分 |
| 全词 | 只匹配完整单词 | 仅对纯 ASCII 查询生效 |
| 正则 | 按正则解释查询 | 写错就地报错,不静默 |
“全词只对纯 ASCII 生效”这条是刻意的。中文没有词边界,给「配置」加 \b 等于让它永远匹配不到。所以边界只在查询是纯 ASCII 词时才加,正则模式下包的是 (?<![A-Za-z0-9_]) / (?![A-Za-z0-9_])。
正则还有一个坑:* 和 ^ 这类零宽匹配会让 lastIndex 原地踏步,浏览器直接卡死。加一行“零宽就手动前进一格”就解决了。
八、合并、分组、已排除
列表默认按轮次分组,同一条消息里命中多次只列一次,标「同文 ×N」。
合并这里出过一个很隐蔽的 bug:合并行的轮次继承了更晚的那一条,于是列表里出现了「第 1 / 第 2 / 第 4 / 第 1 轮」这种倒序。修法是分桶时顺便记住“这段文字首次出现的轮次”,合并后用它,而不是用最后一条。
九、高亮与跳转
高亮没用 <mark>。往 React 管的 DOM 里插节点,轻则被下一次渲染抹掉,重则整块崩掉。这里用的是 CSS Custom Highlight API:
1 | CSS.highlights.set("dsh-session-find", current); // 当前这一条 |
配两条 ::highlight() 规则就行,DOM 一个字节都不动,一次性最多 240 个 range——再多就只标当前这条,剩下的靠列表点。
跳转比高亮难。一个命中可能在:
- 一个默认折叠的回合里;
- 一个默认折叠的步骤里;
- 一个已经滚出可视区的虚拟列表节点里。
所以跳转是四步:展开目标回合与步骤 → 二分查找轮次锚点 → 单向 scrollTop 收敛到节点 key → key 对不上就按文本内容兜底。所谓“单向收敛”,是滚动位置只往命中的方向推,不来回弹——双向调整会跟虚拟列表的回收打架,最后停在半路。
十、一个数字
README 里留了一份实测:同一个会话,Ctrl+F 输入 dsh。
- 面板:198 处匹配,已合并 3 条同文,已排除 6347 处;
- 浏览器自带:屏幕上能找到几十处,滚两屏之后开始漏。
点一下 Aa(区分大小写)变成 156 处。差的这 42 处,是 DSH_ 这类全大写写法——大小写敏感把它们排掉了。对“找个精确的变量名”来说,这是省事,不是丢东西。
这也是“搜屏幕”和“搜全量”的区别:不是精度不同,是范围不同。
十一、装法与重启
1 | # npm |
装完必须完全退出 DSH 再启动,不是刷新页面。客户端 bundle 是宿主启动时组装的,只刷页面加载不到新插件。装好之后改 client.js 只需要刷新——那时候插件已经进了 bundle 清单。
客户端本身是手写的 ModuleLoader,没有构建步骤,模块 id 必须等于包名。发布前我用一句语法自检:
1 | node -e "new (require('vm').Script)(require('fs').readFileSync('client.js','utf8'))" |
十二、边界与 FAQ
| 问题 | 答案 |
|---|---|
| 能搜很早以前的会话吗 | 只搜当前已加载的历史,更早的要先让宿主加载 |
| 能跨会话搜吗 | 不能,设计上就不跨 |
| 结果为什么停在 300 条 | 列表上限,面板会写清楚还剩多少 |
| 中文能开全词吗 | 能开,但等于什么都搜不到,所以只对 ASCII 生效 |
| 折叠步骤里的命中怎么只跳回合 | 那是 jumpApproximate,精确位置拿不到时如实标注 |
| Ctrl+F 没反应 | 没完全重启 DSH;或者另一个插件抢了同一个槽位 |
桌面端 F3 不灵 | 被宿主吃掉,1.4.0 起改用 Ctrl+G |
| 之前的设置丢了 | 1.4.0 起 localStorage 前缀从 @local/dsh-session-find: 改成 dsh-session-find: |
还有一条我自己踩过的:导出功能一开始想用 File System Access API,第一次调用弹窗正常,第二次开始静默降级成普通下载,用户体验是“点了没反应,但文件在下载目录里”。后来换成 Blob + <a download>,行为就稳了。有些新 API 的“可用”是概率性的。
十三、写在最后
这个插件很小:一个面板,四个开关,五段管线。但它解决的是我每天都在遇到的问题——对话越来越长,而我越来越记不住第几轮说过什么。
写它的过程也让我第一次认真看了一遍自己平时怎么用浏览器搜索:我以为我在搜“内容”,其实我只在搜“此刻渲染出来的那一屏”。
如果它对你也有用,装上试试。有问题开 issue,我一般当天会看。

