@yangzhe1991/dsh-code-review 0.1.5 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -11,9 +11,11 @@
11
11
 
12
12
  The conversation itself stays untouched: when a git diff shows up, a small **"open in new tab"** button appears on the block (a markdown diff code block, or the bash tool row that produced the diff — even while collapsed). One click opens the review page.
13
13
 
14
+ When the conversation mentions a **local `.diff` / `.patch` file path**, the path automatically becomes a **view link button** — a path inside backticks gets an inline pill right after it, a bare-text path gets a right-aligned button row under the message. One click opens that file's code review page (the browser cannot read local files, so the plugin host half reads them — absolute paths only, `.diff`/`.patch` suffix only, ≤ 5 MB).
15
+
14
16
  ## Compatibility
15
17
 
16
- - **dsh ≥ 0.1.2-alpha.4** — fully supported since **0.1.5**. The web UI restructure (chat snapshot moved from `useSession((s) => s.chat)` to the session-standard `useChat` hook; markdown code-block DOM wrapped in a `bannerWrap` layer) is handled on both detection paths: markdown diff blocks and collapsed bash tool rows.
18
+ - **dsh ≥ 0.1.2-alpha.4** — fully supported since **0.1.5**. The web UI restructure (chat snapshot moved from `useSession((s) => s.chat)` to the session-standard `useChat` hook; markdown code-block DOM wrapped in a `bannerWrap` layer) is handled on both detection paths: markdown diff blocks and collapsed bash tool rows. Verified against **dsh 0.1.3-alpha.2** since **0.2.1**.
17
19
  - **dsh 0.1.0-rc.x** — still supported via the legacy snapshot path.
18
20
 
19
21
  ## Features
@@ -28,6 +30,7 @@ The conversation itself stays untouched: when a git diff shows up, a small **"op
28
30
  - 🎨 **Theme-matched** — the page inherits the DSH theme colors at open time, so light/dark mode stays consistent.
29
31
  - 📜 **Full-page scrolling** — no height caps; thousands of lines scroll naturally, and long lines wrap.
30
32
  - 💬 **Inline comments** — hover any line number and click to add a comment right on that line (like GitHub reviews). Comments accumulate with a per-line count badge; when done, submit them all at once — as **Looks Good To Me ✓** or **plain comments** — and they are written into the DSH composer as `file:line — comment` lines, ready to send to the agent.
33
+ - 🔗 **One-click local diff paths** — absolute `…/xxx.diff` / `…/xxx.patch` paths mentioned in the conversation become link buttons: backtick-wrapped paths get an inline pill right after them, bare-text paths get a right-aligned button row under the message, and paths listed in bash tool output join the tool row's button row. Clicking opens that file's code review page; on read failure (missing / not a diff / over 5 MB) the button briefly turns red.
31
34
  - 🌐 **Bilingual UI** — follows the DSH interface language (official locale service): a Chinese UI gets Chinese buttons and review page, everything else gets English.
32
35
 
33
36
  ## Screenshots
@@ -42,9 +45,15 @@ The conversation itself stays untouched: when a git diff shows up, a small **"op
42
45
 
43
46
  1. **Markdown code blocks** in assistant messages — a ```` ```diff ```` / ```` ```patch ```` fence, or any code block whose content looks like a git diff (e.g. pasted inside a `text` fence). The block is **collapsed by default** (just the title row + buttons), with an "expand/collapse" toggle if you want to read the raw text inline.
44
47
  2. **Bash tool rows** — when the agent runs `git diff` in a bash tool, the button is added to the tool row (visible even while the row is collapsed; detected from the conversation data, since a collapsed row has no output in the DOM). The terminal card in the details panel gets one too.
48
+ 3. **Local diff paths (since 0.2.0)** — absolute `.diff`/`.patch` paths in assistant or user messages:
49
+ - backtick-wrapped paths (e.g. `` `/Users/me/proj/patch.diff` ``) get an **inline pill button** right after the path;
50
+ - bare-text paths get a **right-aligned button row under the message** (one pill per path); paths inside reasoning blocks and code blocks are not captured (collapsed by default / have their own button mechanism);
51
+ - paths listed in bash tool output join the **tool row's button row**.
45
52
 
46
53
  Streaming output is handled gracefully: the button only appears once the content has been stable for ~1 second, so half-streamed diffs never produce a broken page.
47
54
 
55
+ When a path button is clicked, the plugin's host half (a route registered on `webServer`) reads the local file (absolute paths only, `~` expansion, `.diff`/`.patch` suffix, ≤ 5 MB, no CORS), and the browser half renders it into the standalone review page; on read failure the button briefly turns red. **The route registration requires a `dsh web` restart** (the host half registers it at startup); browser-half changes only need a hard refresh.
56
+
48
57
  ## Install (30 seconds)
