@konnga/dsh-hud 0.7.1 → 0.9.0

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/lib/client.js CHANGED
@@ -44,7 +44,16 @@ window.__ModuleLoader__.load({
44
44
  * 那些 chip 是"扫一眼"的信息(哪个会话脏了),不值得跟面板一样频繁;宿主侧还按目录
45
45
  * 缓存 + 去重,多会话共用一个目录只跑一次 git。
46
46
  */
47
- var ROWS_POLL_MS = 12000;
47
+ // 侧栏标记删掉之后,这份数据只剩"悬停卡片 / ⋯ 菜单"两处**按需展示**在用 ——
48
+ // 所以从 12s 放宽到 30s:按下不表的东西没必要那么勤地去问 git。
49
+ var ROWS_POLL_MS = 30000;
50
+ /**
51
+ * 工具活动的节拍。比面板那 8s 密得多:宿主这一端是**内存读**(不碰 git、不跑子进程),
52
+ * 密一点才能让"正在跑什么"真的看到;代价用"内容没变就不 set"抵掉。
53
+ */
54
+ var TOOLS_POLL_MS = 3000;
55
+ /** 胶囊上最多铺几个技能名(其余收成 `+k`,完整清单在悬停里)。 */
56
+ var SKILLS_SHOWN = 2;
48
57
  /** 面板默认宽度与“停靠在输入框上方”的锚点高度。 */
49
58
  var PANEL_WIDTH = 380;
50
59
  var COMPOSER_OFFSET = 168;
@@ -58,6 +67,8 @@ window.__ModuleLoader__.load({
58
67
  * 它夹在左栏与差异栏之间,是固定的——右边缘那个抓握仍只调整差异栏。
59
68
  */
60
69
  var COMMIT_LIST_WIDTH = 240;
70
+ /** 提交明细栏的最小宽度(它装的是文件路径,不能太窄;默认 240 只是起点,可拖)。 */
71
+ var COMMIT_MIN_WIDTH = 180;
61
72
 
62
73
 
63
74
  /** diff 模式的面板:更宽,且用固定高度(避免 bottom 锚定下"内容变高就跳位")。 */
@@ -93,12 +104,15 @@ window.__ModuleLoader__.load({
93
104
  "panel.clickForDiff": "点击一行查看该文件的差异",
94
105
  "panel.rowKeys": "在面板里按过之后再按 ↑/↓ 选行、Enter 打开",
95
106
  "panel.commits": "最近提交",
96
- "time.now": "刚刚",
97
- "time.minutes": " 分钟前",
98
- "time.hours": " 小时前",
99
- "time.days": " 天前",
107
+ "panel.tags": "标签",
108
+ "panel.tagsEmpty": "这个仓库还没有标签",
109
+ "panel.tagsCapped": "只显示最近 {n} 个",
110
+ "tag.click": "点一下看它指向的提交",
111
+ "tag.annotated": "附注标签",
112
+ "tag.lightweight": "轻量标签",
100
113
  "diff.close": "关闭差异",
101
114
  "diff.resize": "拖动右边缘调整差异宽度",
115
+ "diff.resizeCommit": "拖动调整提交明细栏宽度",
102
116
  "diff.worktree": "未暂存",
103
117
  "diff.index": "已暂存",
104
118
  "diff.wrap": "自动换行",
@@ -106,6 +120,31 @@ window.__ModuleLoader__.load({
106
120
  "diff.copied": "已复制",
107
121
  "diff.empty": "这一侧没有差异",
108
122
  "diff.binary": "二进制文件,没有可显示的文本差异",
123
+ // ── 图片预览(二进制改动里认得出的那几种) ──
124
+ "image.loading": "读取图片…",
125
+ "image.pair": "并排",
126
+ "image.flip": "切换",
127
+ "image.beforeWorktree": "索引",
128
+ "image.afterWorktree": "工作区",
129
+ "image.beforeIndex": "HEAD",
130
+ "image.afterIndex": "索引",
131
+ "image.beforeCommit": "父提交",
132
+ "image.afterCommit": "这次提交",
133
+ "image.dims": "{w}×{h}",
134
+ "image.dimsChanged": "尺寸 {from} → {to}",
135
+ "image.tooLarge": "超过 {limit} MB 预览上限({size})",
136
+ "image.notImage": "不是可预览的图片({size})",
137
+ "image.missing": "这一侧没有这个文件",
138
+ "image.zoomOut": "缩小",
139
+ "image.zoomIn": "放大",
140
+ "image.fitWidth": "适应宽度",
141
+ "image.zoomValue": "{percent}%",
142
+ // ── 轮尾的「本轮图片改动」条 ──
143
+ "turnImages.title": "本轮图片改动",
144
+ "turnImages.titleSession": "本会话图片改动",
145
+ "turnImages.open": "在内置预览里打开",
146
+ "turnImages.compare": "在 dsh-hud 里前后对比",
147
+ "turnImages.more": "还有 {n} 个",
109
148
  "diff.truncated": "差异过大,仅显示前面部分",
110
149
  "diff.untrackedHint": "未跟踪文件,与空文件对比",
111
150
  "diff.commitHint": "这次提交相对其父提交的差异(只读)",
@@ -119,14 +158,15 @@ window.__ModuleLoader__.load({
119
158
  "commit.click": "点击查看这次提交改了什么",
120
159
  // ── 会话归因(本会话改过哪些文件) ──
121
160
  "session.line": "本会话改过 {n} 个文件",
122
- "rows.mode.always": "侧栏 常亮",
123
- "rows.mode.hover": "侧栏 悬停",
124
- "rows.mode.off": "侧栏 关",
125
- "rows.modeTitle": "点一下切换:常亮(行首小标记)→ 悬停(动条上完整那枚)→ 关。当前:{mode}",
161
+ "tools.now": "现在在跑:{name}",
162
+ "tools.total": "本会话共 {n} 次工具调用",
163
+ "tools.byTool": "各工具:",
164
+ "tools.skills": "skills:",
165
+ "tools.recent": "最近:",
126
166
  "rows.ownNone": "本会话没有改动记录",
127
167
  "rows.noRepo": "不在 git 仓库里",
128
168
  "rows.error": "读取失败({code})",
129
- "rows.possible": "另有 {n} 个可能相关(命令旁听)",
169
+ "rows.possible": "命令旁听到 {n} 个可能改动的路径({now} 个现在有差异)",
130
170
  "review.count": "已审 {n}/{total}",
131
171
  "review.staleCount": "{n} 个改动已变,需要重看",
132
172
  "review.mark": "标成「这一版我审过了」(键盘:v)",
@@ -200,12 +240,15 @@ window.__ModuleLoader__.load({
200
240
  "panel.clickForDiff": "Click a file to see its diff",
201
241
  "panel.rowKeys": "Once you have clicked in the panel: ↑/↓ to select a row, Enter to open it",
202
242
  "panel.commits": "Recent commits",
203
- "time.now": "just now",
204
- "time.minutes": "m ago",
205
- "time.hours": "h ago",
206
- "time.days": "d ago",
243
+ "panel.tags": "Tags",
244
+ "panel.tagsEmpty": "No tags in this repository yet",
245
+ "panel.tagsCapped": "Showing the newest {n}",
246
+ "tag.click": "Click to open the commit it points at",
247
+ "tag.annotated": "Annotated tag",
248
+ "tag.lightweight": "Lightweight tag",
207
249
  "diff.close": "Close diff",
208
250
  "diff.resize": "Drag the right edge to resize the diff",
251
+ "diff.resizeCommit": "Drag to resize the commit pane",
209
252
  "diff.worktree": "Unstaged",
210
253
  "diff.index": "Staged",
211
254
  "diff.wrap": "Wrap lines",
@@ -213,6 +256,31 @@ window.__ModuleLoader__.load({
213
256
  "diff.copied": "Copied",
214
257
  "diff.empty": "No changes on this side",
215
258
  "diff.binary": "Binary file — no text diff to show",
259
+ // ── Image preview (binary changes we can recognise) ──
260
+ "image.loading": "Reading image…",
261
+ "image.pair": "Side by side",
262
+ "image.flip": "Flip",
263
+ "image.beforeWorktree": "index",
264
+ "image.afterWorktree": "worktree",
265
+ "image.beforeIndex": "HEAD",
266
+ "image.afterIndex": "index",
267
+ "image.beforeCommit": "parent",
268
+ "image.afterCommit": "this commit",
269
+ "image.dims": "{w}×{h}",
270
+ "image.dimsChanged": "size {from} → {to}",
271
+ "image.tooLarge": "over the {limit} MB preview limit ({size})",
272
+ "image.notImage": "not a previewable image ({size})",
273
+ "image.missing": "not present on this side",
274
+ "image.zoomOut": "Zoom out",
275
+ "image.zoomIn": "Zoom in",
276
+ "image.fitWidth": "Fit width",
277
+ "image.zoomValue": "{percent}%",
278
+ // ── Turn-tail "images this turn" strip ──
279
+ "turnImages.title": "Images this turn",
280
+ "turnImages.titleSession": "Images this session",
281
+ "turnImages.open": "Open in the built-in preview",
282
+ "turnImages.compare": "Compare before/after in dsh-hud",
283
+ "turnImages.more": "{n} more",
216
284
  "diff.truncated": "Diff is large; showing the beginning only",
217
285
  "diff.untrackedHint": "Untracked file, diffed against an empty file",
218
286
  "diff.commitHint": "What this commit changed against its parent (read-only)",
@@ -225,14 +293,15 @@ window.__ModuleLoader__.load({
225
293
  "commit.unknown": "Commit not found: it may have been rebased or amended away — refresh the panel",
226
294
  "commit.click": "Click to see what this commit changed",
227
295
  "session.line": "{n} file(s) changed by this session",
228
- "rows.mode.always": "Sidebar on",
229
- "rows.mode.hover": "Sidebar hover",
230
- "rows.mode.off": "Sidebar off",
231
- "rows.modeTitle": "Click to cycle: always (leading token) → hover (chip in the action strip) → off. Now: {mode}",
296
+ "tools.now": "Running now: {name}",
297
+ "tools.total": "{n} tool call(s) this session",
298
+ "tools.byTool": "By tool:",
299
+ "tools.skills": "skills:",
300
+ "tools.recent": "Recent:",
232
301
  "rows.ownNone": "this session has no recorded changes",
233
302
  "rows.noRepo": "not in a git repository",
234
303
  "rows.error": "read failed ({code})",
235
- "rows.possible": "{n} more possibly related (from commands)",
304
+ "rows.possible": "{n} path(s) commands may have touched ({now} still differ)",
236
305
  "review.count": "{n}/{total} reviewed",
237
306
  "review.staleCount": "{n} changed since reviewed",
238
307
  "review.mark": "Mark this revision as reviewed (key: v)",
@@ -301,9 +370,14 @@ window.__ModuleLoader__.load({
301
370
  modified: "var(--dsw-alias-state-warn-primary, #d9a63c)",
302
371
  added: "var(--dsw-alias-state-success-primary, #3fb950)",
303
372
  deleted: "var(--dsw-alias-state-error-primary, #f06a6a)",
304
- renamed: "var(--dsw-alias-brand-primary, #4b8bf5)",
373
+ // 与 codex-hud 的 `statEntries` 一一对应:R→cyan、C→brightBlue(我们的品牌蓝就是这个位)、
374
+ // T→magenta、?→灰。M/A/D/! 本来就一致(yellow/green/red/critical)。
375
+ // `?` 用 `label-secondary` 而不是 `label-dimmed`:后者是**反相的表面色**(浅 #e1e5ee、深 #43454a),
376
+ // 当文字压在面板底色上只有 1.26:1 / 1.64:1 —— 看不见(issue:未跟踪计数读不出来)。
377
+ // secondary 仍是中性灰、比彩色状态弱一档,但浅 5.8:1 / 深 10.4:1 都读得清。
378
+ renamed: "var(--dsh-hud-cyan, #22d3ee)",
305
379
  copied: "var(--dsw-alias-brand-primary, #4b8bf5)",
306
- typeChanged: "var(--dsw-alias-state-idle-primary, #8a8f98)",
380
+ typeChanged: "var(--dsh-hud-magenta, #d946ef)",
307
381
  untracked: "var(--dsw-alias-label-secondary, #8a8f98)",
308
382
  conflicted: "var(--dsw-alias-state-error-primary, #f06a6a)",
309
383
  };
@@ -325,6 +399,8 @@ window.__ModuleLoader__.load({
325
399
 
326
400
  /** 胶囊里分支名的显示上限;超出就中段省略,别把简写符挤出可见区。 */
327
401
  var PILL_BRANCH_MAX = 28;
402
+ /** 胶囊里工作区名最多几个字符(它在分支前面,不该抢分支的位置)。 */
403
+ var PILL_WORKSPACE_MAX = 20;
328
404
 
329
405
  /**
330
406
  * 差异行的底色:半透明,压在面板表面色之上,因此亮/暗主题都可读
@@ -336,6 +412,29 @@ window.__ModuleLoader__.load({
336
412
 
337
413
  /* --------------------------------------------------------- 本地 UI 状态 */
338
414
 
415
+ /** 固定缩放档位:与内置文档预览的菜单一致(25% / 50% / 100% / 150% / 200%)。 */
416
+ var ZOOM_STEPS = [0.25, 0.5, 1, 1.5, 2];
417
+
418
+ /** 落盘/读回的缩放值:只认档位里的数,其他一律当"适应宽度"。 */
419
+ function readZoom(ui) {
420
+ return ui && ZOOM_STEPS.indexOf(ui.zoom) >= 0 ? ui.zoom : null;
421
+ }
422
+
423
+ /**
424
+ * 走一档缩放。
425
+ *
426
+ * `null`(适应宽度)出发:`+` 从 100% 开始、`−` 从 50% 开始 —— 和"适应宽度"的直觉一致,
427
+ * 不用先经过 25% 才知道按了有反应。
428
+ */
429
+ function stepZoom(current, direction) {
430
+ if (current === null || current === undefined) return direction > 0 ? 1 : 0.5;
431
+ var index = ZOOM_STEPS.indexOf(current);
432
+ if (index < 0) return direction > 0 ? 1 : 0.5;
433
+ var next = index + (direction > 0 ? 1 : -1);
434
+ if (next < 0 || next >= ZOOM_STEPS.length) return current;
435
+ return ZOOM_STEPS[next];
436
+ }
437
+
339
438
  function readUi() {
340
439
  try {
341
440
  var raw = window.localStorage.getItem(UI_KEY);
@@ -355,9 +454,6 @@ window.__ModuleLoader__.load({
355
454
  }
356
455
  }
357
456
 
358
- /** 侧栏状态的三个档位(顺序就是点一下的循环顺序)。 */
359
- var SIDEBAR_MODES = ["always", "hover", "off"];
360
-
361
457
  /** 「审阅进度」的存档键:按"工作区 + 文件"记下审过的那一版签名。 */
362
458
  var REVIEWED_KEY = "dsh-hud.reviewed.v1";
363
459
  /**
@@ -644,8 +740,17 @@ window.__ModuleLoader__.load({
644
740
  /** 最近提交;`commitsOid` 记住它是哪个 HEAD 取的,oid 变了才重取。 */
645
741
  commits: null,
646
742
  commitsOid: null,
743
+ /**
744
+ * 标签列表。与提交不同,**新 tag 可以打在老提交上**(HEAD 不动),所以不能只按 oid 判断;
745
+ * 改用"上次取的时刻"做新鲜度窗口:面板开着时每次刷新都看一眼,但 10s 内不重复问 git。
746
+ */
747
+ tags: null,
748
+ tagsAt: 0,
749
+ tagsTruncated: false,
647
750
  /** 差异栏宽度(面板右边缘可拖),持久化。 */
648
751
  diffWidth: typeof ui.diffWidth === "number" ? ui.diffWidth : DIFF_DEFAULT_WIDTH,
752
+ /** 提交明细栏宽度(它与差异栏之间那条抓握可拖),持久化。 */
753
+ commitWidth: typeof ui.commitWidth === "number" ? ui.commitWidth : COMMIT_LIST_WIDTH,
649
754
  /**
650
755
  * 会话归因:本会话改过哪些文件(宿主半按 fs 事件记的)。
651
756
  *
@@ -657,13 +762,14 @@ window.__ModuleLoader__.load({
657
762
  onlySession: ui.onlySession === true,
658
763
  /** 「只看最近一轮」开关:把清单收窄到最近这一轮动过的文件。 */
659
764
  onlyLatest: ui.onlyLatest === true,
660
- /**
661
- * 侧栏状态显示档位:`always` 常亮(行首 16px 格放紧凑标记)/ `hover` 悬停(动条上放完整那枚)/
662
- * `off` 一处都不画。默认常亮 —— "哪个会话脏了"这件事本来就该扫一眼看到。
663
- */
664
- sidebarMode: SIDEBAR_MODES.indexOf(ui.sidebarMode) === -1 ? "always" : ui.sidebarMode,
665
765
  /** 这一版每个改动文件的签名(`file → signature`);空表表示"这次没要"。 */
666
766
  review: {},
767
+ /**
768
+ * 本会话的工具活动:`active`(正在跑哪个)+ `recent` + 计数。
769
+ *
770
+ * 实时秒数由客户端本地算(`active.startedAt`),不靠把轮询压到亚秒级。
771
+ */
772
+ tools: { phase: "idle", active: null, recent: [], counts: [], skills: [], skillsDropped: 0, total: 0, updatedAt: null },
667
773
  /** 侧栏行内 chip 要的摘要:一次批量取回,按 sessionId 索引。 */
668
774
  rows: { phase: "idle", byId: {}, unavailable: false, truncated: false, updatedAt: null },
669
775
  /**
@@ -686,6 +792,8 @@ window.__ModuleLoader__.load({
686
792
  var commitInflight = null;
687
793
  /** 会话归因那次请求(在途合并)。 */
688
794
  var sessionInflight = null;
795
+ /** 工具活动那次请求(在途合并)。 */
796
+ var toolsInflight = null;
689
797
  /** 侧栏摘要那次请求(在途合并)。 */
690
798
  var rowsInflight = null;
691
799
  /** 回合投影的订阅:`{ sessionId, dispose }`;`null` 表示没接。 */
@@ -719,14 +827,23 @@ window.__ModuleLoader__.load({
719
827
  writeUi({
720
828
  onlySession: state.onlySession === true,
721
829
  onlyLatest: state.onlyLatest === true,
722
- sidebarMode: state.sidebarMode,
723
830
  open: state.panelOpen,
724
831
  pos: state.pos,
725
832
  wrap: state.diff ? state.diff.wrap === true : false,
833
+ zoom: state.diff && ZOOM_STEPS.indexOf(state.diff.zoom) >= 0 ? state.diff.zoom : null,
726
834
  diffWidth: state.diffWidth,
835
+ commitWidth: state.commitWidth,
727
836
  });
728
837
  }
729
838
 
839
+ /** 拖动提交栏右边那条抓握时只改内存;松手才落盘。 */
840
+ function setCommitWidth(width) {
841
+ if (typeof width !== "number" || !Number.isFinite(width)) return;
842
+ var next = Math.max(COMMIT_MIN_WIDTH, Math.round(width));
843
+ if (state.commitWidth === next) return;
844
+ set({ commitWidth: next });
845
+ }
846
+
730
847
  /** 拖动右边缘时只改内存;松手才落盘(persistLayout)。 */
731
848
  function setDiffWidth(width) {
732
849
  if (typeof width !== "number" || !Number.isFinite(width)) return;
@@ -743,11 +860,12 @@ window.__ModuleLoader__.load({
743
860
  if (!state.cwd || !change || !change.file) return Promise.resolve();
744
861
  var cwd = state.cwd;
745
862
  var wrap = state.diff ? state.diff.wrap === true : readUi().wrap === true;
863
+ var zoom = state.diff && ZOOM_STEPS.indexOf(state.diff.zoom) >= 0 ? state.diff.zoom : readZoom(readUi());
746
864
  set({
747
865
  // 工作区差异与「某次提交的明细」是互斥的两种右栏内容,换过去就收起明细。
748
866
  commit: null,
749
867
  cursor: change.file,
750
- diff: { source: "worktree", phase: "loading", path: change.path, file: change.file, origPath: change.origFile || null, data: null, side: "worktree", wrap: wrap, hunk: 0, collapsed: [], errorCode: null },
868
+ diff: { source: "worktree", phase: "loading", path: change.path, file: change.file, origPath: change.origFile || null, data: null, side: "worktree", wrap: wrap, zoom: zoom, hunk: 0, collapsed: [], errorCode: null },
751
869
  });
752
870
 
753
871
  diffInflight = Promise.resolve()
@@ -775,6 +893,7 @@ window.__ModuleLoader__.load({
775
893
  data: value,
776
894
  side: worktreeRows > 0 || indexRows === 0 ? "worktree" : "index",
777
895
  wrap: current.wrap,
896
+ zoom: current.zoom,
778
897
  hunk: 0,
779
898
  collapsed: [],
780
899
  errorCode: null,
@@ -916,6 +1035,7 @@ window.__ModuleLoader__.load({
916
1035
  data: null,
917
1036
  side: "commit",
918
1037
  wrap: wrap,
1038
+ zoom: state.diff && ZOOM_STEPS.indexOf(state.diff.zoom) >= 0 ? state.diff.zoom : readZoom(readUi()),
919
1039
  hunk: 0,
920
1040
  collapsed: [],
921
1041
  errorCode: null,
@@ -972,6 +1092,52 @@ window.__ModuleLoader__.load({
972
1092
  set({ diff: null });
973
1093
  }
974
1094
 
1095
+ /* --------------------------------------------------- 图片预览(二进制) */
1096
+
1097
+ /**
1098
+ * 图片两侧的缓存:键与请求一一对应。
1099
+ *
1100
+ * 一张图的 base64 可能上兆,所以命中就不重取;条目数有上限(超了整表清空,
1101
+ * 和宿主的 `CACHE_MAX_ENTRIES` 同一个思路:宁可少缓存,也不要无界增长)。
1102
+ */
1103
+ var imagePairs = new Map();
1104
+ var IMAGE_CACHE_MAX = 8;
1105
+
1106
+ /**
1107
+ * 同步看一眼缓存:命中就返回条目,否则 null。
1108
+ *
1109
+ * 有了它,`ImageDiff` 的**第一次渲染**就能把已经取过的图画出来 ——
1110
+ * 关掉再打开同一张图不该先闪一下"读取图片…",也不该再发一次请求。
1111
+ */
1112
+ function cachedImagePair(request) {
1113
+ if (request === null || request === undefined) return null;
1114
+ var entry = imagePairs.get(imagePairKey(request));
1115
+ return entry === undefined ? null : entry;
1116
+ }
1117
+
1118
+ /** 供 `ImageDiff` 用:取一个二进制改动两侧的字节与尺寸(同一个请求只发一次)。 */
1119
+ function loadImagePair(request) {
1120
+ var key = imagePairKey(request);
1121
+ var cached = imagePairs.get(key);
1122
+ if (cached !== undefined) return Promise.resolve(cached);
1123
+
1124
+ return Promise.resolve()
1125
+ .then(function () {
1126
+ return ctx.connection.rpc.call(CHANNEL, "image", { args: request });
1127
+ })
1128
+ .then(function (result) {
1129
+ var entry = result && result.ok === true && result.value
1130
+ ? { phase: "ready", value: result.value }
1131
+ : { phase: "error", code: result && result.error && result.error.details ? result.error.details.code : null };
1132
+ if (imagePairs.size >= IMAGE_CACHE_MAX) imagePairs.clear();
1133
+ imagePairs.set(key, entry);
1134
+ return entry;
1135
+ })
1136
+ .catch(function () {
1137
+ return { phase: "error", code: "transport" };
1138
+ });
1139
+ }
1140
+
975
1141
  /**
976
1142
  * 收起右栏。提交明细把差异包在里面,所以它优先整体退掉;
977
1143
  * 只有工作区差异时才只是关掉差异。
@@ -1094,6 +1260,19 @@ window.__ModuleLoader__.load({
1094
1260
  persist();
1095
1261
  }
1096
1262
 
1263
+ /**
1264
+ * 图片缩放的档位(`null` = 适应宽度)。
1265
+ *
1266
+ * 放在 store 而不是组件里:一来记住(和"自动换行"一样落盘),二来组件的 hooks
1267
+ * 在这个假 React 里驱动不了嵌套状态 —— store 的状态任何位置都读得到。
1268
+ */
1269
+ function setZoom(value) {
1270
+ if (!state.diff) return;
1271
+ var next = value === null || ZOOM_STEPS.indexOf(value) >= 0 ? value : null;
1272
+ set({ diff: Object.assign({}, state.diff, { zoom: next }) });
1273
+ persist();
1274
+ }
1275
+
1097
1276
  /** dock 行的位置变了才写,避免每次测量都触发重渲染。 */
1098
1277
  function setAnchorTop(top) {
1099
1278
  if (state.anchorTop === top) return;
@@ -1122,9 +1301,34 @@ window.__ModuleLoader__.load({
1122
1301
  });
1123
1302
  }
1124
1303
 
1304
+ /** 取标签列表(面板开着时才取)。失败只让这一段不显示。 */
1305
+ function loadTags(cwd) {
1306
+ // 10s 新鲜度窗口:面板每 8s 刷新一次,这里避免每次都对 git 跑一遍 for-each-ref;
1307
+ // 又能让"刚打的新 tag"在十来秒内出现(HEAD 没动也得看到)。
1308
+ if (state.tags !== null && Date.now() - state.tagsAt < 10000) return Promise.resolve();
1309
+ return Promise.resolve()
1310
+ .then(function () {
1311
+ return ctx.connection.rpc.call(CHANNEL, "tags", { args: { cwd: cwd } });
1312
+ })
1313
+ .then(function (result) {
1314
+ if (state.cwd !== cwd) return;
1315
+ var value = result && result.ok === true ? result.value : null;
1316
+ var tags = value && Array.isArray(value.tags) ? value.tags : [];
1317
+ probe.log("tags", { count: tags.length, truncated: value ? value.truncated === true : false });
1318
+ set({ tags: tags, tagsAt: Date.now(), tagsTruncated: value ? value.truncated === true : false });
1319
+ })
1320
+ .catch(function () {
1321
+ if (state.cwd !== cwd) return;
1322
+ probe.log("tags-failed", { code: "transport" });
1323
+ set({ tags: [] , tagsAt: Date.now(), tagsTruncated: false });
1324
+ });
1325
+ }
1326
+
1125
1327
  function refresh() {
1126
1328
  // 会话归因跟 status 同一条命(两个都是"当前会话 + 当前工作区"的只读事实)。
1127
1329
  loadSessionChanges();
1330
+ // 工具活动只跟会话有关(没有工作区也该显示"正在跑什么")。
1331
+ loadTools();
1128
1332
  // 回合投影:会话还没物化时这里会安静地不接,下一次刷新再试。
1129
1333
  bindTurns(state.sessionId);
1130
1334
  if (!state.cwd) return Promise.resolve();
@@ -1158,6 +1362,8 @@ window.__ModuleLoader__.load({
1158
1362
  && (state.commits === null || state.commitsOid !== statusValue.oid)) {
1159
1363
  loadCommits(cwd, statusValue.oid);
1160
1364
  }
1365
+ // 标签与 HEAD 无关(新 tag 可以打在老提交上),所以只要面板开着就问一次(10s 窗口)。
1366
+ if (statusValue && state.panelOpen) loadTags(cwd);
1161
1367
  return;
1162
1368
  }
1163
1369
  var code = result && result.error && result.error.details ? result.error.details.code : null;
@@ -1182,6 +1388,9 @@ window.__ModuleLoader__.load({
1182
1388
  errorCode: null,
1183
1389
  commits: null,
1184
1390
  commitsOid: null,
1391
+ tags: null,
1392
+ tagsAt: 0,
1393
+ tagsTruncated: false,
1185
1394
  // 差异与提交明细都绑定在某个工作区上:换了工作区就把它们收起,
1186
1395
  // 否则会显示另一个仓库的文件或提交。键盘光标同理,它指向的是清单里的行。
1187
1396
  diff: null,
@@ -1189,6 +1398,8 @@ window.__ModuleLoader__.load({
1189
1398
  cursor: null,
1190
1399
  phase: cwd ? "loading" : "idle",
1191
1400
  });
1401
+ // 工具活动同理:换会话就清空,别显示上一个会话正在跑的工具。
1402
+ set({ tools: { phase: "idle", active: null, recent: [], counts: [], skills: [], skillsDropped: 0, total: 0, updatedAt: null } });
1192
1403
  // 归因是"这个会话"的事实:换会话就先清空,别把上一个会话的标记留在新会话的清单上。
1193
1404
  set({ sessionChanges: { phase: "idle", files: null, possible: null, commands: [], since: null, dropped: 0, possibleDropped: 0, errorCode: null } });
1194
1405
  // 回合信息同理:换会话就按新会话重新接(旧的先解掉)。
@@ -1396,6 +1607,61 @@ window.__ModuleLoader__.load({
1396
1607
  return rowsInflight;
1397
1608
  }
1398
1609
 
1610
+ /** 取本会话的工具活动(正在跑哪个 / 跑过哪些 / 各几次)。 */
1611
+ function loadTools() {
1612
+ var sessionId = state.sessionId;
1613
+ if (!sessionId) return Promise.resolve();
1614
+ if (toolsInflight) return toolsInflight;
1615
+ toolsInflight = Promise.resolve()
1616
+ .then(function () {
1617
+ return ctx.connection.rpc.call(CHANNEL, "session-tools", { args: { sessionId: sessionId } });
1618
+ })
1619
+ .then(function (result) {
1620
+ toolsInflight = null;
1621
+ if (state.sessionId !== sessionId) return;
1622
+ if (result && result.ok === true && result.value) {
1623
+ var value = result.value;
1624
+ var tools = {
1625
+ phase: "ready",
1626
+ active: value.active && typeof value.active === "object" ? value.active : null,
1627
+ recent: Array.isArray(value.recent) ? value.recent : [],
1628
+ counts: Array.isArray(value.counts) ? value.counts : [],
1629
+ skills: Array.isArray(value.skills) ? value.skills : [],
1630
+ skillsDropped: typeof value.skillsDropped === "number" ? value.skillsDropped : 0,
1631
+ total: typeof value.total === "number" ? value.total : 0,
1632
+ updatedAt: Date.now(),
1633
+ };
1634
+ // 内容没变就不要 set:这个节拍很密(见 TOOLS_POLL_MS),无谓的重渲染会到处都是。
1635
+ var previous = state.tools;
1636
+ var same = previous !== null && previous.active !== null && tools.active !== null
1637
+ ? previous.active.callId === tools.active.callId && previous.total === tools.total
1638
+ : previous !== null && previous.active === null && tools.active === null
1639
+ && previous.total === tools.total
1640
+ && previous.recent.length === tools.recent.length
1641
+ && (previous.recent.length === 0 || previous.recent[0].at === tools.recent[0].at);
1642
+ if (same) return;
1643
+ probe.log("session-tools", {
1644
+ active: tools.active === null ? null : tools.active.name,
1645
+ total: tools.total,
1646
+ kinds: tools.counts.length,
1647
+ // 技能单独露面:排查"技能名有没有记到"时不必再靠猜(这一版新增的字段)。
1648
+ skills: tools.skills.length,
1649
+ });
1650
+ set({ tools: tools });
1651
+ return;
1652
+ }
1653
+ var code = result && result.error && result.error.details ? result.error.details.code : null;
1654
+ probe.log("session-tools-failed", { code: code || "transport" });
1655
+ set({ tools: { phase: "error", active: null, recent: [], counts: [], skills: [], skillsDropped: 0, total: 0, updatedAt: Date.now() } });
1656
+ })
1657
+ .catch(function () {
1658
+ toolsInflight = null;
1659
+ probe.log("session-tools-failed", { code: "transport" });
1660
+ set({ tools: { phase: "error", active: null, recent: [], counts: [], skills: [], skillsDropped: 0, total: 0, updatedAt: Date.now() } });
1661
+ });
1662
+ return toolsInflight;
1663
+ }
1664
+
1399
1665
  /** 审阅存档的键:**按工作区分开**,同一个文件名在别的仓库里是另一码事。 */
1400
1666
  function reviewedKeyOf(file) {
1401
1667
  return reviewKeyOf(state.cwd, file);
@@ -1443,14 +1709,6 @@ window.__ModuleLoader__.load({
1443
1709
  set({ reviewed: writeReviewed(next) });
1444
1710
  }
1445
1711
 
1446
- /** 侧栏状态档位:常亮 → 悬停 → 不显示,点一下走一档。 */
1447
- function cycleSidebarMode() {
1448
- var index = SIDEBAR_MODES.indexOf(state.sidebarMode);
1449
- var next = SIDEBAR_MODES[(index + 1) % SIDEBAR_MODES.length];
1450
- set({ sidebarMode: next });
1451
- persist();
1452
- }
1453
-
1454
1712
  /** 「只看最近一轮」开关:过滤发生在 `visibleChangeRows`(渲染与键盘共用)。 */
1455
1713
  function toggleOnlyLatest() {
1456
1714
  set({ onlyLatest: state.onlyLatest !== true });
@@ -1502,12 +1760,17 @@ window.__ModuleLoader__.load({
1502
1760
  resetPanel: resetPanel,
1503
1761
  setAnchorTop: setAnchorTop,
1504
1762
  setDiffWidth: setDiffWidth,
1763
+ setCommitWidth: setCommitWidth,
1505
1764
  persistLayout: persist,
1506
1765
  openDiff: openDiff,
1766
+ loadImagePair: loadImagePair,
1767
+ cachedImagePair: cachedImagePair,
1768
+ loadSessionChanges: loadSessionChanges,
1507
1769
  closeDiff: closeDiff,
1508
1770
  closeDetail: closeDetail,
1509
1771
  setDiffSide: setDiffSide,
1510
1772
  toggleDiffWrap: toggleDiffWrap,
1773
+ setZoom: setZoom,
1511
1774
  openCommit: openCommit,
1512
1775
  openCommitDiff: openCommitDiff,
1513
1776
  toggleCommit: toggleCommit,
@@ -1519,7 +1782,6 @@ window.__ModuleLoader__.load({
1519
1782
  toggleOnlySession: toggleOnlySession,
1520
1783
  toggleOnlyLatest: toggleOnlyLatest,
1521
1784
  refreshRows: refreshRows,
1522
- cycleSidebarMode: cycleSidebarMode,
1523
1785
  toggleReviewed: toggleReviewed,
1524
1786
  clearReviewed: clearReviewed,
1525
1787
  };
@@ -1781,15 +2043,6 @@ window.__ModuleLoader__.load({
1781
2043
  onClick: props.onClearReviewed,
1782
2044
  }, t("review.clearShort"))
1783
2045
  : null,
1784
- // 侧栏状态档位:常亮 → 悬停 → 关(默认常亮)。
1785
- h("button", {
1786
- type: "button",
1787
- className: "dshh-sessionToggle" + (props.sidebarMode === "always" ? " dshh-sessionToggleOn" : ""),
1788
- "data-session-filter": "sidebar",
1789
- "data-sidebar-mode": props.sidebarMode,
1790
- title: t("rows.modeTitle", { mode: t("rows.mode." + props.sidebarMode) }),
1791
- onClick: props.onCycleSidebarMode,
1792
- }, props.sidebarMode === "always" ? "◉ " : (props.sidebarMode === "hover" ? "◑ " : "○ "), t("rows.mode." + props.sidebarMode)),
1793
2046
  // 第二枚开关只在"这一轮的归属拿得到"时出现:拿不到就整枚不画,别给一个按不动的按钮。
1794
2047
  view === null || view.latestTurn === null
1795
2048
  ? null
@@ -1957,15 +2210,24 @@ window.__ModuleLoader__.load({
1957
2210
  /** 相对时间;超过 30 天直接给提交自己的日期。 */
1958
2211
  function commitWhen(commit, t) {
1959
2212
  if (typeof commit.committedAt !== "number") return commit.date || "";
1960
- var seconds = Math.max(0, Math.round((Date.now() - commit.committedAt) / 1000));
1961
- if (seconds < 60) return t("time.now");
1962
- var minutes = Math.round(seconds / 60);
1963
- if (minutes < 60) return String(minutes) + t("time.minutes");
1964
- var hours = Math.round(minutes / 60);
1965
- if (hours < 24) return String(hours) + t("time.hours");
1966
- var days = Math.round(hours / 24);
1967
- if (days < 30) return String(days) + t("time.days");
1968
- return commit.date || "";
2213
+ return fullWhen(commit.committedAt);
2214
+ }
2215
+
2216
+ /**
2217
+ * 时刻一律给完整年月日 + 时分(本地时区):`2026-10-16 14:18`。
2218
+ *
2219
+ * 两级简化都被否掉了:先是从"2 小时前"改成具体时间(相对时间在对账场景不够用),
2220
+ * 再是"今天只给 `14:18`"也被否 —— 用户要**年月日都在**。所以不再分档,一视同仁。
2221
+ * 只到分钟:提交/标签不需要秒,列一排在面板里也塞得下。
2222
+ */
2223
+ function fullWhen(at) {
2224
+ var then = new Date(at);
2225
+ return String(then.getFullYear()) + "-" + pad2(then.getMonth() + 1) + "-" + pad2(then.getDate())
2226
+ + " " + pad2(then.getHours()) + ":" + pad2(then.getMinutes());
2227
+ }
2228
+
2229
+ function pad2(value) {
2230
+ return value < 10 ? "0" + String(value) : String(value);
1969
2231
  }
1970
2232
 
1971
2233
  /**
@@ -1982,7 +2244,8 @@ window.__ModuleLoader__.load({
1982
2244
  h("div", { className: "dshh-commits" }, commits.map(function (commit, index) {
1983
2245
  var refs = commit.refs || [];
1984
2246
  var isHead = refs.some(function (ref) { return ref.indexOf("HEAD") === 0; });
1985
- var tip = [t("commit.click"), "", commit.subject, commit.author + " · " + commit.date].concat(refs).join("\n");
2247
+ var when = typeof commit.committedAt === "number" ? fullWhen(commit.committedAt) : (commit.date || "");
2248
+ var tip = [t("commit.click"), "", commit.subject, commit.author + " · " + when].concat(refs).join("\n");
1986
2249
  // 整行是按钮:点了在左栏换成这次提交的改动清单(只读)。
1987
2250
  var open = sameCommit(commit.sha, props.activeSha);
1988
2251
  return h("button", {
@@ -2001,6 +2264,58 @@ window.__ModuleLoader__.load({
2001
2264
  })));
2002
2265
  }
2003
2266
 
2267
+ /**
2268
+ * 标签列表:一行一个,tag 名 + 它指向的提交 sha + 相对时间;指向当前 HEAD 的标 HEAD。
2269
+ * 点一行 = 用右栏的提交明细看这个 tag 指向的那次提交(复用同一条管道,不新增界面)。
2270
+ */
2271
+ function TagList(props) {
2272
+ // 原样保留 `null`("还没取到")—— 写成 `props.tags || []` 会把"没取到"和"确实没有"合并,
2273
+ // 于是面板一打开就先闪一行"还没有标签"。
2274
+ var tags = props.tags;
2275
+ var t = props.t;
2276
+ // `null` = 还没取到(先不画,避免闪一下"还没有标签");`[]` = 取回了、确实一个都没有 →
2277
+ // 这时要**画一行写明**:整段不画的话,用户没法区分"这个仓库没有 tag"与"这个功能没做"(实测踩过)。
2278
+ if (!Array.isArray(tags)) return null;
2279
+ if (tags.length === 0) {
2280
+ return h("div", { className: "dshh-section" },
2281
+ h("div", { className: "dshh-sectionTitle" }, t("panel.tags")),
2282
+ h("div", { className: "dshh-note", "data-tags-empty": "1" }, t("panel.tagsEmpty")));
2283
+ }
2284
+ var headSha = props.headSha;
2285
+
2286
+ return h("div", { className: "dshh-section" },
2287
+ h("div", { className: "dshh-sectionTitle" }, t("panel.tags")),
2288
+ h("div", { className: "dshh-commits" }, tags.map(function (tag, index) {
2289
+ var isHead = typeof headSha === "string" && headSha.length > 0
2290
+ && (tag.sha === headSha || headSha.indexOf(tag.sha) === 0);
2291
+ var tip = [
2292
+ t("tag.click"),
2293
+ "",
2294
+ tag.subject || "",
2295
+ t(tag.annotated ? "tag.annotated" : "tag.lightweight") + " · " + tag.sha + " · "
2296
+ + (typeof tag.committedAt === "number" ? fullWhen(tag.committedAt) : (tag.date || "")),
2297
+ ].join("\n");
2298
+ var open = sameCommit(tag.sha, props.activeSha);
2299
+ return h("button", {
2300
+ type: "button",
2301
+ className: "dshh-commit" + (open ? " dshh-commitActive" : ""),
2302
+ "data-tag-row": tag.name,
2303
+ key: tag.name + ":" + String(index),
2304
+ title: tip,
2305
+ onClick: function () {
2306
+ if (props.onOpen) props.onOpen(tag);
2307
+ },
2308
+ },
2309
+ h("span", { className: "dshh-ctag" }, tag.name),
2310
+ h("span", { className: "dshh-csha" }, tag.sha),
2311
+ isHead ? h("span", { className: "dshh-chead" }, "HEAD") : null,
2312
+ h("span", { className: "dshh-cwhen" }, commitWhen(tag, t)));
2313
+ })),
2314
+ props.truncated === true
2315
+ ? h("div", { className: "dshh-note" }, t("panel.tagsCapped", { n: String(tags.length) }))
2316
+ : null);
2317
+ }
2318
+
2004
2319
  /**
2005
2320
  * 一次提交的那一栏(**中间**栏,固定宽度):元信息 + 这次提交的改动清单。
2006
2321
  *
@@ -2065,10 +2380,432 @@ window.__ModuleLoader__.load({
2065
2380
  * 差异视图:两侧页签(宿主半一次把两侧都取回来了,切页签不再发请求)、
2066
2381
  * 自动换行、复制差异。全部只读——它只是把 git 的输出画出来。
2067
2382
  */
2383
+ /* ------------------------------------------------ 图片预览(二进制改动) */
2384
+
2385
+ /** 图片请求的缓存键:把请求的每个字段都带上,免得两侧共用一个键。 */
2386
+ function imagePairKey(request) {
2387
+ return [request.cwd || "", request.path || "", request.origPath || "", request.side || "", request.sha || ""].join("\u0000");
2388
+ }
2389
+
2390
+ /**
2391
+ * 由 diff 状态推出 `image` 请求;推不出(没有 cwd / 文件)返回 null。
2392
+ *
2393
+ * 页签语义与文本 diff 完全一致:未暂存看「索引 → 工作区」,已暂存看「HEAD → 索引」,
2394
+ * 提交明细按 sha 取,所以两个视图里"未暂存 / 已暂存"指的是同一件事。
2395
+ */
2396
+ function imageRequest(diff, cwd) {
2397
+ if (!diff || !diff.file || !cwd) return null;
2398
+ var request = { cwd: cwd, path: diff.file };
2399
+ if (diff.origPath) request.origPath = diff.origPath;
2400
+ if (diff.source === "commit" && diff.sha) request.sha = diff.sha;
2401
+ else request.side = diff.side === "index" ? "index" : "worktree";
2402
+ return request;
2403
+ }
2404
+
2405
+ /** 两侧的标签跟着"看的到底是哪两条记录"走。 */
2406
+ function imageLabels(diff, t) {
2407
+ if (diff.source === "commit") return { before: t("image.beforeCommit"), after: t("image.afterCommit") };
2408
+ if (diff.side === "index") return { before: t("image.beforeIndex"), after: t("image.afterIndex") };
2409
+ return { before: t("image.beforeWorktree"), after: t("image.afterWorktree") };
2410
+ }
2411
+
2412
+ /** 字节数:十进制 KB/MB,和文件管理器里看到的一致。 */
2413
+ function formatBytes(bytes) {
2414
+ if (typeof bytes !== "number" || !isFinite(bytes) || bytes < 0) return "?";
2415
+ if (bytes < 1000) return String(bytes) + " B";
2416
+ if (bytes < 1000 * 1000) return (bytes / 1000).toFixed(1) + " KB";
2417
+ return (bytes / 1000 / 1000).toFixed(2) + " MB";
2418
+ }
2419
+
2420
+ /** 一侧画不出来时的原因文案(每一侧各有各的原因)。 */
2421
+ function sideReasonText(side, t) {
2422
+ if (side.kind === "too-large") {
2423
+ return t("image.tooLarge", {
2424
+ limit: String(Math.round((typeof side.limit === "number" ? side.limit : 0) / 1024 / 1024)),
2425
+ size: formatBytes(side.bytes),
2426
+ });
2427
+ }
2428
+ if (side.kind === "not-image") return t("image.notImage", { size: formatBytes(side.bytes) });
2429
+ return t("image.missing");
2430
+ }
2431
+
2432
+ /**
2433
+ * 两张都画不出来时说什么:能说清原因就说原因,否则退回原来那句话。
2434
+ * "太大"优先于"不是图" —— 前者才是用户会想处理的那件事。
2435
+ */
2436
+ function imageFallbackText(t, before, after) {
2437
+ var sides = [before, after];
2438
+ var tooLarge = sides.filter(function (side) { return side.kind === "too-large"; })[0];
2439
+ if (tooLarge !== undefined) return sideReasonText(tooLarge, t);
2440
+ var notImage = sides.filter(function (side) { return side.kind === "not-image"; })[0];
2441
+ if (notImage !== undefined) return sideReasonText(notImage, t);
2442
+ return t("diff.binary");
2443
+ }
2444
+
2445
+ /**
2446
+ * 二进制(图片)改动的预览:并排两张图,或按需在两张之间切换。
2447
+ *
2448
+ * 只在文本 diff 报 `binary` 时挂上来。字节来自宿主的 `image` 端点(只读),
2449
+ * 这里只负责画:单侧画不出来就画那一侧的原因,两侧都不行才退回原来那句话。
2450
+ */
2451
+ function ImageDiff(props) {
2452
+ var t = props.t;
2453
+ var store = props.store;
2454
+ var diff = props.diff;
2455
+ var request = imageRequest(diff, props.cwd);
2456
+ var key = request === null ? null : imagePairKey(request);
2457
+ // 先同步看缓存:已经取过的图第一帧就画出来,不闪"读取图片…"。
2458
+ var viewRef = React.useState(function () {
2459
+ return store.cachedImagePair ? store.cachedImagePair(request) : null;
2460
+ });
2461
+ var modeRef = React.useState("pair");
2462
+ var flipRef = React.useState("after");
2463
+
2464
+ React.useEffect(function () {
2465
+ if (key === null) return undefined;
2466
+ var cancelled = false;
2467
+ var cached = store.cachedImagePair ? store.cachedImagePair(request) : null;
2468
+ if (cached !== null) {
2469
+ viewRef[1](cached);
2470
+ return undefined;
2471
+ }
2472
+ viewRef[1](null);
2473
+ store.loadImagePair(request).then(function (entry) {
2474
+ if (!cancelled) viewRef[1](entry);
2475
+ });
2476
+ return function () { cancelled = true; };
2477
+ }, [key]);
2478
+
2479
+ if (key === null) return h("div", { className: "dshh-empty", "data-dsh-hud": "image" }, t("diff.binary"));
2480
+
2481
+ var view = viewRef[0];
2482
+ if (view === null) return h("div", { className: "dshh-empty", "data-dsh-hud": "image" }, t("image.loading"));
2483
+ if (view.phase === "error") return h("div", { className: "dshh-empty", "data-dsh-hud": "image" }, errorText(view.code, t));
2484
+
2485
+ var value = view.value || {};
2486
+ var before = value.before || { kind: "missing" };
2487
+ var after = value.after || { kind: "missing" };
2488
+ if (before.kind !== "image" && after.kind !== "image") {
2489
+ return h("div", { className: "dshh-empty", "data-dsh-hud": "image" }, imageFallbackText(t, before, after));
2490
+ }
2491
+
2492
+ var labels = imageLabels(diff, t);
2493
+ var mode = modeRef[0];
2494
+ var flip = flipRef[0];
2495
+ // 缩放住在 store 里(和"自动换行"一样记住):`null` = 适应宽度,数字 = 固定档位。
2496
+ var zoom = diff.zoom === undefined ? null : diff.zoom;
2497
+ var cells = mode === "pair"
2498
+ ? [{ key: "before", label: labels.before, side: before }, { key: "after", label: labels.after, side: after }]
2499
+ : [{ key: flip, label: flip === "after" ? labels.after : labels.before, side: flip === "after" ? after : before }];
2500
+
2501
+ function cell(entry) {
2502
+ var side = entry.side;
2503
+ var body = side.kind === "image"
2504
+ ? h("img", {
2505
+ className: "dshh-img",
2506
+ alt: entry.label,
2507
+ // 缩放:适应宽度交给 CSS(`max-width:100%`);固定档位按**固有像素**算宽度,
2508
+ // 因为宿主已经把宽高一起给我们了 —— 不用等图片加载就能算对。
2509
+ style: zoom === null || typeof side.width !== "number"
2510
+ ? undefined
2511
+ : { width: Math.round(side.width * zoom) + "px", maxWidth: "none" },
2512
+ src: "data:" + side.mime + ";base64," + String(side.base64 || ""),
2513
+ })
2514
+ : h("div", { className: "dshh-imgNote" }, sideReasonText(side, t));
2515
+ return h("div", { className: "dshh-imgCell", key: entry.key },
2516
+ h("div", { className: "dshh-imgLabel" }, entry.label),
2517
+ body);
2518
+ }
2519
+
2520
+ function tab(id, label) {
2521
+ var active = mode === id;
2522
+ return h("button", {
2523
+ type: "button",
2524
+ className: "dshh-tab" + (active ? " dshh-tabActive" : ""),
2525
+ "aria-pressed": active ? "true" : "false",
2526
+ onClick: function () { modeRef[1](id); },
2527
+ }, label);
2528
+ }
2529
+
2530
+ // 尺寸变了才是这张图最值得一眼看到的信息(改图最常见的就是换尺寸)。
2531
+ var dimsChanged = before.kind === "image" && after.kind === "image"
2532
+ && typeof before.width === "number" && typeof after.width === "number"
2533
+ && (before.width !== after.width || before.height !== after.height)
2534
+ ? h("span", { className: "dshh-imgDims" }, t("image.dimsChanged", {
2535
+ from: t("image.dims", { w: String(before.width), h: String(before.height) }),
2536
+ to: t("image.dims", { w: String(after.width), h: String(after.height) }),
2537
+ }))
2538
+ : null;
2539
+ var bytes = before.kind === "image" && after.kind === "image"
2540
+ ? h("span", { className: "dshh-imgBytes" }, formatBytes(before.bytes) + " → " + formatBytes(after.bytes))
2541
+ : null;
2542
+
2543
+ // 缩放按钮:内置那个文档预览把缩放藏在"预览区底部 72px 的悬停感应带"里
2544
+ // (CSS 默认 opacity:0,还得 Ctrl+滚轮),这里给一排看得见、点的着的。
2545
+ var zoomControls = h("span", { className: "dshh-imgZoom", "data-dsh-hud": "image-zoom" }, [
2546
+ h("button", {
2547
+ key: "out",
2548
+ type: "button",
2549
+ className: "dshh-imgZoomBtn",
2550
+ title: t("image.zoomOut"),
2551
+ "data-dsh-hud": "image-zoom-out",
2552
+ onClick: function () { store.setZoom(stepZoom(zoom, -1)); },
2553
+ }, "−"),
2554
+ h("button", {
2555
+ key: "value",
2556
+ type: "button",
2557
+ className: "dshh-imgZoomBtn dshh-imgZoomValue",
2558
+ title: t("image.fitWidth"),
2559
+ "data-dsh-hud": "image-zoom-value",
2560
+ onClick: function () { store.setZoom(null); },
2561
+ }, zoom === null ? t("image.fitWidth") : t("image.zoomValue", { percent: String(Math.round(zoom * 100)) })),
2562
+ h("button", {
2563
+ key: "in",
2564
+ type: "button",
2565
+ className: "dshh-imgZoomBtn",
2566
+ title: t("image.zoomIn"),
2567
+ "data-dsh-hud": "image-zoom-in",
2568
+ onClick: function () { store.setZoom(stepZoom(zoom, 1)); },
2569
+ }, "+"),
2570
+ ]);
2571
+
2572
+ return h("div", { className: "dshh-imgWrap", "data-dsh-hud": "image" },
2573
+ h("div", { className: "dshh-imgMeta" },
2574
+ tab("pair", t("image.pair")),
2575
+ mode === "flip"
2576
+ ? h("button", {
2577
+ type: "button",
2578
+ className: "dshh-tab",
2579
+ title: t("image.flip"),
2580
+ "data-dsh-hud": "image-flip",
2581
+ onClick: function () { flipRef[1](flip === "after" ? "before" : "after"); },
2582
+ }, t("image.flip"))
2583
+ : null,
2584
+ zoomControls,
2585
+ dimsChanged,
2586
+ bytes),
2587
+ h("div", {
2588
+ className: "dshh-imgPair" + (mode === "flip" ? " dshh-imgPairSingle" : "") + (zoom === null ? "" : " dshh-imgPairZoomed"),
2589
+ }, cells.map(cell)));
2590
+ }
2591
+
2592
+ /* ----------------------------------------- 轮尾:本轮改过的图片(一条门) */
2593
+
2594
+ /** 图片类文件的扩展名。这里只做粗筛 —— 真正"是不是图"由宿主按文件头判断。 */
2595
+ var IMAGE_FILE_EXTENSIONS = ["png", "jpg", "jpeg", "gif", "webp", "bmp", "ico", "avif"];
2596
+
2597
+ /** 按扩展名粗筛"这看起来是张图"。 */
2598
+ function looksLikeImage(file) {
2599
+ if (typeof file !== "string") return false;
2600
+ var dot = file.lastIndexOf(".");
2601
+ if (dot < 0 || dot === file.length - 1) return false;
2602
+ return IMAGE_FILE_EXTENSIONS.indexOf(file.slice(dot + 1).toLowerCase()) >= 0;
2603
+ }
2604
+
2605
+ /** 只取文件名(chip 上放不下整条路径)。 */
2606
+ function baseNameOf(file) {
2607
+ var parts = String(file).split("/");
2608
+ return parts[parts.length - 1] || String(file);
2609
+ }
2610
+
2611
+ /**
2612
+ * 槽位给的是宿主的 `TurnLocation`。
2613
+ *
2614
+ * 形状属于宿主内部约定(槽位文档只说"receives the Turn"),所以这里宽松取值:
2615
+ * 取不到就回 null,由调用方退到"本会话"的说法,而不是整条不显示。
2616
+ */
2617
+ function turnNumberOf(turn) {
2618
+ if (typeof turn === "number" && isFinite(turn)) return turn;
2619
+ if (turn && typeof turn === "object" && typeof turn.turn === "number") return turn.turn;
2620
+ return null;
2621
+ }
2622
+
2623
+ /**
2624
+ * 从会话改动里挑出"这一轮改过的图片",最近的在前。
2625
+ *
2626
+ * **两档都收**:`●` 确定改过(写文件 / 编辑工具)与 `◐` 命令旁听(`>`、`tee`、`cp`、`rm`…)。
2627
+ * 只收确定档不够用 —— 图几乎都是脚本/命令产出的(`python gen.py`、`cp`、`curl -o`),
2628
+ * 那些永远进不了确定档,而"点一下看图"对它们一样有用。`◐` 是猜的,所以 chip 上带 `◐`、
2629
+ * 悬停给出那条命令(措辞与面板里改动清单一致)。
2630
+ *
2631
+ * 回合号拿不到(`turnNumberOf` 回 null)就不按回合收窄,退回"本会话最近的图片改动"。
2632
+ *
2633
+ * @returns `{ files, exact }`;`exact` 为 false 表示标题该说"本会话"而不是"本轮"。
2634
+ */
2635
+ function imageChangesOf(changes, turnNumber) {
2636
+ if (changes === null || changes === undefined) return { files: [], exact: turnNumber !== null };
2637
+ var confirmed = changes.files ? changes.files : {};
2638
+ var guessed = changes.possible ? changes.possible : {};
2639
+ var picked = [];
2640
+ var seen = {};
2641
+
2642
+ for (var file in confirmed) {
2643
+ if (!Object.prototype.hasOwnProperty.call(confirmed, file)) continue;
2644
+ if (!looksLikeImage(file)) continue;
2645
+ var record = confirmed[file] || {};
2646
+ if (turnNumber !== null) {
2647
+ var turns = Array.isArray(record.turns) ? record.turns : [];
2648
+ if (turns.indexOf(turnNumber) < 0 && record.lastTurn !== turnNumber) continue;
2649
+ }
2650
+ seen[file] = true;
2651
+ picked.push({
2652
+ file: file,
2653
+ name: baseNameOf(file),
2654
+ tier: "confirmed",
2655
+ reason: "",
2656
+ at: typeof record.lastAt === "number" ? record.lastAt : 0,
2657
+ });
2658
+ }
2659
+
2660
+ for (var guess in guessed) {
2661
+ if (!Object.prototype.hasOwnProperty.call(guessed, guess)) continue;
2662
+ if (seen[guess] === true) continue;
2663
+ if (!looksLikeImage(guess)) continue;
2664
+ var item = guessed[guess] || {};
2665
+ // 命令旁听只记"最近一次"的回合号(没有 turns 列表),所以只能按 lastTurn 收窄。
2666
+ if (turnNumber !== null && item.lastTurn !== turnNumber) continue;
2667
+ seen[guess] = true;
2668
+ picked.push({
2669
+ file: guess,
2670
+ name: baseNameOf(guess),
2671
+ tier: "possible",
2672
+ reason: typeof item.reason === "string" ? item.reason : "",
2673
+ at: typeof item.lastAt === "number" ? item.lastAt : 0,
2674
+ });
2675
+ }
2676
+
2677
+ picked.sort(function (left, right) { return right.at - left.at; });
2678
+ return { files: picked, exact: turnNumber !== null };
2679
+ }
2680
+
2681
+ /** 缩略图用哪一侧的字节:优先工作区,其次基线(新增/删除时只有一侧有)。 */
2682
+ function thumbSideOf(entry) {
2683
+ if (entry === null || entry.phase !== "ready" || !entry.value) return null;
2684
+ var pair = entry.value;
2685
+ if (pair.after && pair.after.kind === "image") return pair.after;
2686
+ if (pair.before && pair.before.kind === "image") return pair.before;
2687
+ return null;
2688
+ }
2689
+
2690
+ /**
2691
+ * 轮尾那条「本轮图片改动」:每项一张缩略图 + 文件名。
2692
+ *
2693
+ * 内置的改动卡片点一行是打开 review tab(图片在那儿看不见),这条是补上的门:
2694
+ * **点缩略图 → 槽位给的 `openFile(path)`**,即内置的右侧文档预览(图片原生渲染、可缩放);
2695
+ * 旁边那个 `⇄` 才打开 dsh-hud 面板看前后两张 —— 前后对比只有我们有。
2696
+ */
2697
+ function TurnImages(props) {
2698
+ var t = props.t;
2699
+ var store = props.store;
2700
+ var sessionId = props.sessionId;
2701
+ var useSessions = props.useSessions;
2702
+ var openFile = props.openFile;
2703
+ var turnNumber = turnNumberOf(props.turn);
2704
+
2705
+ // useSessions 是选择器 hook:选择器参数是必需的。
2706
+ var sessions = typeof useSessions === "function" ? useSessions(function (snapshot) { return snapshot; }) : null;
2707
+ var summary = sessionId !== undefined && sessions && sessions.byId ? sessions.byId[sessionId] : undefined;
2708
+ var cwd = summary ? summary.cwd : undefined;
2709
+
2710
+ var state = useStore(store);
2711
+ // 缩略图要等字节;缓存本身不在快照里,所以取完自增一下触发重画。
2712
+ var tick = React.useState(0);
2713
+ var bump = tick[1];
2714
+
2715
+ // 轮尾每个已完成的回合都会挂一次:绑定会话 + 没取过会话改动就取一次(宿主侧是内存读,很便宜)。
2716
+ React.useEffect(function () {
2717
+ if (sessionId === undefined || cwd === undefined) return undefined;
2718
+ store.bindSession(sessionId, cwd);
2719
+ if (state.sessionChanges === undefined || state.sessionChanges.phase === "idle") store.loadSessionChanges();
2720
+ return undefined;
2721
+ }, [sessionId, cwd]);
2722
+
2723
+ var picked = imageChangesOf(state.sessionChanges, turnNumber);
2724
+ var shown = picked.files.slice(0, 4);
2725
+ var keys = shown.map(function (entry) { return entry.file; }).join("\u0000");
2726
+
2727
+ React.useEffect(function () {
2728
+ if (keys === "" || cwd === undefined) return undefined;
2729
+ var cancelled = false;
2730
+ var wanted = shown.map(function (entry) {
2731
+ return { cwd: cwd, path: entry.file, side: "worktree" };
2732
+ }).filter(function (request) {
2733
+ return store.cachedImagePair(request) === null;
2734
+ });
2735
+ if (wanted.length === 0) return undefined;
2736
+ Promise.all(wanted.map(function (request) { return store.loadImagePair(request); })).then(function () {
2737
+ if (!cancelled) bump(function (count) { return count + 1; });
2738
+ });
2739
+ return function () { cancelled = true; };
2740
+ }, [keys, cwd]);
2741
+
2742
+ // 会话改动还没到、或这一轮没动过图:整条不渲染(不在每条轮尾留个空壳)。
2743
+ if (state.sessionChanges === undefined || state.sessionChanges.phase !== "ready") return null;
2744
+ if (shown.length === 0) return null;
2745
+
2746
+ var rest = picked.files.length - shown.length;
2747
+
2748
+ function chip(entry) {
2749
+ var thumb = thumbSideOf(cwd === undefined ? null : store.cachedImagePair({ cwd: cwd, path: entry.file, side: "worktree" }));
2750
+ var dot = entry.name.lastIndexOf(".");
2751
+ var extension = dot > 0 ? entry.name.slice(dot + 1).toUpperCase() : "?";
2752
+ // `◐` 是命令旁听来的(猜的):说清这一点,并把那条命令放进悬停里。
2753
+ var hint = entry.file + " · " + t("turnImages.open");
2754
+ if (entry.tier === "possible" && entry.reason !== "") hint += " · " + t("session.possibleTitle", { reason: entry.reason });
2755
+ return h("span", {
2756
+ key: entry.file,
2757
+ className: "dshh-imgstripItem",
2758
+ "data-dsh-hud": "turn-image",
2759
+ "data-file": entry.file,
2760
+ "data-tier": entry.tier,
2761
+ }, [
2762
+ h("button", {
2763
+ key: "open",
2764
+ type: "button",
2765
+ className: "dshh-imgstripOpen",
2766
+ title: hint,
2767
+ onClick: function () { if (typeof openFile === "function") openFile(entry.file); },
2768
+ }, [
2769
+ thumb === null
2770
+ ? h("span", { key: "ph", className: "dshh-imgstripThumb dshh-imgstripThumbEmpty" }, extension)
2771
+ : h("img", {
2772
+ key: "img",
2773
+ className: "dshh-imgstripThumb",
2774
+ alt: entry.name,
2775
+ src: "data:" + thumb.mime + ";base64," + String(thumb.base64 || ""),
2776
+ }),
2777
+ entry.tier === "possible"
2778
+ ? h("span", { key: "tier", className: "dshh-imgstripTier", title: hint }, "◐")
2779
+ : null,
2780
+ h("span", { key: "name", className: "dshh-imgstripName" }, entry.name),
2781
+ ]),
2782
+ h("button", {
2783
+ key: "compare",
2784
+ type: "button",
2785
+ className: "dshh-imgstripCompare",
2786
+ title: t("turnImages.compare"),
2787
+ "data-dsh-hud": "turn-image-compare",
2788
+ onClick: function () {
2789
+ store.openPanel(true);
2790
+ store.openDiff({ file: entry.file, path: entry.file, origFile: null });
2791
+ },
2792
+ }, "⇄"),
2793
+ ]);
2794
+ }
2795
+
2796
+ return h("div", { className: "dshh-imgstrip", "data-dsh-hud": "turn-images" }, [
2797
+ h("span", { key: "title", className: "dshh-imgstripTitle" }, picked.exact ? t("turnImages.title") : t("turnImages.titleSession")),
2798
+ h("span", { key: "items", className: "dshh-imgstripItems" }, shown.map(chip)),
2799
+ rest > 0 ? h("span", { key: "more", className: "dshh-imgstripMore" }, t("turnImages.more", { n: String(rest) })) : null,
2800
+ ]);
2801
+ }
2802
+
2068
2803
  function DiffView(props) {
2069
2804
  var t = props.t;
2070
2805
  var diff = props.diff;
2071
2806
  var store = props.store;
2807
+ // 图片预览要按工作区发请求;文本 diff 用不到它。
2808
+ var cwd = props.cwd;
2072
2809
 
2073
2810
  var copiedRef = React.useState(false);
2074
2811
  var copied = copiedRef[0];
@@ -2136,7 +2873,8 @@ window.__ModuleLoader__.load({
2136
2873
  } else if (!data) {
2137
2874
  body = h("div", { className: "dshh-empty" }, t("diff.empty"));
2138
2875
  } else if (data.binary === true) {
2139
- body = h("div", { className: "dshh-empty" }, t("diff.binary"));
2876
+ // 二进制里最常被改的就是图:能认出来就画出来,认不出才退回那句话。
2877
+ body = h(ImageDiff, { t: t, store: store, diff: diff, cwd: cwd });
2140
2878
  } else if (rows.length === 0) {
2141
2879
  body = h("div", { className: "dshh-empty" }, t("diff.empty"));
2142
2880
  } else {
@@ -2224,12 +2962,33 @@ window.__ModuleLoader__.load({
2224
2962
 
2225
2963
  /** 分支名过长时中段省略,保留头尾信息量。 */
2226
2964
  function shortBranch(name) {
2227
- if (name.length <= PILL_BRANCH_MAX) return name;
2228
- var head = Math.ceil((PILL_BRANCH_MAX - 1) / 2);
2229
- var tail = PILL_BRANCH_MAX - 1 - head;
2965
+ return middleEllipsis(name, PILL_BRANCH_MAX);
2966
+ }
2967
+
2968
+ /** 从中间省略:保留头尾(路径/分支名两头的区分度都比中间高)。 */
2969
+ function middleEllipsis(name, max) {
2970
+ if (name.length <= max) return name;
2971
+ var head = Math.ceil((max - 1) / 2);
2972
+ var tail = max - 1 - head;
2230
2973
  return name.slice(0, head) + "…" + name.slice(name.length - tail);
2231
2974
  }
2232
2975
 
2976
+ /**
2977
+ * 工作区名 = 会话 cwd 的目录名(侧栏给工作区起的名字就是它)。
2978
+ *
2979
+ * 取的是 **cwd** 而不是仓库顶层:会话开在子目录时,用户认的是那个子目录;
2980
+ * 完整路径在悬停与面板里,胶囊上只要一个能认出来的短名字。
2981
+ */
2982
+ function workspaceNameOf(cwd) {
2983
+ if (typeof cwd !== 'string' || cwd.length === 0) return null;
2984
+ var trimmed = cwd.replace(/[/\\]+$/, '');
2985
+ if (trimmed.length === 0) return null;
2986
+ var index = Math.max(trimmed.lastIndexOf('/'), trimmed.lastIndexOf('\\'));
2987
+ var name = index === -1 ? trimmed : trimmed.slice(index + 1);
2988
+ if (name.length === 0) return null;
2989
+ return middleEllipsis(name, PILL_WORKSPACE_MAX);
2990
+ }
2991
+
2233
2992
  /**
2234
2993
  * 改动计数 → 简写符列表 `[{ key, mark, value }]`(只列非零项,顺序固定)。
2235
2994
  *
@@ -2290,7 +3049,8 @@ window.__ModuleLoader__.load({
2290
3049
  return {
2291
3050
  row: row,
2292
3051
  mine: mineCount,
2293
- possible: possibleCount,
3052
+ possibleNow: typeof row.possibleNow === "number" ? row.possibleNow : 0,
3053
+ possible: possibleCount,
2294
3054
  shorthands: shorthands,
2295
3055
  changed: changed,
2296
3056
  dirty: row.repo === true && row.dirty === true,
@@ -2298,11 +3058,6 @@ window.__ModuleLoader__.load({
2298
3058
  };
2299
3059
  }
2300
3060
 
2301
- /** 两位数以上收成 `+`(行首那格只容得下两个字符)。 */
2302
- function compactCount(value) {
2303
- return value >= 10 ? "+" : String(value);
2304
- }
2305
-
2306
3061
  /**
2307
3062
  * `⋯` 菜单里的只读摘要(座位 `sidebar.workspaces.session.menu.item`)。
2308
3063
  *
@@ -2318,7 +3073,7 @@ window.__ModuleLoader__.load({
2318
3073
  var sessionId = props.sessionId;
2319
3074
  var store = props.store;
2320
3075
  var state = useStore(store);
2321
- if (!sessionId || state.sidebarMode === "off") return null;
3076
+ if (!sessionId) return null;
2322
3077
  var row = state.rows && state.rows.byId ? state.rows.byId[sessionId] : undefined;
2323
3078
  var summary = rowSummaryOf(row);
2324
3079
  if (summary === null) return null;
@@ -2341,7 +3096,9 @@ window.__ModuleLoader__.load({
2341
3096
 
2342
3097
  var own = [];
2343
3098
  if (summary.mine > 0) own.push(t("session.pill", { n: String(summary.mine) }));
2344
- if (summary.possible > 0) own.push(t("rows.possible", { n: String(summary.possible) }));
3099
+ if (summary.possible > 0) {
3100
+ own.push(t("rows.possible", { n: String(summary.possible), now: String(summary.possibleNow) }));
3101
+ }
2345
3102
  var ownLine = own.length === 0 ? t("rows.ownNone") : own.join(" · ");
2346
3103
 
2347
3104
  return h("div", {
@@ -2354,84 +3111,6 @@ window.__ModuleLoader__.load({
2354
3111
  ]);
2355
3112
  }
2356
3113
 
2357
- /**
2358
- * 常亮那一枚(座位 `sidebar.session.row.leading`)。
2359
- *
2360
- * 产品只给 16px 宽(`.slot{width:16px;flex:none}`),所以这里只放**一个双字符标记**;
2361
- * 优先级 = 价值顺序:先报"本会话改过几个"(只有我们有),没有就报"这个工作区脏不脏"。
2362
- * 产品自己在那一格显示"会话状态点"时这个座位不会被渲染 —— 那时退回悬停档。
2363
- */
2364
- function SessionLeadingToken(props) {
2365
- var t = props.t || function (key) { return key; };
2366
- var sessionId = props.sessionId;
2367
- var store = props.store;
2368
- var state = useStore(store);
2369
- if (!sessionId || state.sidebarMode !== "always") return null;
2370
- var row = state.rows && state.rows.byId ? state.rows.byId[sessionId] : undefined;
2371
- var summary = rowSummaryOf(row);
2372
- if (summary === null) return null;
2373
- /*
2374
- * 常亮档**永远有标记**:干净、非仓库、读取失败各有各的写法。
2375
- * 之前"干净就整枚不画",结果是用户以为功能没生效 —— 常亮的意义就是"扫一眼总能看到"。
2376
- */
2377
- var token = null;
2378
- // 优先级 = 价值顺序:本会话确定改过 → 命令旁听的可能相关 → 工作区脏 → 读取失败 → 干净 → 非仓库。
2379
- if (summary.mine > 0) token = { text: "●" + compactCount(summary.mine), kind: "mine" };
2380
- else if (summary.possible > 0) token = { text: "◐" + compactCount(summary.possible), kind: "possible" };
2381
- else if (summary.dirty) token = { text: "!" + compactCount(summary.changed), kind: "dirty" };
2382
- else if (summary.row.error !== null) token = { text: "?", kind: "error" };
2383
- else if (summary.row.repo === true) token = { text: "✓", kind: "clean" };
2384
- else token = { text: "—", kind: "clean" };
2385
- probe.log("row-token", { sessionId: sessionId, text: token.text, kind: token.kind });
2386
- return h("span", {
2387
- className: "dshh-rowtoken dshh-rowtoken-" + token.kind,
2388
- "data-dsh-hud": "row-token",
2389
- title: rowChipTitle(summary.row, t),
2390
- }, token.text);
2391
- }
2392
-
2393
- /**
2394
- * 悬停动条上那枚(座位 `sidebar.workspaces.session.row.action`)。
2395
- *
2396
- * 动作条只有 32px 高、平时就放一两个图标按钮,所以只放"图标级"宽度:
2397
- * `!` 脏 + 改动总数 + `●n`/`◐n` 本会话改动数;明细在悬停里。
2398
- */
2399
- function SessionRowChip(props) {
2400
- var t = props.t || function (key) { return key; };
2401
- var sessionId = props.sessionId;
2402
- var store = props.store;
2403
- var state = useStore(store);
2404
- if (!sessionId || state.sidebarMode !== "hover") return null;
2405
- var row = state.rows && state.rows.byId ? state.rows.byId[sessionId] : undefined;
2406
- var summary = rowSummaryOf(row);
2407
- if (summary === null) return null;
2408
-
2409
- var children = [];
2410
- if (summary.row.repo === true) {
2411
- var head = summary.dirty ? "!" : "";
2412
- if (summary.changed > 0) head += String(summary.changed);
2413
- if (head !== "") children.push(h("span", { key: "git" }, head));
2414
- }
2415
- if (summary.mine > 0) children.push(h("span", { key: "mine", className: "dshh-rowchipMine" }, "●" + String(summary.mine)));
2416
- else if (summary.possible > 0) children.push(h("span", { key: "possible", className: "dshh-rowchipPossible" }, "◐" + String(summary.possible)));
2417
-
2418
- // 干净:给一个 ✓,别让这一格空着(空着和"没做这个功能"长得一样)。
2419
- if (children.length === 0) children.push(h("span", { key: "clean", className: "dshh-rowchipClean" }, "✓"));
2420
- probe.log("row-chip", {
2421
- sessionId: sessionId,
2422
- repo: summary.row.repo === true,
2423
- dirty: summary.dirty,
2424
- changed: summary.changed,
2425
- mine: summary.mine,
2426
- possible: summary.possible,
2427
- });
2428
- return h("span", {
2429
- className: "dshh-rowchip",
2430
- "data-dsh-hud": "row-chip",
2431
- title: rowChipTitle(summary.row, t),
2432
- }, children);
2433
- }
2434
-
2435
3114
  /**
2436
3115
  * 侧栏会话行**悬停卡片**里的那一块(座位 `sidebar.session.row.hover`)。
2437
3116
  *
@@ -2444,7 +3123,7 @@ window.__ModuleLoader__.load({
2444
3123
  var sessionId = props.sessionId;
2445
3124
  var store = props.store;
2446
3125
  var state = useStore(store);
2447
- if (!sessionId || state.sidebarMode === "off") return null;
3126
+ if (!sessionId) return null;
2448
3127
  var row = state.rows && state.rows.byId ? state.rows.byId[sessionId] : undefined;
2449
3128
  var summary = rowSummaryOf(row);
2450
3129
  if (summary === null) return null;
@@ -2473,7 +3152,9 @@ window.__ModuleLoader__.load({
2473
3152
  if (row.error) lines.push(t("rows.error", { code: String(row.error) }));
2474
3153
  var own = [];
2475
3154
  if (mineCount > 0) own.push(t("session.pill", { n: String(mineCount) }));
2476
- if (possibleCount > 0) own.push(t("rows.possible", { n: String(possibleCount) }));
3155
+ if (possibleCount > 0) {
3156
+ own.push(t("rows.possible", { n: String(possibleCount), now: String(summary.possibleNow) }));
3157
+ }
2477
3158
  // 和第二行对齐:没有改动记录时也明说一句 —— 否则卡片上会缺一行,看着像没加载出来。
2478
3159
  lines.push(own.length === 0 ? t("rows.ownNone") : own.join(" · "));
2479
3160
  if (lines.length === 0) return null;
@@ -2486,35 +3167,6 @@ window.__ModuleLoader__.load({
2486
3167
  }));
2487
3168
  }
2488
3169
 
2489
- /** 行内 chip 的悬停说明:分支、上游、各类计数、以及这个会话自己的改动。 */
2490
- function rowChipTitle(row, t) {
2491
- var lines = [];
2492
- if (row.repo === true) {
2493
- var head = branchLabel(row, t);
2494
- if (row.dirty === true) head += "*";
2495
- if (row.ahead > 0) head += " ↑" + String(row.ahead);
2496
- if (row.behind > 0) head += " ↓" + String(row.behind);
2497
- lines.push(head);
2498
- var shorthands = countShorthands(row.counts);
2499
- if (shorthands.length > 0) {
2500
- lines.push(shorthands.map(function (item) {
2501
- return t("count." + item.key) + " " + String(item.value);
2502
- }).join(" · "));
2503
- }
2504
- if (row.cwd) lines.push(row.cwd);
2505
- } else if (row.repo === false && row.error === null) {
2506
- // 不在仓库里也要说清(不能只丢一个路径 —— 用户看不出"为什么这一行没有分支")。
2507
- lines.push(t("rows.noRepo"));
2508
- if (row.cwd) lines.push(row.cwd);
2509
- }
2510
- if (row.error) lines.push(t("rows.error", { code: String(row.error) }));
2511
- var mineCount = typeof row.mine === "number" ? row.mine : 0;
2512
- var possibleCount = typeof row.possible === "number" ? row.possible : 0;
2513
- if (mineCount > 0) lines.push(t("session.pill", { n: String(mineCount) }));
2514
- if (possibleCount > 0) lines.push(t("rows.possible", { n: String(possibleCount) }));
2515
- return lines.join("\n");
2516
- }
2517
-
2518
3170
  function HudDockPill(props) {
2519
3171
  var t = props.t || function (key) { return key; };
2520
3172
  var sessionId = props.sessionId;
@@ -2536,6 +3188,24 @@ window.__ModuleLoader__.load({
2536
3188
 
2537
3189
  var state = useStore(store);
2538
3190
 
3191
+ /**
3192
+ * 工具胶囊的"实时秒数"滴答。
3193
+ *
3194
+ * **必须在组件体里、无条件、固定顺序地调用**:早先放在普通函数 `toolPillNode` 里、还带
3195
+ * `if (now === undefined)` 的条件,就是条件钩子 —— React 会抛错,导致整行 dock 渲染失败,
3196
+ * 两枚胶囊一起消失(用户实测"胶囊都不显示了")。回调里再判断要不要开定时器就没这问题。
3197
+ */
3198
+ var tick = React.useState(Date.now());
3199
+ var tickSet = tick[1];
3200
+ var toolsTickKey = state.tools && state.tools.active !== null && state.tools.active !== undefined
3201
+ ? String(state.tools.active.callId)
3202
+ : null;
3203
+ React.useEffect(function () {
3204
+ if (toolsTickKey === null) return undefined;
3205
+ var timer = window.setInterval(function () { tickSet(Date.now()); }, 500);
3206
+ return function () { window.clearInterval(timer); };
3207
+ }, [toolsTickKey]);
3208
+
2539
3209
  React.useEffect(function () {
2540
3210
  if (!mainView) return;
2541
3211
  store.bindSession(sessionId, cwd);
@@ -2550,18 +3220,30 @@ window.__ModuleLoader__.load({
2550
3220
  : null;
2551
3221
 
2552
3222
  var children = [];
3223
+ // 工作区名放在最前、且与分支用 `·` 隔开:写成 `git:(dsh-hud/main*)` 会被读成"分支叫 dsh-hud/main"。
3224
+ // 三种"非仓库"状态也带上它 —— 工作区跟 git 没关系,没仓库时它反而更有用。
3225
+ var workspace = workspaceNameOf(cwd);
3226
+ if (workspace !== null) {
3227
+ children.push(h("span", { key: "workspace", className: "dshh-workspace" }, workspace));
3228
+ children.push(h("span", { key: "workspaceSep", className: "dshh-workspaceSep" }, " · "));
3229
+ }
2553
3230
  if (state.phase === "error") {
2554
- children.push("⎇ " + t("pill.error"));
3231
+ children.push(h("span", { key: "gitWraperr", className: "dshh-gitWrap" }, "git:(" + t("pill.error") + ")"));
2555
3232
  } else if (state.phase === "loading" || state.phase === "idle") {
2556
- children.push("⎇ " + t("pill.loading"));
3233
+ children.push(h("span", { key: "gitWrapload", className: "dshh-gitWrap" }, "git:(" + t("pill.loading") + ")"));
2557
3234
  } else if (!state.status || !state.status.repo) {
2558
- children.push("⎇ " + t("pill.notRepo"));
3235
+ children.push(h("span", { key: "gitWrapnorepo", className: "dshh-gitWrap" }, "git:(" + t("pill.notRepo") + ")"));
2559
3236
  } else {
2560
3237
  var status = state.status;
2561
- var head = "⎇ " + shortBranch(branchLabel(status, t)) + (status.dirty ? "*" : "");
2562
- if (status.ahead > 0) head += " ↑" + String(status.ahead);
2563
- if (status.behind > 0) head += " ↓" + String(status.behind);
2564
- children.push(head);
3238
+ // `git:(branch*)` —— codex-hud 的 gitSegment 就是这么拼的,配色也照它:
3239
+ // `git:(` 与 `)` 品红(colors.git)、分支名与 ↑↓ 青(colors.gitBranch),
3240
+ // 而 `*` 在括号**里面**、与分支名同色。
3241
+ children.push(h("span", { key: "gitOpen", className: "dshh-gitWrap" }, "git:("));
3242
+ children.push(h("span", { key: "branch", className: "dshh-branch" },
3243
+ shortBranch(branchLabel(status, t)) + (status.dirty ? "*" : "")));
3244
+ children.push(h("span", { key: "gitClose", className: "dshh-gitWrap" }, ")"));
3245
+ if (status.ahead > 0) children.push(h("span", { key: "ahead", className: "dshh-ahead" }, " ↑" + String(status.ahead)));
3246
+ if (status.behind > 0) children.push(h("span", { key: "behind", className: "dshh-ahead" }, " ↓" + String(status.behind)));
2565
3247
 
2566
3248
  // 简写符:只列非零项,按固定顺序,各自上色(M 黄 / A 绿 / D 红 / ? 灰 …)。
2567
3249
  // 与侧栏行内 chip 共用 `countShorthands` —— 两处各写一套算法一定会漂。
@@ -2608,7 +3290,132 @@ window.__ModuleLoader__.load({
2608
3290
  onClick: function () {
2609
3291
  store.togglePanel();
2610
3292
  },
2611
- }, children));
3293
+ }, children),
3294
+ // 工具活动那一枚并排在后面 —— dock 是竖列,独立注册会另起一整行(用户实测"换行了")。
3295
+ toolPillNode(state, { t: t, now: props.now === undefined ? tick[0] : props.now }));
3296
+ }
3297
+
3298
+ /**
3299
+ * 本会话工具活动的胶囊(座位 `conversation.input.dock`,与 Git 胶囊各自一行)。
3300
+ *
3301
+ * 照 codex-hud 那条 `Tools:` 的口径:**正在跑**就报工具名与实时秒数(秒数本地算,不靠轮询);
3302
+ * 空闲就报最近一次是哪个工具、本会话一共几次(失败打叉)。完全没跑过工具时整枚不画 ——
3303
+ * 空白注册会白占一行(dock 是竖列,一条注册就是一整行)。
3304
+ */
3305
+ /**
3306
+ * 工具活动那一枚(**与 Git 胶囊同一行**,所以它只是那一行里的第二个节点,不是独立注册)。
3307
+ *
3308
+ * 照 codex-hud 那条 `Tools:` 的口径:**正在跑**就报工具名与实时秒数(秒数本地算,不靠轮询);
3309
+ * 空闲就报最近一次是哪个工具、本会话一共几次(失败打叉)。完全没跑过工具时返回 null ——
3310
+ * 不画它,但那一行仍然在(Git 胶囊自己不消失,界面不会跳)。
3311
+ */
3312
+ function toolPillNode(state, options) {
3313
+ var t = options.t;
3314
+ var tools = state.tools;
3315
+ if (!tools || (tools.active === null && tools.total === 0)) return null;
3316
+ var now = options.now;
3317
+
3318
+ var children = [];
3319
+ if (tools.active !== null) {
3320
+ // 配色对齐 codex-hud 的 activity-lines:`◐` 黄 + 工具名青 + 目标/耗时用默认正文色。
3321
+ var elapsed = toolElapsedLabel(tools.active, now);
3322
+ children.push(h("span", { key: "glyph", className: "dshh-toolsGlyph dshh-toolsGlyphRun" }, "\u25d0"));
3323
+ children.push(h("span", { key: "name", className: "dshh-toolsName" },
3324
+ " " + String(tools.active.name === undefined ? "?" : tools.active.name)));
3325
+ // 带上参数摘要(codex-hud 就是 `◐ exec_command: pnpm test`)—— 技能调用因此能看出是哪个技能。
3326
+ if (typeof tools.active.summary === "string" && tools.active.summary.length > 0) {
3327
+ children.push(": " + tools.active.summary);
3328
+ }
3329
+ if (elapsed !== null) children.push(" " + elapsed);
3330
+ } else {
3331
+ // 空闲时把**每个工具各自几次**铺出来(codex-hud 那条 `Tools:` 就是这么给的):
3332
+ // `✓ bash ×16 │ ✓ apply_patch ×6 │ ✗ write_stdin ×3`。名字与数字贴在一起,
3333
+ // 就不会再有"×54 是总数还是这个工具的次数"那种误读。
3334
+ //
3335
+ // 最多铺 3 个(按次数排序,宿主已经排好),其余收成 `+k` —— 悬停里有完整清单与最近几条。
3336
+ // `skill` 工具本身对用户没信息量(`skill ×2` 说明不了是哪个技能)—— codex-hud 也是直接列技能名。
3337
+ // 所以把它从计数里剔掉,改由下面那段技能清单承担;总次数不受影响。
3338
+ var toolCounts = tools.counts.filter(function (item) { return item.name !== "skill"; });
3339
+ var shown = toolCounts.slice(0, 3);
3340
+ for (var i = 0; i < shown.length; i += 1) {
3341
+ var item = shown[i];
3342
+ var bad = item.ok === false;
3343
+ if (i > 0) children.push(" \u2502 ");
3344
+ children.push(h("span", {
3345
+ key: "glyph" + String(i),
3346
+ className: "dshh-toolsGlyph" + (bad ? " dshh-toolsGlyphBad" : " dshh-toolsGlyphOk"),
3347
+ }, bad ? "\u2717" : "\u2713"));
3348
+ children.push(" " + String(item.name) + " \u00d7" + String(item.count));
3349
+ }
3350
+ // 技能清单:名字直接铺出来("会话用了哪些 skill"要能一眼看到,不只是次数)。
3351
+ // 标签用字面的 `skills:`(不翻译)—— 与 `git:(` 同一套:它是约定写法,不是描述。
3352
+ var skills = tools.skills || [];
3353
+ if (skills.length > 0) {
3354
+ if (children.length > 0) children.push(" \u2502 ");
3355
+ children.push(t("tools.skills") + " ");
3356
+ for (var k = 0; k < skills.length && k < SKILLS_SHOWN; k += 1) {
3357
+ if (k > 0) children.push(" \u00b7 ");
3358
+ children.push(h("span", {
3359
+ key: "skill" + String(k),
3360
+ className: skills[k].ok === false ? "dshh-toolsGlyphBad" : "dshh-skillName",
3361
+ }, String(skills[k].name) + " \u00d7" + String(skills[k].count)));
3362
+ }
3363
+ if (skills.length > SKILLS_SHOWN) children.push(" +" + String(skills.length - SKILLS_SHOWN));
3364
+ if (tools.skillsDropped > 0) children.push(" +" + String(tools.skillsDropped));
3365
+ }
3366
+ if (toolCounts.length > shown.length) children.push(" +" + String(toolCounts.length - shown.length));
3367
+ }
3368
+
3369
+ // 渲染现场:画没画、画的是哪一档(与 row-chip/row-token 同一套排查手段)。
3370
+ probe.log("tools-pill", {
3371
+ active: tools.active === null ? null : String(tools.active.name),
3372
+ total: tools.total,
3373
+ });
3374
+ return h("span", {
3375
+ className: "dshh-pill dshh-toolsPill",
3376
+ "data-dsh-hud": "tools-pill",
3377
+ key: "tools",
3378
+ title: toolsTitle(tools, t),
3379
+ }, children);
3380
+ }
3381
+
3382
+ /** 已经跑了多久:`1.2s` / `12s`(拿不到开始时刻就返回 null,不编一个数)。 */
3383
+ function toolElapsedLabel(active, now) {
3384
+ if (active === null || typeof active !== "object") return null;
3385
+ if (typeof active.startedAt !== "number") return null;
3386
+ var seconds = Math.max(0, (now - active.startedAt) / 1000);
3387
+ return seconds < 10 ? seconds.toFixed(1) + "s" : String(Math.round(seconds)) + "s";
3388
+ }
3389
+
3390
+ /** 工具胶囊的悬停说明:在跑什么、一共几次、各工具几次、最近几条的结果与耗时。 */
3391
+ function toolsTitle(tools, t) {
3392
+ var lines = [];
3393
+ if (tools.active !== null) {
3394
+ lines.push(t("tools.now", { name: String(tools.active.name) }));
3395
+ if (typeof tools.active.summary === "string" && tools.active.summary.length > 0) lines.push(tools.active.summary);
3396
+ }
3397
+ lines.push(t("tools.total", { n: String(tools.total) }));
3398
+ if (tools.counts.length > 0) {
3399
+ lines.push(t("tools.byTool") + " " + tools.counts.map(function (item) {
3400
+ return String(item.name) + " \u00d7" + String(item.count);
3401
+ }).join(" \u00b7 "));
3402
+ }
3403
+ if (tools.skills && tools.skills.length > 0) {
3404
+ lines.push(t("tools.skills") + " " + tools.skills.map(function (item) {
3405
+ return String(item.name) + " \u00d7" + String(item.count) + (item.ok === false ? " (\u2717)" : "");
3406
+ }).join(" \u00b7 "));
3407
+ }
3408
+ var recent = tools.recent.slice(0, 3);
3409
+ if (recent.length > 0) {
3410
+ lines.push(t("tools.recent"));
3411
+ lines.push(recent.map(function (item) {
3412
+ var mark = item.ok === false ? "\u2717" : "\u2713";
3413
+ var ms = typeof item.ms === "number" ? " " + (item.ms / 1000).toFixed(1) + "s" : "";
3414
+ var summary = typeof item.summary === "string" && item.summary.length > 0 ? " \u00b7 " + item.summary : "";
3415
+ return mark + " " + String(item.name) + ms + summary;
3416
+ }).join("\n"));
3417
+ }
3418
+ return lines.join("\n");
2612
3419
  }
2613
3420
 
2614
3421
  /* ------------------------------------------------------- 悬浮面板 */
@@ -2634,13 +3441,24 @@ window.__ModuleLoader__.load({
2634
3441
  * @param layout - `panelLayout()` 的结果。
2635
3442
  * @param diffWidth - 差异栏的目标宽度。
2636
3443
  */
2637
- function panelWidthFor(layout, diffWidth) {
2638
- var middle = layout.commit === true ? COMMIT_LIST_WIDTH : 0;
2639
- if (layout.diff !== true && layout.commit !== true) return PANEL_WIDTH;
2640
- var right = layout.diff === true ? RESIZE_GRIP_WIDTH + (typeof diffWidth === "number" ? diffWidth : DIFF_DEFAULT_WIDTH) : 0;
3444
+ function panelWidthFor(layout, diffWidth, commitWidth) {
3445
+ var hasCommit = layout.commit === true;
3446
+ var hasDiff = layout.diff === true;
3447
+ if (!hasCommit && !hasDiff) return PANEL_WIDTH;
3448
+ // **逐栏钳到自己的下限**:只对总宽加一个 floor 是不够的 —— 存档里写着 40px 时,
3449
+ // 总宽仍然会按 40 算(floor 只保证"不小于最小总和",但差额会被算到别的栏头上)。
3450
+ var middle = hasCommit
3451
+ ? Math.max(COMMIT_MIN_WIDTH, typeof commitWidth === "number" ? commitWidth : COMMIT_LIST_WIDTH)
3452
+ : 0;
3453
+ var right = hasDiff
3454
+ ? Math.max(DIFF_MIN_WIDTH, typeof diffWidth === "number" ? diffWidth : DIFF_DEFAULT_WIDTH)
3455
+ : 0;
3456
+ // 两条抓握:面板右边缘那条(调差异栏;只有提交栏时调提交栏)—— 它是绝对定位、**不占宽**,
3457
+ // 但宽度里要预留 6px 让位,否则它压在内容上;栏间那条是真实的一格,占宽。
3458
+ var outerGrip = RESIZE_GRIP_WIDTH;
3459
+ var innerGrip = hasCommit && hasDiff ? RESIZE_GRIP_WIDTH : 0;
2641
3460
  var available = window.innerWidth - 2 * VIEWPORT_MARGIN;
2642
- var min = PANEL_WIDTH + middle + (layout.diff === true ? RESIZE_GRIP_WIDTH + DIFF_MIN_WIDTH : 0);
2643
- return Math.max(Math.min(min, available), Math.min(PANEL_WIDTH + middle + right, available));
3461
+ return Math.min(PANEL_WIDTH + middle + innerGrip + right + outerGrip, available);
2644
3462
  }
2645
3463
 
2646
3464
  /** 把位置夹回视口内,避免拖到屏幕外再也点不到。 */
@@ -2700,7 +3518,7 @@ window.__ModuleLoader__.load({
2700
3518
  // 从 store 现读,别用可能过期的渲染闭包(模式切换会改宽度)。
2701
3519
  function currentPanelWidth() {
2702
3520
  var snapshot = store.getSnapshot();
2703
- return panelWidthFor(panelLayout(snapshot), snapshot.diffWidth);
3521
+ return panelWidthFor(panelLayout(snapshot), snapshot.diffWidth, snapshot.commitWidth);
2704
3522
  }
2705
3523
 
2706
3524
  // Esc 收起面板。用捕获阶段监听:焦点可能在任何输入框里,捕获阶段先拿到。
@@ -2833,7 +3651,8 @@ window.__ModuleLoader__.load({
2833
3651
  if (!grip) return undefined;
2834
3652
  var onMove = function (event) {
2835
3653
  if (event.pointerId !== grip.pointerId) return;
2836
- store.setDiffWidth(grip.startWidth - (event.clientX - grip.startX));
3654
+ if (grip.kind === "commit") store.setCommitWidth(grip.startWidth - (event.clientX - grip.startX));
3655
+ else store.setDiffWidth(grip.startWidth - (event.clientX - grip.startX));
2837
3656
  };
2838
3657
  var finish = function (event) {
2839
3658
  if (event.pointerId !== grip.pointerId) return;
@@ -2852,15 +3671,21 @@ window.__ModuleLoader__.load({
2852
3671
 
2853
3672
  if (!state.panelOpen) return null;
2854
3673
 
2855
- function onGripPointerDown(event) {
3674
+ /**
3675
+ * 抓握按下。
3676
+ *
3677
+ * `kind` 决定它调哪一栏:`diff` = 面板右边缘那条(调差异栏);`commit` = 提交栏右边那条
3678
+ * (调提交明细栏)。只有提交栏、没有差异栏时,右边缘那条也调提交栏 —— 它是右边界上唯一
3679
+ * 还能变宽的东西,不该按下去没反应。
3680
+ */
3681
+ function onGripPointerDown(kind, event) {
2856
3682
  if (event.button !== 0) return;
2857
3683
  var snapshot = store.getSnapshot();
2858
3684
  event.preventDefault();
2859
- setGrip({
2860
- pointerId: event.pointerId,
2861
- startX: event.clientX,
2862
- startWidth: typeof snapshot.diffWidth === "number" ? snapshot.diffWidth : DIFF_DEFAULT_WIDTH,
2863
- });
3685
+ var start = kind === "commit"
3686
+ ? (typeof snapshot.commitWidth === "number" ? snapshot.commitWidth : COMMIT_LIST_WIDTH)
3687
+ : (typeof snapshot.diffWidth === "number" ? snapshot.diffWidth : DIFF_DEFAULT_WIDTH);
3688
+ setGrip({ kind: kind, pointerId: event.pointerId, startX: event.clientX, startWidth: start });
2864
3689
  }
2865
3690
 
2866
3691
  function onHeaderPointerDown(event) {
@@ -2897,7 +3722,7 @@ window.__ModuleLoader__.load({
2897
3722
  position: "fixed",
2898
3723
  zIndex: 30,
2899
3724
  boxSizing: "border-box",
2900
- width: panelWidthFor(layout, state.diffWidth),
3725
+ width: panelWidthFor(layout, state.diffWidth, state.commitWidth),
2901
3726
  maxWidth: "calc(100vw - 16px)",
2902
3727
  display: "flex",
2903
3728
  flexDirection: "column",
@@ -2976,8 +3801,6 @@ window.__ModuleLoader__.load({
2976
3801
  onToggleLatest: function () { store.toggleOnlyLatest(); },
2977
3802
  review: reviewView(state),
2978
3803
  onClearReviewed: function () { store.clearReviewed(); },
2979
- sidebarMode: state.sidebarMode,
2980
- onCycleSidebarMode: function () { store.cycleSidebarMode(); },
2981
3804
  }),
2982
3805
  // 顺序:改动概览 → **改动清单** → 最近提交。清单是"现在要处理的",
2983
3806
  // 提交是背景信息;清单放前面,一屏不够时先看到的是眼前的事。
@@ -3005,6 +3828,24 @@ window.__ModuleLoader__.load({
3005
3828
  // 再点一次打开着的那条 = 收起明细(打开提交不改左栏,只高亮它)。
3006
3829
  onOpen: function (commit) { store.toggleCommit(commit); },
3007
3830
  }),
3831
+ h(TagList, {
3832
+ tags: state.tags,
3833
+ truncated: state.tagsTruncated,
3834
+ t: t,
3835
+ headSha: state.status && state.status.oid ? state.status.oid : null,
3836
+ activeSha: commitMode ? state.commit.sha : null,
3837
+ // 点一个 tag = 看它指向的那次提交(tag 自己不是提交,所以把提交信息填进去)。
3838
+ onOpen: function (tag) {
3839
+ store.toggleCommit({
3840
+ sha: tag.sha,
3841
+ subject: tag.subject || "",
3842
+ author: "",
3843
+ date: tag.date || "",
3844
+ committedAt: typeof tag.committedAt === "number" ? tag.committedAt : null,
3845
+ refs: [tag.name],
3846
+ });
3847
+ },
3848
+ }),
3008
3849
  ];
3009
3850
  listBody = h.apply(null, ["div", { className: "dshh-body" }].concat(repoChildren));
3010
3851
  } else {
@@ -3024,10 +3865,18 @@ window.__ModuleLoader__.load({
3024
3865
  onOpenFile: function (file) { store.openCommitDiff(file); },
3025
3866
  activeFile: commitOpenFile,
3026
3867
  cursor: state.cursor,
3027
- width: COMMIT_LIST_WIDTH,
3868
+ width: typeof state.commitWidth === "number" ? state.commitWidth : COMMIT_LIST_WIDTH,
3869
+ })
3870
+ : null,
3871
+ commitMode && diffMode
3872
+ ? h("div", {
3873
+ className: "dshh-grip dshh-gripBetween",
3874
+ "data-dsh-hud": "grip-commit",
3875
+ title: t("diff.resizeCommit"),
3876
+ onPointerDown: function (event) { onGripPointerDown("commit", event); },
3028
3877
  })
3029
3878
  : null,
3030
- diffMode ? h(DiffView, { t: t, diff: state.diff, store: store }) : null)
3879
+ diffMode ? h(DiffView, { t: t, diff: state.diff, store: store, cwd: state.cwd }) : null)
3031
3880
  : listBody;
3032
3881
 
3033
3882
  return h("div", { className: "dshh-panel", "data-dsh-hud": "panel", style: panelStyle },
@@ -3075,9 +3924,17 @@ window.__ModuleLoader__.load({
3075
3924
  className: "dshh-grip",
3076
3925
  "data-dsh-hud": "grip",
3077
3926
  title: t("diff.resize"),
3078
- onPointerDown: onGripPointerDown,
3927
+ onPointerDown: function (event) { onGripPointerDown("diff", event); },
3079
3928
  })
3080
- : null,
3929
+ : commitMode
3930
+ // 只有提交栏:右边缘这条改成调它(否则按下去没反应)。
3931
+ ? h("div", {
3932
+ className: "dshh-grip",
3933
+ "data-dsh-hud": "grip-commit",
3934
+ title: t("diff.resizeCommit"),
3935
+ onPointerDown: function (event) { onGripPointerDown("commit", event); },
3936
+ })
3937
+ : null,
3081
3938
  h("div", { className: "dshh-footer" },
3082
3939
  h("span", null, t("panel.readonly")),
3083
3940
  h("span", null, state.updatedAt ? t("panel.updated") + timeLabel(state.updatedAt) : "")));
@@ -3093,7 +3950,7 @@ window.__ModuleLoader__.load({
3093
3950
  // dock 座位给的是整栏宽的一行,而输入卡片有自己的最大宽度并居中
3094
3951
  // (--dsh-composer-card-max-width)。外层容器复刻框架自带 dock 占位元素
3095
3952
  // (队列公告)的几何,胶囊才会真正贴在输入卡片上方左端,而不是飘到栏的最左。
3096
- ".dshh-dock{box-sizing:border-box;display:flex;justify-content:flex-start;",
3953
+ ".dshh-dock{box-sizing:border-box;display:flex;align-items:center;gap:6px;justify-content:flex-start;",
3097
3954
  "width:calc(100% - var(--dsh-composer-side-clearance,16px) - var(--dsh-composer-side-clearance,16px)",
3098
3955
  " - var(--dsh-composer-dock-inset,8px) - var(--dsh-composer-dock-inset,8px));",
3099
3956
  "max-width:calc(var(--dsh-composer-card-max-width,952px) - var(--dsh-composer-dock-inset,8px) - var(--dsh-composer-dock-inset,8px));",
@@ -3146,37 +4003,46 @@ window.__ModuleLoader__.load({
3146
4003
  "color:var(--dsw-alias-brand-primary,#4b8bf5);}",
3147
4004
  ".dshh-markerPossible{color:var(--dsw-alias-state-warn-primary,#d9a63c);opacity:.75;}",
3148
4005
  // 行尾的审阅标记:`○` 未审 / `✓` 这一版已审 / `✓`(黄) 改动已变。
3149
- // 侧栏会话行里那枚 chip:一行只有 32px 高、悬停才露出来,所以它必须很小。
3150
- // 侧栏会话行里那枚 chip:一行只有 32px 高、悬停动作条很窄,所以它必须小且不许撑宽。
3151
- ".dshh-rowchip{display:inline-flex;align-items:center;gap:2px;flex:none;max-width:72px;",
3152
- "font-family:ui-monospace,Consolas,monospace;font-size:10.5px;line-height:1;",
3153
- "white-space:nowrap;overflow:hidden;text-overflow:ellipsis;color:var(--dsw-alias-label-secondary,#8a8f98);}",
3154
- ".dshh-rowchipMine{color:var(--dsw-alias-brand-primary,#4b8bf5);}",
3155
- // 行首常亮那枚:产品给的格子只有 16px,所以最多两个字符、不许换行。
3156
- ".dshh-rowtoken{font-family:ui-monospace,Consolas,monospace;font-size:10px;line-height:1;",
3157
- "white-space:nowrap;flex:none;letter-spacing:-0.02em;}",
3158
4006
  // `⋯` 菜单里那条只读摘要:像一条说明,不像一个可点的菜单项。
3159
4007
  ".dshh-menureadout{display:flex;flex-direction:column;gap:2px;padding:4px 10px 6px;",
3160
4008
  "font-size:11px;line-height:1.5;color:var(--dsw-alias-label-tertiary,var(--dsw-alias-label-secondary,#8a8f98));",
3161
4009
  "cursor:default;user-select:text;max-width:260px;}",
3162
4010
  ".dshh-menureadoutLine{white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}",
3163
- ".dshh-rowtoken-mine{color:var(--dsw-alias-brand-primary,#4b8bf5);}",
3164
- ".dshh-rowtoken-possible{color:var(--dsw-alias-state-warn-primary,#d9a63c);}",
3165
- ".dshh-rowtoken-dirty{color:var(--dsw-alias-state-warn-primary,#d9a63c);opacity:.75;}",
3166
- ".dshh-rowtoken-clean{color:var(--dsw-alias-label-tertiary,rgba(138,143,152,0.6));}",
3167
- ".dshh-rowtoken-error{color:var(--dsw-alias-state-warn-primary,#d9a63c);}",
3168
- ".dshh-rowchipClean{color:var(--dsw-alias-state-success-primary,#3fb950);opacity:.8;}",
3169
4011
  // 悬停卡片里那块详情:跟着卡片自己的字号,只做行距与弱化。
3170
4012
  ".dshh-rowhover{display:flex;flex-direction:column;gap:2px;font-size:11px;",
3171
4013
  "color:var(--dsw-alias-label-secondary,#8a8f98);min-width:0;}",
3172
4014
  ".dshh-rowhoverLine{white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}",
3173
- ".dshh-rowchipPossible{color:var(--dsw-alias-state-warn-primary,#d9a63c);}",
4015
+ // 「已审 x/y」是个可点控件:同样不能用 label-dimmed(反相表面色,1.26:1 / 1.64:1),
4016
+ // 平时用次级文字色,悬停才升到正文色。
3174
4017
  ".dshh-review{flex:none;margin-left:auto;padding:0 2px;font-size:10.5px;cursor:pointer;",
3175
- "color:var(--dsw-alias-label-dimmed,rgba(138,143,152,0.65));}",
4018
+ "color:var(--dsw-alias-label-secondary,#8a8f98);}",
3176
4019
  ".dshh-review:hover{color:var(--dsw-alias-label-primary,#e5e7eb);}",
3177
4020
  ".dshh-reviewOn{color:var(--dsw-alias-state-success-primary,#3fb950);}",
3178
4021
  ".dshh-reviewStale{color:var(--dsw-alias-state-warn-primary,#d9a63c);}",
3179
- ".dshh-markerOther{color:var(--dsw-alias-label-dimmed,rgba(138,143,152,0.6));}",
4022
+ ".dshh-ctag{flex:0 1 auto;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;",
4023
+ "font-family:ui-monospace,Consolas,monospace;font-size:11px;color:var(--dsw-alias-state-warn-primary,#d9a63c);}",
4024
+ // 行首的 `○`(与本会话无关):要"最不显眼",但同样得看得见 —— 与上面同一个理由。
4025
+ ".dshh-markerOther{color:var(--dsw-alias-label-secondary,#8a8f98);}",
4026
+ // 工具活动胶囊:与 Git 胶囊同形,但它是只读读数,指针不该是“可点”。
4027
+ // 与 codex-hud 对齐的几支色(它的默认配色表:git=magenta、gitBranch=cyan、project=yellow):
4028
+ // 产品没有青/品红的语义变量(只有 button-info-*,那是按钮用的),所以这两支写死值,
4029
+ // 但都走变量、可被覆盖。
4030
+ ".dshh-workspace{color:var(--dsw-alias-state-warn-primary,#d9a63c);flex:none;}",
4031
+ ".dshh-gitWrap{color:var(--dsh-hud-magenta,#d946ef);flex:none;}",
4032
+ ".dshh-branch{color:var(--dsh-hud-cyan,#22d3ee);flex:none;}",
4033
+ ".dshh-ahead{color:var(--dsh-hud-cyan,#22d3ee);flex:none;}",
4034
+ ".dshh-workspaceSep{color:var(--dsw-alias-label-tertiary,rgba(138,143,152,0.6));flex:none;}",
4035
+ ".dshh-toolsPill{cursor:default;flex:0 1 auto;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}",
4036
+ // 配色对齐 codex-hud 的 activity-lines:只有状态符上色,名字/次数/分隔符都用正文色。
4037
+ ".dshh-toolsGlyph{flex:none;}",
4038
+ ".dshh-toolsGlyphOk{color:var(--dsw-alias-state-success-primary,#3fb950);}",
4039
+ ".dshh-toolsGlyphRun{color:var(--dsw-alias-state-warn-primary,#d9a63c);}",
4040
+ ".dshh-toolsGlyphBad{color:var(--dsw-alias-state-error-primary,#f06a6a);}",
4041
+ // 唯一一处写死的颜色:codex-hud 把"正在跑的工具名"画成青色,产品没有对应的语义变量
4042
+ // (只有 button-info-*,那是按钮用的)。留 `--dsh-hud-tool-name` 作为覆盖钩子。
4043
+ ".dshh-toolsName{color:var(--dsh-hud-cyan,#22d3ee);}",
4044
+ // 技能名与工具名同色系但不同支:工具那支是青,技能走品牌色(一眼能分开)。
4045
+ ".dshh-skillName{color:var(--dsw-alias-brand-primary,#4b8bf5);}",
3180
4046
  ".dshh-section{display:flex;flex-direction:column;gap:4px;}",
3181
4047
  ".dshh-list{display:flex;flex-direction:column;gap:1px;max-height:30vh;overflow:auto;",
3182
4048
  "border-top:1px solid var(--dsw-alias-border-l1,rgba(127,127,127,0.16));padding-top:4px;}",
@@ -3223,6 +4089,57 @@ window.__ModuleLoader__.load({
3223
4089
  ".dshh-cwhen{flex:none;font-size:10.5px;color:var(--dsw-alias-label-secondary,#8a8f98);}",
3224
4090
  ".dshh-note{font-size:11px;color:var(--dsw-alias-label-secondary,#8a8f98);}",
3225
4091
  ".dshh-empty{padding:14px 12px;font-size:12px;color:var(--dsw-alias-label-secondary,#8a8f98);}",
4092
+ // ---- 图片预览(二进制改动) ----
4093
+ ".dshh-imgWrap{flex:1 1 auto;min-height:0;overflow:auto;padding:10px;display:flex;flex-direction:column;gap:8px;}",
4094
+ ".dshh-imgMeta{display:flex;align-items:center;gap:8px;flex-wrap:wrap;font-size:11px;",
4095
+ "color:var(--dsw-alias-label-secondary,#8a8f98);}",
4096
+ ".dshh-imgDims{color:var(--dsw-alias-state-warn-primary,#d9a63c);}",
4097
+ ".dshh-imgBytes{font-family:ui-monospace,Consolas,monospace;}",
4098
+ ".dshh-imgPair{display:flex;gap:10px;align-items:flex-start;flex-wrap:wrap;}",
4099
+ ".dshh-imgPairSingle{flex-wrap:nowrap;}",
4100
+ // 固定档位(非"适应宽度")时:图片按固有像素算宽度,容器随内容撑开,外层横向滚动。
4101
+ ".dshh-imgPairZoomed{flex-wrap:nowrap;width:max-content;align-items:flex-start;}",
4102
+ ".dshh-imgPairZoomed .dshh-imgCell{flex:0 0 auto;}",
4103
+ ".dshh-imgPairZoomed .dshh-img{max-height:none;}",
4104
+ ".dshh-imgZoom{display:inline-flex;align-items:center;gap:2px;margin-left:auto;}",
4105
+ ".dshh-imgZoomBtn{border:1px solid var(--dsw-alias-border-l1,rgba(127,127,127,0.25));",
4106
+ "background:var(--dsw-alias-bg-layer-1,transparent);color:inherit;cursor:pointer;font:inherit;",
4107
+ "min-width:22px;height:20px;padding:0 6px;border-radius:5px;display:inline-flex;align-items:center;justify-content:center;}",
4108
+ ".dshh-imgZoomBtn:hover{color:var(--dsw-alias-label-primary,#e5e7eb);",
4109
+ "border-color:var(--dsw-alias-border-l2,rgba(127,127,127,0.45));}",
4110
+ ".dshh-imgZoomValue{min-width:74px;font-variant-numeric:tabular-nums;}",
4111
+ ".dshh-imgCell{flex:1 1 150px;min-width:0;display:flex;flex-direction:column;gap:4px;}",
4112
+ ".dshh-imgLabel{font-size:11px;color:var(--dsw-alias-label-secondary,#8a8f98);}",
4113
+ ".dshh-imgNote{font-size:11px;color:var(--dsw-alias-label-secondary,#8a8f98);padding:6px 0;}",
4114
+ // 透明像素要有底色才看得出边界:棋盘格由四段半透明渐变拼出来,两种主题都不用另配颜色。
4115
+ ".dshh-img{max-width:100%;max-height:420px;object-fit:contain;border-radius:6px;",
4116
+ "border:1px solid var(--dsw-alias-border-l1,rgba(127,127,127,0.28));",
4117
+ "background-image:linear-gradient(45deg,rgba(127,127,127,0.12) 25%,transparent 25%),",
4118
+ "linear-gradient(-45deg,rgba(127,127,127,0.12) 25%,transparent 25%),",
4119
+ "linear-gradient(45deg,transparent 75%,rgba(127,127,127,0.12) 75%),",
4120
+ "linear-gradient(-45deg,transparent 75%,rgba(127,127,127,0.12) 75%);",
4121
+ "background-size:16px 16px;background-position:0 0,0 8px,8px -8px,-8px 0;}",
4122
+ // ---- 轮尾「本轮图片改动」条 ----
4123
+ ".dshh-imgstrip{display:flex;align-items:center;gap:8px;flex-wrap:wrap;font-size:11px;",
4124
+ "color:var(--dsw-alias-label-secondary,#8a8f98);}",
4125
+ ".dshh-imgstripTitle{flex:none;}",
4126
+ ".dshh-imgstripItems{display:flex;gap:6px;flex-wrap:wrap;}",
4127
+ ".dshh-imgstripItem{display:inline-flex;align-items:center;gap:2px;padding:2px 4px 2px 2px;",
4128
+ "border:1px solid var(--dsw-alias-border-l1,rgba(127,127,127,0.25));border-radius:8px;",
4129
+ "background:var(--dsw-alias-bg-layer-1,transparent);}",
4130
+ ".dshh-imgstripOpen{display:inline-flex;align-items:center;gap:6px;border:0;background:transparent;",
4131
+ "color:inherit;cursor:pointer;padding:0;font:inherit;}",
4132
+ ".dshh-imgstripOpen:hover{color:var(--dsw-alias-label-primary,#e5e7eb);}",
4133
+ ".dshh-imgstripThumb{width:28px;height:28px;object-fit:cover;border-radius:5px;display:block;",
4134
+ "background:var(--dsw-alias-bg-layer-2,rgba(127,127,127,0.14));}",
4135
+ ".dshh-imgstripThumbEmpty{align-items:center;justify-content:center;font-size:8px;letter-spacing:0.02em;",
4136
+ "display:flex;color:var(--dsw-alias-label-secondary,#8a8f98);}",
4137
+ ".dshh-imgstripName{max-width:150px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}",
4138
+ ".dshh-imgstripTier{flex:none;opacity:.7;}",
4139
+ ".dshh-imgstripCompare{border:0;background:transparent;color:inherit;cursor:pointer;padding:0 2px;",
4140
+ "font:inherit;opacity:.7;}",
4141
+ ".dshh-imgstripCompare:hover{opacity:1;color:var(--dsw-alias-brand-primary,#4b8bf5);}",
4142
+ ".dshh-imgstripMore{flex:none;}",
3226
4143
  ".dshh-footer{display:flex;justify-content:space-between;gap:8px;padding:6px 10px;flex:none;",
3227
4144
  "border-top:1px solid var(--dsw-alias-border-l1,rgba(127,127,127,0.22));",
3228
4145
  "font-size:10.5px;color:var(--dsw-alias-label-secondary,#8a8f98);}",
@@ -3242,6 +4159,9 @@ window.__ModuleLoader__.load({
3242
4159
  "box-shadow:inset 2px 0 0 var(--dsw-alias-brand-primary,#4b8bf5);}",
3243
4160
  ".dshh-hunkMark{text-align:right;}",
3244
4161
  ".dshh-grip{position:absolute;top:0;right:0;bottom:0;width:6px;cursor:col-resize;z-index:5;}",
4162
+ // 栏间那条抓握**必须占宽**:它是提交栏与差异栏之间的真实一格。绝对定位会让它贴到面板右边缘,
4163
+ // 与外面那条叠在一起(面板右边缘那条不占宽,靠宽度公式里预留的 6px 让位)。
4164
+ ".dshh-gripBetween{position:static;flex:none;width:6px;}",
3245
4165
  ".dshh-grip:hover{background:var(--dsw-alias-border-l2,rgba(127,127,127,0.35));}",
3246
4166
  ".dshh-diffview{display:flex;flex-direction:column;flex:1 1 auto;min-width:0;min-height:0;}",
3247
4167
  ".dshh-toolbar{display:flex;align-items:center;gap:4px;padding:5px 8px;flex:none;overflow:hidden;",
@@ -3380,6 +4300,17 @@ window.__ModuleLoader__.load({
3380
4300
  };
3381
4301
  }, "dsh-hud: session rows poll");
3382
4302
 
4303
+ // 工具活动单独一条节拍(3s;宿主是内存读,密一点才看得到"正在跑")。
4304
+ ctx.effect(function () {
4305
+ var id = window.setInterval(function () {
4306
+ if (document.visibilityState === "hidden") return;
4307
+ loadTools();
4308
+ }, TOOLS_POLL_MS);
4309
+ return function () {
4310
+ window.clearInterval(id);
4311
+ };
4312
+ }, "dsh-hud: tool activity poll");
4313
+
3383
4314
  // `⋯` 菜单里的一条只读摘要:行尾拿不到常亮座位,这里是最实在的"完整信息"落点。
3384
4315
  ctx.slots.inject("sidebar.workspaces.session.menu.item", function () {
3385
4316
  return ctx.slots.register({
@@ -3393,31 +4324,6 @@ window.__ModuleLoader__.load({
3393
4324
  }, SessionMenuSummary);
3394
4325
  });
3395
4326
 
3396
- // 行首那格(常亮档):产品只给 16px,所以是一枚双字符标记。
3397
- ctx.slots.inject("sidebar.session.row.leading", function () {
3398
- return ctx.slots.register({
3399
- name: "sidebar.session.row.leading",
3400
- id: "dsh-hud-session-leading",
3401
- order: 30,
3402
- locale: NS,
3403
- inject: function () {
3404
- return { store: store };
3405
- },
3406
- }, SessionLeadingToken);
3407
- });
3408
-
3409
- // 侧栏每条会话行悬停时的那组动作:产品自己的动作也只是普通条目,这里挂一枚只读状态。
3410
- ctx.slots.inject("sidebar.workspaces.session.row.action", function () {
3411
- return ctx.slots.register({
3412
- name: "sidebar.workspaces.session.row.action",
3413
- id: "dsh-hud-session-status",
3414
- order: 30,
3415
- locale: NS,
3416
- inject: function () {
3417
- return { store: store };
3418
- },
3419
- }, SessionRowChip);
3420
- });
3421
4327
 
3422
4328
  // 悬停卡片里那块详情(宽敞的地方;动作条那枚放不下的东西都在这儿)。
3423
4329
  ctx.slots.inject("sidebar.session.row.hover", function () {
@@ -3432,6 +4338,7 @@ window.__ModuleLoader__.load({
3432
4338
  }, SessionRowHoverChip);
3433
4339
  });
3434
4340
 
4341
+
3435
4342
  ctx.slots.inject("conversation.input.dock", function () {
3436
4343
  return ctx.slots.register({
3437
4344
  name: "conversation.input.dock",
@@ -3443,6 +4350,20 @@ window.__ModuleLoader__.load({
3443
4350
  },
3444
4351
  }, HudDockPill);
3445
4352
  });
4353
+
4354
+ // 轮尾(内置「已编辑 N 个文件」卡片所在的那条尾巴)的一扇门:本轮动过图,
4355
+ // 点缩略图就在内置文档预览里打开;`⇄` 才进本面板看前后对比。
4356
+ ctx.slots.inject("conversation.chat.turnTail", function () {
4357
+ return ctx.slots.register({
4358
+ name: "conversation.chat.turnTail",
4359
+ id: "dsh-hud-turn-images",
4360
+ order: 30,
4361
+ locale: NS,
4362
+ inject: function (sessionId) {
4363
+ return { store: store, sessionId: sessionId };
4364
+ },
4365
+ }, TurnImages);
4366
+ });
3446
4367
  }
3447
4368
 
3448
4369
  exports.apply = apply;