49
58
 
50
59
  ```sh
@@ -63,6 +72,8 @@ dsh plugin --profile web add link:/path/to/@yangzhe1991/dsh-code-review
63
72
 
64
73
  The plugin registers a root-scope `shell.overlay` seat that renders nothing and watches the whole document with a `MutationObserver`. For every `.md-code-block` and `[data-terminal]` element it runs a stability-debounced check; candidates get a small button appended to the block's banner/header (never replacing official nodes, so React's reconciliation stays safe). Collapsed bash tool rows are handled from the data layer: a session-scope seat subscribes to the conversation snapshot and records settled bash results that look like a diff.
65
74
 
75
+ Local path chips are split across the two layers: message rows (assistant/user) are probed by the same scanner — backtick-wrapped paths get an inline button inserted right after the `code` element, bare-text paths get a button row appended to the row; paths in bash tool results go through the data layer (same pipeline as the diff buttons). Text nodes are only read, never split (splitting React-managed text nodes would break streaming rendering), so bare-text paths get row-level buttons instead of in-place edits. On click, the browser half first opens a placeholder tab synchronously (beats popup blockers), then fetches `GET /dsh-code-review/diff?path=…` from the host route and navigates the tab to the standalone review page.
76
+
66
77
  Clicking the button synchronously builds a self-contained HTML page (inline styles + inherited theme variables + the two-column content, all HTML-escaped) and opens it via a **Blob URL** — no server route needed, and the synchronous `window.open` inside the click gesture is not popup-blocked. The parser is a pure module (`src/client/diff-parse.ts`, unit-tested in `test/parse.test.mjs`) and supports: multi-file diffs, hunks with line-number tracking, delete/add pairing into changed rows (uneven counts degrade to single-side rows), `` markers, new/deleted/renamed/binary files, bare patches without a `diff --git` header, and commit-message prefixes (`git show` output).
67
78
 
68
79
  ## Development
@@ -71,6 +82,8 @@ Clicking the button synchronously builds a self-contained HTML page (inline styl
71
82
  npm install
72
83
  npm run build # esbuild → lib/index.js (host half) + lib/client.js (browser half)
73
84
  node test/parse.test.mjs # parser + standalone HTML unit tests
85
+ node test/path-detect.test.mjs # local path-detection regex unit tests
86
+ node test/node-route.test.mjs # host-half route (read/validation/error codes) tests
74
87
  node test/client-smoke.test.mjs # bundle smoke test (mock __ModuleLoader__)
75
88
  ```
76
89
 
package/README.zh.md CHANGED
@@ -11,6 +11,13 @@
11
11
 
12
12
  聊天窗口本身保持原样:只要出现 git diff,对应块(markdown diff 代码块、或产生 diff 的 bash 工具行——折叠状态也显示)上就自动出现一个**「在新标签页打开」**小按钮,点一下即打开审查页。
13
13
 
14
+ 对话里提到**本地 .diff / .patch 文件路径**时,路径会自动变成**查看链接按钮**——反引号里的路径原地变成小胶囊按钮,纯文本路径在消息下方补一行按钮;点击直接打开该文件的 Code Review 审查页(浏览器读不了本地文件,由插件宿主侧代读,只读绝对路径、仅限 .diff/.patch 后缀、≤5MB)。
15
+
16
+ ## 兼容性
17
+
18
+ - **dsh ≥ 0.1.2-alpha.4** — 自 **0.1.5** 起完整支持。Web UI 重构(聊天快照从 `useSession((s) => s.chat)` 迁移到会话标准的 `useChat` hook;markdown 代码块 DOM 包了一层 `bannerWrap` 层)在两条检测路径上均已适配:markdown diff 块与折叠的 bash 工具行。自 **0.2.1** 起在 **dsh 0.1.3-alpha.2** 上验证通过。
19
+ - **dsh 0.1.0-rc.x** — 仍通过旧版快照路径支持。
20
+
14
21
  ## 功能
15
22
 
16
23
  - ⬅️➡️ **双列并排视图** —— 旧文件在左、新文件在右,两侧各自带行号,类似 GitHub 的 split review。
@@ -23,6 +30,7 @@
23
30
  - 🎨 **主题一致** —— 打开时继承 DSH 当前主题色,深浅色模式自动跟随。
24
31
  - 📜 **整页滚动** —— 无高度限制,几千行自然滚动,超长行折行显示。
25
32
  - 💬 **行内评论** —— 悬停任意行号点击即可在该行提评论(类似 GitHub review),行号旁显示评论数徽章;攒齐后一次性提交 —— 可选 **Looks Good To Me ✓** 或**纯评论**,提交后自动写入 DSH 对话框(`文件名:行号 — 评论` 逐条列出),直接发给 agent。
33
+ - 🔗 **本地路径一键查看** —— 对话里出现 `…/xxx.diff`/`…/xxx.patch` 绝对路径时自动变成链接按钮:反引号内的路径原地变成小胶囊按钮,纯文本路径在消息下方补一行右对齐按钮;bash 工具输出里列出的路径也会出现在工具行按钮排上。点击后打开该文件的 Code Review 页,读取失败(文件不存在/非 diff/超 5MB)时按钮短暂变红提示。
26
34
  - 🌐 **中英双语** —— 跟随 DSH 界面语言(官方 locale 服务):中文界面显示中文按钮与审查页,其他语言一律英文。
27
35
 
28
36
  ## 截图
@@ -37,9 +45,15 @@
37
45
 
38
46
  1. **assistant 消息里的 markdown 代码块** —— ```` ```diff ```` / ```` ```patch ```` 语言标签,或内容形似 git diff 的任意代码块(比如贴在 `text` 围栏里)。代码块**默认折叠**(只留标题行 + 按钮),带「展开/收起」开关,想看原始文本就内联展开。
39
47
  2. **bash 工具行** —— agent 在 bash 工具里跑 `git diff` 后,按钮加在工具行上(折叠状态也可见;折叠行 DOM 里没有输出内容,从会话数据层检测)。详情面板里的终端卡片同样支持。
48
+ 3. **本地 diff 路径(0.2.0 起)** —— assistant / 用户消息里出现绝对路径的 `.diff`/`.patch` 文件时:
49
+ - 反引号内的路径(如 `` `/Users/me/proj/patch.diff` ``)原地变成**内联胶囊按钮**,跟在路径后面;
50
+ - 纯文本路径在**消息下方补一行右对齐按钮**(多条路径逐个列出);思考块与代码块内的路径不捕获(前者默认折叠、后者已有自身机制);
51
+ - bash 工具输出里列出的路径加在**工具行按钮排**上。
40
52
 
41
53
  流式输出有专门处理:内容稳定约 1 秒后按钮才出现,半截 diff 不会产生残缺页面。
42
54
 
55
+ 点击路径按钮时:插件宿主侧(`webServer` 注册的路由)读取本地文件内容(仅绝对路径、支持 `~` 展开、仅 `.diff`/`.patch` 后缀、≤5MB、不设 CORS),浏览器侧再把内容渲染成独立审查页;读取失败时按钮短暂变红提示。**读取需重启 `dsh web` 才生效**(node 半在宿主启动时注册路由),浏览器半改动硬刷新即可。
56
+
43
57
  ## 安装(30 秒)
44
58
 
45
59
  ```sh
@@ -58,6 +72,8 @@ dsh plugin --profile web add link:/path/to/@yangzhe1991/dsh-code-review
58
72
 
59
73
  插件注册一个 root 作用域的 `shell.overlay` 席位,自身渲染 null,用 `MutationObserver` 监听全文档。对每个 `.md-code-block` 和 `[data-terminal]` 元素做「稳定去抖」检测,候选块会在官方 banner/header 上追加一个小按钮(只追加、不改写官方节点,React 协调安全)。折叠的 bash 工具行走数据层:session 作用域组件订阅会话快照,把输出形似 diff 的已落地 bash 结果记录进文本表,扫描器给对应工具行加按钮。
60
74
 
75
+ 本地路径 chip 的分工:消息行(assistant/用户)由同一扫描器按行探测,反引号内的路径在 code 元素后原地插内联按钮,纯文本路径在行尾追加一排按钮;bash 工具结果里的路径走数据层(与 diff 按钮同一条数据链路)。文本节点只读不拆(对 React 管理的文本节点做拆分会破坏流式渲染),所以纯文本路径不做原地改造。点击后浏览器半先同步开占位窗(防弹窗拦截),再向宿主路由 `GET /dsh-code-review/diff?path=…` 拉取内容,成功即导航到独立审查页。
76
+
61
77
  点击按钮时,**在用户手势内同步**构建自包含 HTML 页面(内联样式 + 继承的主题变量 + 双列内容,全部 HTML 转义),通过 **Blob URL** 打开 —— 不依赖服务器任何路由,同步调用的 `window.open` 也不会被弹窗拦截。解析器是纯模块(`src/client/diff-parse.ts`,`test/parse.test.mjs` 有单测),支持:多文件 diff、带行号跟踪的 hunk、删除/新增配对成变更行(数量不等时多出的行退化为单侧行)、`` 标记、新增/删除/重命名/二进制文件、无 `diff --git` 头的裸 patch、`git show` 的提交说明前缀。
62
78
 
63
79
  ## 开发
@@ -66,6 +82,8 @@ dsh plugin --profile web add link:/path/to/@yangzhe1991/dsh-code-review
66
82
  npm install
67
83
  npm run build # esbuild → lib/index.js(node 半)+ lib/client.js(浏览器半)
68
84
  node test/parse.test.mjs # 解析器 + 独立页 HTML 单测
85
+ node test/path-detect.test.mjs # 本地路径检测正则单测
86
+ node test/node-route.test.mjs # node 半路由(读取/校验/错误码)单测
69
87
  node test/client-smoke.test.mjs # bundle 冒烟测试(mock __ModuleLoader__)
70
88
  ```
71
89
 
package/lib/client.js CHANGED
@@ -231,6 +231,60 @@ function countStats(files) {
231
231
  return { added, removed, files: files.length };
232
232
  }
233
233
 
234
+ // src/client/diff-path.ts
235
+ var ABS_ALT = "(?<![\\w/])/(?!/)[^\\s]*?\\.(?:diff|patch)";
236
+ var TILDE_ALT = "(?<![\\w/])~/[^\\s]*?\\.(?:diff|patch)";
237
+ var SUFFIX_BOUNDARY = "(?![\\w\\-/]|\\.(?=\\w))";
238
+ var PATH_RE = new RegExp(`(?:${ABS_ALT}|${TILDE_ALT})${SUFFIX_BOUNDARY}`, "gi");
239
+ var WRAPPERS = [
240
+ ['"', '"'],
241
+ ["'", "'"],
242
+ ["`", "`"],
243
+ ["(", ")"],
244
+ ["[", "]"],
245
+ ["{", "}"],
246
+ ["<", ">"],
247
+ ["\u300C", "\u300D"],
248
+ ["\u300E", "\u300F"],
249
+ ["\uFF08", "\uFF09"],
250
+ ["\u3010", "\u3011"]
251
+ ];
252
+ function unwrap(s) {
253
+ let t = s;
254
+ let changed = true;
255
+ while (changed) {
256
+ changed = false;
257
+ for (const [left, right] of WRAPPERS) {
258
+ if (t.length > left.length + right.length && t.startsWith(left) && t.endsWith(right)) {
259
+ t = t.slice(left.length, -right.length);
260
+ changed = true;
261
+ break;
262
+ }
263
+ }
264
+ }
265
+ return t;
266
+ }
267
+ function findDiffPaths(text) {
268
+ const out = [];
269
+ const seen = /* @__PURE__ */ new Set();
270
+ for (const match of text.matchAll(PATH_RE)) {
271
+ const p = match[0];
272
+ if (seen.has(p)) continue;
273
+ seen.add(p);
274
+ out.push(p);
275
+ }
276
+ return out;
277
+ }
278
+ function cleanDiffPath(raw) {
279
+ const cleaned = unwrap(raw.trim());
280
+ if (cleaned === "" || !cleaned.startsWith("/") && !cleaned.startsWith("~/")) {
281
+ return null;
282
+ }
283
+ const matches = findDiffPaths(cleaned);
284
+ if (matches.length !== 1 || matches[0] !== cleaned) return null;
285
+ return cleaned;
286
+ }
287
+
234
288
  // src/client/diff-highlight.ts
235
289
  var KEYWORDS = /* @__PURE__ */ new Set([
236
290
  // JS/TS
@@ -739,6 +793,9 @@ var CR_TEXTS = {
739
793
  collapse: "\u6536\u8D77",
740
794
  expandTitle: "\u5C55\u5F00\u539F\u59CB diff \u6587\u672C",
741
795
  collapseTitle: "\u6536\u8D77\u539F\u59CB diff \u6587\u672C",
796
+ viewPath: "\u67E5\u770B",
797
+ viewPathTitle: (path) => `\u5728\u72EC\u7ACB\u6807\u7B7E\u9875\u67E5\u770B\u672C\u5730 diff \u6587\u4EF6:${path}`,
798
+ pathReadFailed: "\u8BFB\u53D6\u5931\u8D25",
742
799
  reportLgtmHeader: (count) => `Looks good to me \u2713 \u2014 Code Review \u5171 ${count} \u6761\u8BC4\u8BBA:`,
743
800
  reportCommentHeader: (count) => `Code Review \u610F\u89C1,\u5171 ${count} \u6761:`,
744
801
  reportLgtmBare: "Looks good to me \u2713",
@@ -766,6 +823,9 @@ var CR_TEXTS = {
766
823
  collapse: "Collapse",
767
824
  expandTitle: "Show raw diff text",
768
825
  collapseTitle: "Hide raw diff text",
826
+ viewPath: "View",
827
+ viewPathTitle: (path) => `Open the local diff file in a separate tab: ${path}`,
828
+ pathReadFailed: "Failed to read",
769
829
  reportLgtmHeader: (count) => `Looks good to me \u2713 \u2014 Code Review with ${count} comment${count === 1 ? "" : "s"}:`,
770
830
  reportCommentHeader: (count) => `Code Review comments (${count}):`,
771
831
  reportLgtmBare: "Looks good to me \u2713",
@@ -1132,6 +1192,41 @@ var BUTTON_CSS = `
1132
1192
  margin-left: auto;
1133
1193
  padding: 4px 12px 7px;
1134
1194
  }
1195
+ /* \u672C\u5730\u8DEF\u5F84\u67E5\u770B chip:\u53CD\u5F15\u53F7\u5185\u8054\u5F62\u6001\u4E0E\u6D88\u606F\u7EA7/\u5DE5\u5177\u884C\u5F62\u6001\u5171\u7528,
1196
+ * \u4E3B\u8272\u63CF\u8FB9\u7684\u5C0F\u80F6\u56CA;\u5185\u8054\u5F62\u6001\u66F4\u5C0F\u66F4\u9690\u8EAB,\u4E0D\u62A2\u6B63\u6587\u89C6\u7EBF\u3002 */
1197
+ .dsh-cr-pathchip {
1198
+ display: inline-flex;
1199
+ align-items: center;
1200
+ gap: 4px;
1201
+ max-width: min(320px, 100%);
1202
+ font-size: 12px;
1203
+ line-height: 18px;
1204
+ color: var(--dsw-alias-state-business-primary, #3b6fe0);
1205
+ background: var(--dsw-alias-bg-base, #ffffff);
1206
+ border: 1px solid var(--dsw-alias-border-l2, #e5e5e7);
1207
+ border-radius: 8px;
1208
+ padding: 1px 8px;
1209
+ cursor: pointer;
1210
+ white-space: nowrap;
1211
+ overflow: hidden;
1212
+ text-overflow: ellipsis;
1213
+ vertical-align: middle;
1214
+ }
1215
+ .dsh-cr-pathchip:hover {
1216
+ border-color: var(--dsw-alias-state-business-primary, #3b6fe0);
1217
+ background: var(--dsw-alias-bg-layer-1, #f7f7f8);
1218
+ }
1219
+ .dsh-cr-pathchip[data-error='true'] {
1220
+ color: var(--dsw-alias-state-error-primary, #d92d20);
1221
+ border-color: var(--dsw-alias-state-error-primary, #d92d20);
1222
+ }
1223
+ /* \u5185\u8054\u5F62\u6001:\u8DDF\u5728 code \u5143\u7D20\u540E,\u4E0D\u6539\u6BB5\u843D\u884C\u9AD8 */
1224
+ .dsh-cr-pathchip[data-inline='true'] {
1225
+ margin: 0 2px 0 4px;
1226
+ padding: 0 6px;
1227
+ font-size: 11px;
1228
+ line-height: 16px;
1229
+ }
1135
1230
  `;
1136
1231
  function ensureStyle() {
1137
1232
  if (styleInjected || typeof document === "undefined") return;
@@ -1266,10 +1361,156 @@ function removeButtons(host) {
1266
1361
  btn.remove();
1267
1362
  }
1268
1363
  const row = host.querySelector("[data-dsh-cr-row]");
1269
- if (row instanceof HTMLElement && row.querySelector("[data-dsh-cr-btn], [data-dsh-cr-fold]") === null) {
1364
+ if (row instanceof HTMLElement && row.querySelector("[data-dsh-cr-btn], [data-dsh-cr-fold], [data-dsh-cr-pathchip]") === null) {
1270
1365
  row.remove();
1271
1366
  }
1272
1367
  }
1368
+ function basenameOf(path) {
1369
+ const i = path.lastIndexOf("/");
1370
+ return i >= 0 ? path.slice(i + 1) : path;
1371
+ }
1372
+ function openPathReviewTab(path, chip) {
1373
+ void (async () => {
1374
+ let html;
1375
+ try {
1376
+ const res = await fetch(`/dsh-code-review/diff?path=${encodeURIComponent(path)}`, { cache: "no-store" });
1377
+ if (!res.ok) throw new Error(`http ${res.status}`);
1378
+ const data = await res.json();
1379
+ if (data.ok !== true || typeof data.text !== "string") throw new Error(data.error ?? "bad-response");
1380
+ if (parseGitDiff(data.text).length === 0) throw new Error("not-a-diff");
1381
+ html = buildStandaloneHtml(parseGitDiff(data.text), collectThemeVars(), data.text, detectLang());
1382
+ } catch (err) {
1383
+ console.warn("[dsh-code-review] \u672C\u5730 diff \u8BFB\u53D6\u5931\u8D25:", path, err);
1384
+ markPathChipError(chip);
1385
+ return;
1386
+ }
1387
+ const url = URL.createObjectURL(new Blob([html], { type: "text/html" }));
1388
+ const win = window.open(url, "_blank");
1389
+ if (win !== null) {
1390
+ reviewWindows.add(win);
1391
+ window.setTimeout(() => URL.revokeObjectURL(url), 6e4);
1392
+ return;
1393
+ }
1394
+ const fallback = window.open("", "_blank");
1395
+ if (fallback !== null) {
1396
+ try {
1397
+ fallback.document.open();
1398
+ fallback.document.write(html);
1399
+ fallback.document.close();
1400
+ reviewWindows.add(fallback);
1401
+ return;
1402
+ } catch (err) {
1403
+ console.warn("[dsh-code-review] \u5360\u4F4D\u7A97\u5199\u5165\u5931\u8D25:", path, err);
1404
+ fallback.close();
1405
+ }
1406
+ }
1407
+ markPathChipError(chip);
1408
+ })();
1409
+ }
1410
+ function markPathChipError(chip) {
1411
+ if (chip === null || !chip.isConnected) return;
1412
+ const texts = CR_TEXTS[detectLang()];
1413
+ const path = chip.dataset.path ?? "";
1414
+ chip.dataset.error = "true";
1415
+ chip.title = texts.pathReadFailed;
1416
+ chip.textContent = texts.pathReadFailed;
1417
+ window.setTimeout(() => {
1418
+ if (!chip.isConnected) return;
1419
+ delete chip.dataset.error;
1420
+ chip.title = texts.viewPathTitle(path);
1421
+ chip.textContent = `${basenameOf(path)} ${texts.viewPath}`;
1422
+ }, 3e3);
1423
+ }
1424
+ function createPathChip(path, texts, inline) {
1425
+ const chip = document.createElement("button");
1426
+ chip.type = "button";
1427
+ chip.className = "dsh-cr-pathchip";
1428
+ chip.dataset.dshCrPathchip = "";
1429
+ chip.dataset.path = path;
1430
+ if (inline) chip.dataset.inline = "true";
1431
+ chip.textContent = `${basenameOf(path)} ${texts.viewPath}`;
1432
+ chip.title = texts.viewPathTitle(path);
1433
+ chip.addEventListener("click", (event) => {
1434
+ event.stopPropagation();
1435
+ event.preventDefault();
1436
+ openPathReviewTab(path, chip);
1437
+ });
1438
+ return chip;
1439
+ }
1440
+ function rowPathSig(m) {
1441
+ return JSON.stringify([...m.inline.map((i) => i.path), ...[...m.plain].sort()]);
1442
+ }
1443
+ function collectRowPathMatches(row) {
1444
+ const inline = [];
1445
+ const plain = [];
1446
+ const inlineSeen = /* @__PURE__ */ new Set();
1447
+ const walker = document.createTreeWalker(row, NodeFilter.SHOW_TEXT, {
1448
+ acceptNode(node2) {
1449
+ const parent = node2.parentElement;
1450
+ if (parent === null) return NodeFilter.FILTER_REJECT;
1451
+ if (parent.closest('[data-variant="think"], .md-code-block, [data-dsh-cr-pathchip], [data-dsh-cr-pathrow]') !== null) {
1452
+ return NodeFilter.FILTER_REJECT;
1453
+ }
1454
+ return NodeFilter.FILTER_ACCEPT;
1455
+ }
1456
+ });
1457
+ let node = walker.nextNode();
1458
+ while (node !== null) {
1459
+ const parent = node.parentElement;
1460
+ if (parent !== null && parent.tagName === "CODE" && !inlineSeen.has(parent)) {
1461
+ const path = cleanDiffPath(parent.textContent ?? "");
1462
+ if (path !== null) {
1463
+ inline.push({ el: parent, path });
1464
+ inlineSeen.add(parent);
1465
+ node = walker.nextNode();
1466
+ continue;
1467
+ }
1468
+ }
1469
+ for (const path of findDiffPaths(node.nodeValue ?? "")) plain.push(path);
1470
+ node = walker.nextNode();
1471
+ }
1472
+ const inlinePaths = new Set(inline.map((i) => i.path));
1473
+ return { inline, plain: [...new Set(plain)].filter((p) => !inlinePaths.has(p)) };
1474
+ }
1475
+ function removeRowPathChips(row) {
1476
+ for (const chip of row.querySelectorAll("[data-dsh-cr-pathchip]")) chip.remove();
1477
+ for (const container of row.querySelectorAll("[data-dsh-cr-pathrow]")) container.remove();
1478
+ }
1479
+ function applyRowPathChips(row, matches, texts) {
1480
+ removeRowPathChips(row);
1481
+ for (const { el, path } of matches.inline) {
1482
+ if (!el.isConnected) continue;
1483
+ el.after(createPathChip(path, texts, true));
1484
+ }
1485
+ if (matches.plain.length > 0) {
1486
+ const container = document.createElement("div");
1487
+ container.className = "dsh-cr-row dsh-cr-pathrow";
1488
+ container.dataset.dshCrPathrow = "";
1489
+ for (const path of matches.plain) container.appendChild(createPathChip(path, texts, false));
1490
+ row.appendChild(container);
1491
+ }
1492
+ }
1493
+ function attachPathChips(host, paths, texts) {
1494
+ for (const chip of host.querySelectorAll("[data-dsh-cr-pathchip]")) {
1495
+ if (!paths.includes(chip.dataset.path ?? "")) chip.remove();
1496
+ }
1497
+ if (paths.length === 0) return;
1498
+ let container = host.querySelector(".dsh-cr-row");
1499
+ if (container === null) {
1500
+ container = document.createElement("div");
1501
+ container.className = "dsh-cr-row";
1502
+ container.dataset.dshCrRow = "";
1503
+ host.appendChild(container);
1504
+ }
1505
+ const existing = /* @__PURE__ */ new Set();
1506
+ for (const chip of container.querySelectorAll("[data-dsh-cr-pathchip]")) {
1507
+ if (chip.dataset.path !== void 0) existing.add(chip.dataset.path);
1508
+ }
1509
+ for (const path of paths) {
1510
+ if (existing.has(path)) continue;
1511
+ container.appendChild(createPathChip(path, texts, false));
1512
+ }
1513
+ }
1273
1514
  function findCodeBlockBody(el) {
1274
1515
  const first = el.firstElementChild;
1275
1516
  for (const child of el.children) {
@@ -1342,6 +1583,7 @@ function buttonHostFor(el) {
1342
1583
  return first instanceof HTMLElement ? first : null;
1343
1584
  }
1344
1585
  var pendingTexts = /* @__PURE__ */ new Map();
1586
+ var pendingPaths = /* @__PURE__ */ new Map();
1345
1587
  function readToolRoot(data) {
1346
1588
  if (typeof data !== "object" || data === null) return null;
1347
1589
  return data.root ?? null;
@@ -1367,6 +1609,7 @@ function toolResultText(root) {
1367
1609
  }
1368
1610
  function syncDiffToolTexts(sessionId, chat) {
1369
1611
  const found = /* @__PURE__ */ new Map();
1612
+ const foundPaths = /* @__PURE__ */ new Map();
1370
1613
  for (const key of chat.order) {
1371
1614
  const node = chat.nodes.get(key);
1372
1615
  if (node === void 0 || node.kind !== "tool-call") continue;
@@ -1374,13 +1617,18 @@ function syncDiffToolTexts(sessionId, chat) {
1374
1617
  if (root === null || !isSettledToolResult(root)) continue;
1375
1618
  if (root.call === null || root.call.name !== "bash") continue;
1376
1619
  const text = toolResultText(root);
1377
- if (!isDiffText(text)) continue;
1378
- found.set(key, text);
1620
+ if (isDiffText(text)) found.set(key, text);
1621
+ const paths = findDiffPaths(text);
1622
+ if (paths.length > 0) foundPaths.set(key, paths);
1379
1623
  }
1380
1624
  for (const [key, entry] of pendingTexts) {
1381
1625
  if (entry.sessionId === sessionId && !found.has(key)) pendingTexts.delete(key);
1382
1626
  }
1627
+ for (const [key, entry] of pendingPaths) {
1628
+ if (entry.sessionId === sessionId && !foundPaths.has(key)) pendingPaths.delete(key);
1629
+ }
1383
1630
  for (const [key, text] of found) pendingTexts.set(key, { sessionId, text });
1631
+ for (const [key, paths] of foundPaths) pendingPaths.set(key, { sessionId, paths });
1384
1632
  }
1385
1633
  function findRowByKey(key) {
1386
1634
  for (const el of document.querySelectorAll("[data-chat-anchor-key]")) {
@@ -1396,10 +1644,19 @@ function attachToolRowButtons() {
1396
1644
  if (!(sample instanceof HTMLElement)) continue;
1397
1645
  ensureButtons(sample, () => pendingTexts.get(key)?.text ?? entry.text, null);
1398
1646
  }
1647
+ for (const [key, entry] of pendingPaths) {
1648
+ const row = findRowByKey(key);
1649
+ if (row === null) continue;
1650
+ const sample = row.querySelector('[data-sample="bash"]');
1651
+ if (!(sample instanceof HTMLElement)) continue;
1652
+ attachPathChips(sample, entry.paths, CR_TEXTS[detectLang()]);
1653
+ }
1399
1654
  }
1400
1655
  var DiffScanner = class {
1401
1656
  constructor() {
1402
1657
  __publicField(this, "states", /* @__PURE__ */ new WeakMap());
1658
+ /** 消息行路径 chip 的行状态(与 BlockState 并列,策略同款:去抖 + 签名)。 */
1659
+ __publicField(this, "pathStates", /* @__PURE__ */ new WeakMap());
1403
1660
  __publicField(this, "observer", null);
1404
1661
  __publicField(this, "scanTimer", null);
1405
1662
  __publicField(this, "safetyTimer", null);
@@ -1431,12 +1688,15 @@ var DiffScanner = class {
1431
1688
  this.scan();
1432
1689
  }, SCAN_DEBOUNCE);
1433
1690
  }
1434
- /** 全量扫描:数据层工具行按钮 + DOM 候选块。 */
1691
+ /** 全量扫描:数据层工具行按钮 + DOM 候选块 + 消息行路径 chip。 */
1435
1692
  scan() {
1436
1693
  attachToolRowButtons();
1437
1694
  for (const el of document.querySelectorAll(".md-code-block, [data-terminal]")) {
1438
1695
  this.inspect(el);
1439
1696
  }
1697
+ for (const row of document.querySelectorAll("[data-chat-anchor-key]")) {
1698
+ this.inspectPathRow(row);
1699
+ }
1440
1700
  }
1441
1701
  /** 检查一个候选块:内容未变则不动,变了则排去抖,不再形似 diff 则移除按钮。 */
1442
1702
  inspect(el) {
@@ -1514,6 +1774,65 @@ var DiffScanner = class {
1514
1774
  btn.textContent = st.folded ? texts.expand : texts.collapse;
1515
1775
  }
1516
1776
  }
1777
+ /**
1778
+ * 检查一个消息行的本地 diff 路径:
1779
+ * - 工具行(含 data-sample)走数据层,这里跳过;
1780
+ * - 没有路径/不再有路径 → 移除 chip 并清态;
1781
+ * - 签名变更 → 移除旧 chip、排稳定去抖(流式期间 chip 不闪现);
1782
+ * - 签名未变 → 校验已挂 chip 与当前 DOM 是否一致(React 可能重建过
1783
+ * code 元素),不一致直接重建(内容稳定,不需要再去抖)。
1784
+ */
1785
+ inspectPathRow(row) {
1786
+ if (row.querySelector("[data-sample]") !== null) return;
1787
+ if (!/\.(?:diff|patch)/i.test(row.textContent ?? "")) {
1788
+ removeRowPathChips(row);
1789
+ this.pathStates.delete(row);
1790
+ return;
1791
+ }
1792
+ const matches = collectRowPathMatches(row);
1793
+ const sig = rowPathSig(matches);
1794
+ if (matches.inline.length === 0 && matches.plain.length === 0) {
1795
+ removeRowPathChips(row);
1796
+ this.pathStates.delete(row);
1797
+ return;
1798
+ }
1799
+ const st = this.pathStates.get(row);
1800
+ if (st !== void 0 && st.sig === sig) {
1801
+ if (this.pathChipsConsistent(row, matches)) return;
1802
+ if (st.timer === null) applyRowPathChips(row, matches, CR_TEXTS[detectLang()]);
1803
+ return;
1804
+ }
1805
+ removeRowPathChips(row);
1806
+ if (st !== void 0 && st.timer !== null) clearTimeout(st.timer);
1807
+ const timer = window.setTimeout(() => {
1808
+ const cur = this.pathStates.get(row);
1809
+ if (cur === void 0 || cur.timer !== timer) return;
1810
+ cur.timer = null;
1811
+ const m2 = collectRowPathMatches(row);
1812
+ if (rowPathSig(m2) !== cur.sig) return;
1813
+ applyRowPathChips(row, m2, CR_TEXTS[detectLang()]);
1814
+ }, STABLE_MS);
1815
+ this.pathStates.set(row, { sig, timer });
1816
+ }
1817
+ /** 校验消息行上已挂的 chip 与当前匹配是否一致(数量 + 路径 + 锚点)。 */
1818
+ pathChipsConsistent(row, matches) {
1819
+ const chips = row.querySelectorAll("[data-dsh-cr-pathchip]");
1820
+ if (chips.length !== matches.inline.length + matches.plain.length) return false;
1821
+ const inlineOk = new Set(matches.inline.map((m) => m.path));
1822
+ const plainOk = new Set(matches.plain);
1823
+ for (const chip of chips) {
1824
+ const path = chip.dataset.path ?? "";
1825
+ if (chip.dataset.inline === "true") {
1826
+ if (!inlineOk.has(path)) return false;
1827
+ const sib = chip.previousElementSibling;
1828
+ if (!(sib instanceof HTMLElement) || sib.tagName !== "CODE") return false;
1829
+ if ((sib.textContent ?? "") !== path) return false;
1830
+ } else if (!plainOk.has(path)) {
1831
+ return false;
1832
+ }
1833
+ }
1834
+ return true;
1835
+ }
1517
1836
  };
1518
1837
  var inject = ["slots", "locale"];
1519
1838
  function apply(ctx) {