@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/index.js CHANGED
@@ -17,6 +17,9 @@
17
17
  * - `diff { cwd, path, origPath? }` → 一个改动文件的**两侧** unified diff
18
18
  * (工作区 / 已暂存),已解析成带行号的 rows;未跟踪文件与 `--no-index` 对齐,
19
19
  * 文本按行截断、行数有上限,二进制只报 `binary`。
20
+ * - `image { cwd, path, origPath?, side?, sha? }` → 图片改动**两侧**的字节与像素尺寸
21
+ * (`kind` 为 `image` / `too-large` / `not-image` / `missing`),给二进制预览用;
22
+ * 单侧上限 `MAX_IMAGE_BYTES`,超限只回报字节数。
20
23
  * - `log { cwd, count? }` → 最近提交(短 sha、作者、日期、refs、标题),条数钳制 1..50。
21
24
  * - `commit { cwd, sha }` → 某次提交的元信息与改动清单(相对**第一个父提交**,
22
25
  * 合并提交也按第一父提交看),清单形状与 `status.changes` 一致。
@@ -256,7 +259,10 @@ export async function readStatus(cwd) {
256
259
  return { repo: null, branch: null, detached: false, oid: null, upstream: null, ahead: 0, behind: 0, dirty: false, counts: emptyCounts(), changes: [], truncated: false }
257
260
  }
258
261
 
259
- const status = await runGit(repoRoot, ['status', '--porcelain=v2', '--branch', '-z', '--untracked-files=normal'])
262
+ // `--untracked-files=all`:未跟踪**按文件**计,而不是把整个未跟踪目录折成一条。
263
+ // 不这样写的话,`?n` 会比编辑器(以及 `git status -uall`)报的数小得多 —— 目录里 200 个新文件
264
+ // 只算 1 条。代价是 git 要走一遍未跟踪树,所以清单仍受 `MAX_CHANGES` 上限保护(超出置 truncated)。
265
+ const status = await runGit(repoRoot, ['status', '--porcelain=v2', '--branch', '-z', '--untracked-files=all'])
260
266
  if (!status.ok) {
261
267
  if (status.killed) throw Object.assign(new Error('git status timed out'), { code: 'timeout' })
262
268
  throw Object.assign(new Error(status.stderr.trim() || status.message || 'git status failed'), { code: 'git-failed' })
@@ -318,12 +324,29 @@ export async function readSessionOverview() {
318
324
  mine: entry === undefined ? 0 : entry.files.size,
319
325
  possible: entry === undefined ? 0 : entry.possible.size,
320
326
  }
321
- if (row.cwd === null) return { ...summary, repo: false, error: null }
327
+ /**
328
+ * 「可能相关」里**此刻仍然有差异**的个数(与这份 status 的清单求交)。
329
+ *
330
+ * 为什么要两个数:`possible` 是"命令里提到过的路径"(历史,含已经还原/被忽略的),
331
+ * 面板那一行只数"现在还有差异"的。同一个概念两个口径,界面上必须分别说清,
332
+ * 否则就会出现"工作区干净 + 14 个可能相关"这种让人以为算错了的组合。
333
+ */
334
+ const possibleNow = entry === undefined
335
+ ? 0
336
+ : (() => {
337
+ const status = row.cwd === null ? undefined : byCwd.get(row.cwd)
338
+ if (status === undefined || status === null || Array.isArray(status.changes) !== true) return 0
339
+ let count = 0
340
+ for (const change of status.changes) if (entry.possible.has(change.file)) count += 1
341
+ return count
342
+ })()
343
+ if (row.cwd === null) return { ...summary, possibleNow: 0, repo: false, error: null }
322
344
  const status = byCwd.get(row.cwd)
323
- if (status === undefined || status === null) return { ...summary, repo: false, error: null, pending: true }
324
- if (status.error !== undefined) return { ...summary, repo: false, error: status.error }
345
+ if (status === undefined || status === null) return { ...summary, possibleNow: 0, repo: false, error: null, pending: true }
346
+ if (status.error !== undefined) return { ...summary, possibleNow: 0, repo: false, error: status.error }
325
347
  return {
326
348
  ...summary,
349
+ possibleNow,
327
350
  repo: status.repo !== null,
328
351
  branch: status.branch,
329
352
  detached: status.detached === true,
@@ -758,6 +781,78 @@ async function resolveCommit(cwd, sha) {
758
781
  }
759
782
 
760
783
  /** 这次提交的改动清单 + 元信息(只读 `git diff` / `git show`)。 */
784
+ /** 标签列表上限(超出的只报 truncated,不硬塞)。 */
785
+ const TAGS_MAX = 100
786
+
787
+ /**
788
+ * 读标签列表(`refs/tags`,按 creatordate 倒序 = 最新的在前)。
789
+ *
790
+ * 两处容易搞错、这里显式处理:
791
+ * - **附注 tag**(annotated)的 `%(objectname)` 是 **tag 对象**的 sha,不是它指向的提交;
792
+ * 要拿提交得用 `%(*objectname)`。轻量 tag 两者相同。列表里显示提交 sha(能对上“最近提交”)。
793
+ * - 主题用 `%(contents:subject)`:附注 tag 是 tag 说明的首行,轻量 tag 拿到的是提交标题 ——
794
+ * 两种情况都比只给一个名字有用;它理论上可以是多行,所以最后一个字段按“剩下全部”再取首行。
795
+ */
796
+ export async function readTags(cwd, count = TAGS_MAX) {
797
+ const root = await runGit(cwd, ['rev-parse', '--show-toplevel'])
798
+ if (!root.ok) {
799
+ if (root.killed) throw Object.assign(new Error('git rev-parse timed out'), { code: 'timeout' })
800
+ if (root.code === 'ENOENT' && !fs.existsSync(cwd)) throw Object.assign(new Error('cwd does not exist'), { code: 'no-cwd' })
801
+ if (root.code === 'ENOENT') throw Object.assign(new Error('git executable not found'), { code: 'git-missing' })
802
+ return { repo: null, tags: [], truncated: false }
803
+ }
804
+
805
+ const repoRoot = root.stdout.split('\n')[0].trim()
806
+ if (!repoRoot) return { repo: null, tags: [], truncated: false }
807
+
808
+ const requested = Number.isFinite(count) ? Math.floor(count) : TAGS_MAX
809
+ const limit = Math.min(Math.max(1, requested), TAGS_MAX)
810
+
811
+ // 多取一条用来判断“还有更多”(比事后猜“正好等于上限”准确)。
812
+ const result = await runGit(repoRoot, [
813
+ 'for-each-ref',
814
+ '--sort=-creatordate',
815
+ `--count=${String(limit + 1)}`,
816
+ '--format=%(refname:short)%00%(objecttype)%00%(objectname:short)%00%(*objectname:short)%00%(creatordate:short)%00%(creatordate:unix)%00%(contents:subject)',
817
+ 'refs/tags',
818
+ ])
819
+ // 没有 tag 时 git 也返回 0(输出为空),不是错误。
820
+ if (!result.ok) return { repo: repoRoot, tags: [], truncated: false }
821
+
822
+ const lines = result.stdout.split('\n').filter(line => line.length > 0)
823
+ const tags = lines.map((line) => {
824
+ const parts = line.split('\0')
825
+ const [name, objecttype, objectname, peeled, date, seconds] = parts
826
+ // 最后一个字段是主题:附注 tag 的说明可能是多行,这里只取首行。
827
+ const subject = parts.slice(6).join('\0').split('\n')[0] ?? ''
828
+ const at = Number(seconds)
829
+ const annotated = objecttype === 'tag'
830
+ // 附注 tag 要“剥一层”拿提交 sha;剥不出来(比如 tag 指向 blob/tree)就退回对象自身的 sha。
831
+ const sha = annotated && peeled ? peeled : objectname
832
+ return {
833
+ name: name ?? '',
834
+ sha: sha ?? '',
835
+ annotated,
836
+ date: date ?? '',
837
+ committedAt: Number.isFinite(at) ? at * 1000 : null,
838
+ subject,
839
+ }
840
+ })
841
+
842
+ // creatordate 同一个秒内的先后 git 不保证(测试里三个 tag 常在同一秒建出来),
843
+ // 再按名字倒序定死 —— 否则界面上同批 tag 每次刷新都可能换位置。
844
+ //
845
+ // 顺序:**先排序再截断**。反过来的话截出来的是 git 的任意顺序(t1、t2),排序再好也没用。
846
+ tags.sort((left, right) => {
847
+ const diff = (right.committedAt ?? 0) - (left.committedAt ?? 0)
848
+ if (diff !== 0) return diff
849
+ return left.name < right.name ? 1 : (left.name > right.name ? -1 : 0)
850
+ })
851
+
852
+ const truncated = tags.length > limit
853
+ return { repo: repoRoot, tags: tags.slice(0, limit), truncated }
854
+ }
855
+
761
856
  export async function readCommit(cwd, sha) {
762
857
  const commit = await resolveCommit(cwd, sha)
763
858
  if (!commit.repo) return { repo: null, commit: null, files: [], truncated: false }
@@ -824,6 +919,270 @@ export async function readCommitDiff(cwd, sha, file, origFile = null) {
824
919
  }
825
920
  }
826
921
 
922
+ /* --------------------------------------------- 二进制预览(图片,只读) */
923
+
924
+ /**
925
+ * 单侧图片预览的字节上限。
926
+ *
927
+ * 超限不等于"看不了这个改动":仍然回报真实字节数,只是不把内容塞进 JSON
928
+ * (base64 还要再涨 1/3,RPC 体量必须有界)。
929
+ */
930
+ export const MAX_IMAGE_BYTES = 4 * 1024 * 1024
931
+
932
+ /** PNG 的签名(8 字节)。 */
933
+ const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])
934
+
935
+ /**
936
+ * 从**内容**而不是扩展名认图片类型(扩展名会骗人:`.png` 里放 gif 很常见)。
937
+ *
938
+ * @param buffer - 文件头若干字节。
939
+ * @returns MIME 字符串,认不出返回 null。
940
+ */
941
+ export function sniffImageMime(buffer) {
942
+ if (!Buffer.isBuffer(buffer) || buffer.length < 12) return null
943
+ if (buffer.length >= 8 && buffer.subarray(0, 8).equals(PNG_SIGNATURE)) return 'image/png'
944
+ if (buffer[0] === 0xff && buffer[1] === 0xd8 && buffer[2] === 0xff) return 'image/jpeg'
945
+ const gif = buffer.subarray(0, 6).toString('latin1')
946
+ if (gif === 'GIF87a' || gif === 'GIF89a') return 'image/gif'
947
+ if (buffer.subarray(0, 4).toString('latin1') === 'RIFF' && buffer.subarray(8, 12).toString('latin1') === 'WEBP') return 'image/webp'
948
+ if (buffer.subarray(0, 2).toString('latin1') === 'BM') return 'image/bmp'
949
+ return null
950
+ }
951
+
952
+ /** PNG:IHDR 紧跟签名,宽高各 4 字节大端。 */
953
+ function pngSize(buffer) {
954
+ if (buffer.length < 24) return null
955
+ return { width: buffer.readUInt32BE(16), height: buffer.readUInt32BE(20) }
956
+ }
957
+
958
+ /** GIF:逻辑屏幕描述符,宽高各 2 字节小端。 */
959
+ function gifSize(buffer) {
960
+ if (buffer.length < 10) return null
961
+ return { width: buffer.readUInt16LE(6), height: buffer.readUInt16LE(8) }
962
+ }
963
+
964
+ /**
965
+ * JPEG:从头扫段,找 SOFn(C0–CF,但 C4/C8/CC 不是)里的高/宽。
966
+ *
967
+ * 段长字段是**含自身两字节**的长度,所以下一段起点是 `index + 2 + length`;
968
+ * 填充 0xFF、TEM 与 RSTn 都没有长度字段,只前进一字节。
969
+ */
970
+ function jpegSize(buffer) {
971
+ let index = 2
972
+ while (index + 9 < buffer.length) {
973
+ if (buffer[index] !== 0xff) {
974
+ index += 1
975
+ continue
976
+ }
977
+ const marker = buffer[index + 1]
978
+ if (marker === 0xff || marker === 0x01 || (marker >= 0xd0 && marker <= 0xd9)) {
979
+ index += 1
980
+ continue
981
+ }
982
+ const isFrame = marker >= 0xc0 && marker <= 0xcf && marker !== 0xc4 && marker !== 0xc8 && marker !== 0xcc
983
+ if (isFrame) return { width: buffer.readUInt16BE(index + 7), height: buffer.readUInt16BE(index + 5) }
984
+ const length = buffer.readUInt16BE(index + 2)
985
+ if (length < 2) return null
986
+ index += 2 + length
987
+ }
988
+ return null
989
+ }
990
+
991
+ /** WebP:VP8X 是 24 位减一、VP8(有损)内嵌 14 位、VP8L(无损)打包在 32 位里。 */
992
+ function webpSize(buffer) {
993
+ if (buffer.length < 30) return null
994
+ const chunk = buffer.subarray(12, 16).toString('latin1')
995
+ if (chunk === 'VP8X') return { width: buffer.readUIntLE(24, 3) + 1, height: buffer.readUIntLE(27, 3) + 1 }
996
+ if (chunk === 'VP8 ' && buffer[23] === 0x9d && buffer[24] === 0x01 && buffer[25] === 0x2a) {
997
+ return { width: buffer.readUInt16LE(26) & 0x3fff, height: buffer.readUInt16LE(28) & 0x3fff }
998
+ }
999
+ if (chunk === 'VP8L' && buffer[20] === 0x2f) {
1000
+ const packed = buffer.readUInt32LE(21)
1001
+ return { width: (packed & 0x3fff) + 1, height: ((packed >> 14) & 0x3fff) + 1 }
1002
+ }
1003
+ return null
1004
+ }
1005
+
1006
+ /** BMP:BITMAPINFOHEADER 的宽为有符号、高可为负(自上而下)。 */
1007
+ function bmpSize(buffer) {
1008
+ if (buffer.length < 26) return null
1009
+ const width = buffer.readInt32LE(18)
1010
+ const height = buffer.readInt32LE(22)
1011
+ if (width <= 0 || height === 0) return null
1012
+ return { width, height: Math.abs(height) }
1013
+ }
1014
+
1015
+ /**
1016
+ * 读出图片的像素尺寸。纯函数,只读头部,认不出返回 `null`(绝不抛)。
1017
+ *
1018
+ * @param buffer - 文件内容(头部足够即可,但这里拿的是完整内容)。
1019
+ * @param mime - `sniffImageMime` 的结果。
1020
+ * @returns `{ width, height }` 或 null。
1021
+ */
1022
+ export function readImageSize(buffer, mime) {
1023
+ try {
1024
+ if (mime === 'image/png') return pngSize(buffer)
1025
+ if (mime === 'image/jpeg') return jpegSize(buffer)
1026
+ if (mime === 'image/gif') return gifSize(buffer)
1027
+ if (mime === 'image/webp') return webpSize(buffer)
1028
+ if (mime === 'image/bmp') return bmpSize(buffer)
1029
+ return null
1030
+ }
1031
+ catch {
1032
+ return null
1033
+ }
1034
+ }
1035
+
1036
+ /**
1037
+ * `runGit` 的字节版。
1038
+ *
1039
+ * `cat-file blob` 的输出是任意二进制,按 utf8 解码会毁掉它(非法序列变成 U+FFFD,
1040
+ * 再编码回去就不是原文件了),所以这条路径必须单独走 buffer 编码。
1041
+ */
1042
+ function runGitBytes(cwd, args, maxBytes) {
1043
+ return new Promise((resolve) => {
1044
+ execFile(
1045
+ 'git',
1046
+ args,
1047
+ { cwd, timeout: GIT_TIMEOUT_MS, maxBuffer: maxBytes, windowsHide: true, encoding: 'buffer' },
1048
+ (error, stdout, stderr) => {
1049
+ if (!error) {
1050
+ resolve({ ok: true, stdout })
1051
+ return
1052
+ }
1053
+ resolve({
1054
+ ok: false,
1055
+ stdout,
1056
+ stderr: stderr ? stderr.toString('utf8') : '',
1057
+ killed: Boolean(error.killed) || Boolean(error.signal),
1058
+ code: typeof error.code === 'number' ? error.code : (error.code ?? null),
1059
+ message: error instanceof Error ? error.message : String(error),
1060
+ })
1061
+ },
1062
+ )
1063
+ })
1064
+ }
1065
+
1066
+ /** 一个 git 对象的字节数;对象不存在(新文件还没有索引副本)返回 null。 */
1067
+ async function gitBlobSize(repoRoot, spec) {
1068
+ const result = await runGit(repoRoot, ['cat-file', '-s', spec])
1069
+ if (!result.ok) return null
1070
+ const size = Number.parseInt(result.stdout.trim(), 10)
1071
+ return Number.isFinite(size) && size >= 0 ? size : null
1072
+ }
1073
+
1074
+ /** 判断一侧的内容:能预览就带上 base64 与像素尺寸,否则只说"为什么不能"。 */
1075
+ function describeSide(buffer) {
1076
+ const bytes = buffer.length
1077
+ const mime = sniffImageMime(buffer)
1078
+ if (mime === null) return { kind: 'not-image', bytes }
1079
+ const size = readImageSize(buffer, mime)
1080
+ return {
1081
+ kind: 'image',
1082
+ mime,
1083
+ bytes,
1084
+ width: size === null ? null : size.width,
1085
+ height: size === null ? null : size.height,
1086
+ base64: buffer.toString('base64'),
1087
+ }
1088
+ }
1089
+
1090
+ /** 读工作区里的一个文件;不是普通文件、或超过上限,都只回报状态。 */
1091
+ async function readWorktreeSide(repoRoot, file) {
1092
+ const full = path.resolve(repoRoot, file)
1093
+ const rootPrefix = repoRoot.endsWith(path.sep) ? repoRoot : repoRoot + path.sep
1094
+ // validateDiffPath 已经挡掉绝对路径与 `..`;这里再确认落在仓库内(软链接不越界)。
1095
+ if (!full.startsWith(rootPrefix)) return { kind: 'missing' }
1096
+ let stats
1097
+ try {
1098
+ stats = await fs.promises.stat(full)
1099
+ }
1100
+ catch {
1101
+ return { kind: 'missing' }
1102
+ }
1103
+ if (!stats.isFile()) return { kind: 'missing' }
1104
+ if (stats.size > MAX_IMAGE_BYTES) return { kind: 'too-large', bytes: stats.size, limit: MAX_IMAGE_BYTES }
1105
+ try {
1106
+ return describeSide(await fs.promises.readFile(full))
1107
+ }
1108
+ catch {
1109
+ return { kind: 'missing' }
1110
+ }
1111
+ }
1112
+
1113
+ /** 读一个 git 对象(`<rev>:<path>`,rev 为空串即索引):不存在即 missing,超限只回报字节数。 */
1114
+ async function readGitSide(repoRoot, rev, file) {
1115
+ const spec = `${rev}:${file}`
1116
+ const size = await gitBlobSize(repoRoot, spec)
1117
+ if (size === null) return { kind: 'missing' }
1118
+ if (size > MAX_IMAGE_BYTES) return { kind: 'too-large', bytes: size, limit: MAX_IMAGE_BYTES }
1119
+ const result = await runGitBytes(repoRoot, ['cat-file', 'blob', spec], MAX_IMAGE_BYTES + 64 * 1024)
1120
+ if (!result.ok) {
1121
+ if (result.killed) throw Object.assign(new Error('git cat-file timed out'), { code: 'timeout' })
1122
+ return { kind: 'missing' }
1123
+ }
1124
+ return describeSide(result.stdout)
1125
+ }
1126
+
1127
+ /**
1128
+ * 读一个二进制(图片)改动的两侧字节与像素尺寸,全部只读。
1129
+ *
1130
+ * `side` 与文本 diff 的两枚页签一一对应,这样"未暂存 / 已暂存"在两个视图里指的是同一件事:
1131
+ * - `worktree`(未暂存):基线 = 索引 `:<path>`,新 = 工作区文件;
1132
+ * - `index`(已暂存):基线 = `HEAD`,新 = 索引 `:<path>`;
1133
+ * - 给了 `sha`(提交明细):基线 = 第一父提交(根提交则为空),新 = 该提交。
1134
+ *
1135
+ * 两侧各自回答"能不能预览":`kind` 为 `image` / `too-large` / `not-image` / `missing`。
1136
+ * 任一侧不可预览都不算失败 —— 另一侧照样能看。
1137
+ *
1138
+ * @param cwd - 会话工作区(绝对路径)。
1139
+ * @param file - 仓库内相对路径。
1140
+ * @param origFile - 重命名/复制的原路径,可选。
1141
+ * @param side - `'worktree'` | `'index'`,给了 sha 时忽略。
1142
+ * @param sha - 提交 sha(提交明细用),可选。
1143
+ * @returns `{ repo, path, origPath, side, sha, before, after }`。
1144
+ */
1145
+ export async function readImagePair(cwd, file, origFile = null, side = 'worktree', sha = null) {
1146
+ const base = { path: file, origPath: origFile ?? null, side: sha === null ? side : 'commit', sha }
1147
+ const empty = { repo: null, ...base, before: { kind: 'missing' }, after: { kind: 'missing' } }
1148
+
1149
+ if (sha !== null) {
1150
+ const commit = await resolveCommit(cwd, sha)
1151
+ if (!commit.repo) return empty
1152
+ return {
1153
+ repo: commit.repo,
1154
+ shortSha: commit.shortSha,
1155
+ ...base,
1156
+ before: commit.base === null ? { kind: 'missing' } : await readGitSide(commit.repo, commit.base, origFile ?? file),
1157
+ after: await readGitSide(commit.repo, commit.sha, file),
1158
+ }
1159
+ }
1160
+
1161
+ const root = await runGit(cwd, ['rev-parse', '--show-toplevel'])
1162
+ if (!root.ok) {
1163
+ if (root.killed) throw Object.assign(new Error('git rev-parse timed out'), { code: 'timeout' })
1164
+ if (root.code === 'ENOENT' && !fs.existsSync(cwd)) throw Object.assign(new Error('cwd does not exist'), { code: 'no-cwd' })
1165
+ if (root.code === 'ENOENT') throw Object.assign(new Error('git executable not found'), { code: 'git-missing' })
1166
+ return empty
1167
+ }
1168
+
1169
+ const repoRoot = root.stdout.split('\n')[0].trim()
1170
+ if (side === 'index') {
1171
+ return {
1172
+ repo: repoRoot,
1173
+ ...base,
1174
+ before: await readGitSide(repoRoot, 'HEAD', origFile ?? file),
1175
+ after: await readGitSide(repoRoot, '', file),
1176
+ }
1177
+ }
1178
+ return {
1179
+ repo: repoRoot,
1180
+ ...base,
1181
+ before: await readGitSide(repoRoot, '', origFile ?? file),
1182
+ after: await readWorktreeSide(repoRoot, file),
1183
+ }
1184
+ }
1185
+
827
1186
  /* ------------------------------------------------- 会话改动(按会话归因) */
828
1187
 
829
1188
  /**
@@ -858,6 +1217,161 @@ const SESSION_COMMAND_MAX = 20
858
1217
  const COMMAND_TEXT_MAX = 140
859
1218
  /** 一条命令里最多抽多少个路径(防御性上限)。 */
860
1219
  const COMMAND_PATHS_MAX = 20
1220
+ /** 每个会话留多少条"最近工具调用"(胶囊悬停里列最近几条)。 */
1221
+ const TOOL_RECENT_MAX = 12
1222
+ /** 最多同时记多少个会话的工具活动(与归因那份各自独立淘汰)。 */
1223
+ const TOOL_SESSIONS_MAX = 32
1224
+ /** 每个会话最多记多少个技能(超出的只计数不列名)。 */
1225
+ const TOOL_SKILLS_MAX = 20
1226
+ /** 参数摘要的显示上限(超长命令/路径截断,别把胶囊撑爆)。 */
1227
+ const TOOL_SUMMARY_MAX = 60
1228
+
1229
+ /**
1230
+ * sessionId → { active, recent, counts, total }:这个会话**跑到哪些工具**。
1231
+ *
1232
+ * 与归因那份记录分开存:归因关心"改了哪些文件",这份关心"跑了什么"。
1233
+ */
1234
+ const sessionTools = new Map()
1235
+
1236
+ /** 取(必要时新建)某个会话的工具活动记录,并做数量上限淘汰。 */
1237
+ function sessionToolEntry(sessionId) {
1238
+ let entry = sessionTools.get(sessionId)
1239
+ if (entry === undefined) {
1240
+ entry = { active: null, recent: [], counts: new Map(), status: new Map(), skills: new Map(), total: 0 }
1241
+ sessionTools.set(sessionId, entry)
1242
+ if (sessionTools.size > TOOL_SESSIONS_MAX) {
1243
+ const oldest = sessionTools.keys().next().value
1244
+ if (oldest !== undefined && oldest !== sessionId) sessionTools.delete(oldest)
1245
+ }
1246
+ }
1247
+ return entry
1248
+ }
1249
+
1250
+ /**
1251
+ * 参数里可以安全拿出来做"一行摘要"的键。
1252
+ *
1253
+ * 只认这些**路径/命令类**的键:工具参数里可能带着整个文件内容(write 的 `content`),
1254
+ * 随便取"第一个字符串"就会把内容贴到界面上 —— 所以认不出来就干脆不写摘要。
1255
+ */
1256
+ const TOOL_SUMMARY_KEYS = ['command', 'cmd', 'script', 'filePath', 'path', 'file', 'pattern', 'query', 'url']
1257
+
1258
+ /** 「调用技能」那件工具的工具名(`dsh-tool-skill` 注册的名字)。 */
1259
+ const SKILL_TOOL_NAME = 'skill'
1260
+
1261
+ /**
1262
+ * 技能名:`skill` 工具的入参是 `{ name: '技能名' }`。
1263
+ *
1264
+ * 只对这一个工具认 `name` 键 —— 别的工具用 `name` 表达别的东西,不能被当技能名抓走。
1265
+ */
1266
+ function skillNameOf(exec) {
1267
+ if (exec === null || typeof exec !== 'object' || exec.name !== SKILL_TOOL_NAME) return null
1268
+ const args = exec.arguments
1269
+ if (args === null || typeof args !== 'object') return null
1270
+ const value = args.name
1271
+ return typeof value === 'string' && value.trim().length > 0 ? value.trim().slice(0, TOOL_SUMMARY_MAX) : null
1272
+ }
1273
+
1274
+ /** 从工具调用参数里取一行摘要(认不出来返回 null,不用兜底去猜)。 */
1275
+ function toolSummaryOf(exec) {
1276
+ const skill = skillNameOf(exec)
1277
+ if (skill !== null) return skill
1278
+ const args = exec && exec.arguments
1279
+ if (args === null || typeof args !== 'object') return null
1280
+ for (const key of TOOL_SUMMARY_KEYS) {
1281
+ const value = args[key]
1282
+ if (typeof value === 'string' && value.trim().length > 0) {
1283
+ return value.replace(/\s+/g, ' ').trim().slice(0, TOOL_SUMMARY_MAX)
1284
+ }
1285
+ }
1286
+ return null
1287
+ }
1288
+
1289
+ /** 工具开跑:记下"现在是哪个工具、什么时候开始的"(供胶囊显示实时秒数)。 */
1290
+ function noteToolStart(exec) {
1291
+ try {
1292
+ const identity = sessionIdentityOf(exec)
1293
+ if (identity === null) return
1294
+ const entry = sessionToolEntry(identity.sessionId)
1295
+ entry.active = {
1296
+ callId: identity.callId,
1297
+ name: typeof exec.name === 'string' && exec.name.length > 0 ? exec.name : '?',
1298
+ summary: toolSummaryOf(exec),
1299
+ startedAt: Date.now(),
1300
+ }
1301
+ entry.total += 1
1302
+ const name = entry.active.name
1303
+ entry.counts.set(name, (entry.counts.get(name) || 0) + 1)
1304
+ const skill = skillNameOf(exec)
1305
+ if (skill !== null) {
1306
+ const seen = entry.skills.get(skill)
1307
+ entry.skills.set(skill, { count: (seen ? seen.count : 0) + 1, ok: seen ? seen.ok : null })
1308
+ }
1309
+ } catch {
1310
+ // 旁听失败不影响工具本身。
1311
+ }
1312
+ }
1313
+
1314
+ /** 工具结束:把这一次挪进"最近",并清掉 active(只有同一个 callId 才清,避免并发调用互相抹掉)。 */
1315
+ function noteToolFinish(exec, result) {
1316
+ try {
1317
+ const identity = sessionIdentityOf(exec)
1318
+ if (identity === null) return
1319
+ const entry = sessionToolEntry(identity.sessionId)
1320
+ const active = entry.active
1321
+ const startedAt = active !== null && active.callId === identity.callId ? active.startedAt : null
1322
+ entry.recent.push({
1323
+ name: active !== null && active.callId === identity.callId ? active.name : (typeof exec.name === 'string' ? exec.name : '?'),
1324
+ summary: active !== null && active.callId === identity.callId ? active.summary : toolSummaryOf(exec),
1325
+ ok: !resultFailed(result),
1326
+ at: Date.now(),
1327
+ ms: startedAt === null ? null : Math.max(0, Date.now() - startedAt),
1328
+ })
1329
+ if (entry.recent.length > TOOL_RECENT_MAX) entry.recent.splice(0, entry.recent.length - TOOL_RECENT_MAX)
1330
+ // 每个工具"最近一次调用"的结果:胶囊上每个工具前面那个 ✓/✗ 用它。
1331
+ const finished = entry.recent[entry.recent.length - 1]
1332
+ if (finished !== undefined) entry.status.set(finished.name, finished.ok === true)
1333
+ // 技能自己的最近一次结果(胶囊上可以据此打 ✓/✗)。
1334
+ const skill = skillNameOf(exec)
1335
+ if (skill !== null && entry.skills.has(skill)) {
1336
+ entry.skills.set(skill, { count: entry.skills.get(skill).count, ok: !resultFailed(result) })
1337
+ }
1338
+ if (active !== null && active.callId === identity.callId) entry.active = null
1339
+ } catch {
1340
+ // 同上。
1341
+ }
1342
+ }
1343
+
1344
+ /**
1345
+ * 读出某个会话的工具活动(端点载荷)。
1346
+ *
1347
+ * `active` 带 `startedAt`:实时秒数由客户端本地计时(否则要为了一个秒数把轮询压到亚秒级 ✗)。
1348
+ */
1349
+ export function readSessionTools(sessionId) {
1350
+ const entry = sessionId === null || sessionId === undefined ? undefined : sessionTools.get(sessionId)
1351
+ if (entry === undefined) {
1352
+ return { sessionId: sessionId ?? null, tracking: true, active: null, recent: [], counts: [], skills: [], skillsDropped: 0, total: 0 }
1353
+ }
1354
+ const counts = [...entry.counts].map(([name, count]) => ({
1355
+ name,
1356
+ count,
1357
+ // `null` = 还没看到它结束过(比如正在跑第一次),别替它编一个 ✓。
1358
+ ok: entry.status.has(name) ? entry.status.get(name) : null,
1359
+ }))
1360
+ counts.sort((left, right) => right.count - left.count || (left.name < right.name ? -1 : 1))
1361
+ // 技能按次数排序(用得多在前),超过上限只报前若干个。
1362
+ const skills = [...entry.skills].map(([name, item]) => ({ name, count: item.count, ok: item.ok }))
1363
+ skills.sort((left, right) => right.count - left.count || (left.name < right.name ? -1 : 1))
1364
+ return {
1365
+ sessionId,
1366
+ tracking: true,
1367
+ active: entry.active === null ? null : { ...entry.active },
1368
+ recent: entry.recent.slice(-5).reverse(),
1369
+ counts,
1370
+ skills: skills.slice(0, TOOL_SKILLS_MAX),
1371
+ skillsDropped: Math.max(0, skills.length - TOOL_SKILLS_MAX),
1372
+ total: entry.total,
1373
+ }
1374
+ }
861
1375
 
862
1376
  /** sessionId → { cwd, since, files: Map<relPath, {...}>, dropped, unconfirmed }。 */
863
1377
  const sessionChanges = new Map()
@@ -1316,8 +1830,15 @@ function trackSessionChanges(ctx) {
1316
1830
  }),
1317
1831
  // 命令旁听:`tools/post-execute` 是 waterfall,**必须把 next() 的判定原样透传**
1318
1832
  // (兜底是 `{ kind: 'accept' }`,吞掉就等于把工具结果的裁决改掉了)。
1833
+ // 工具开跑:记"现在在跑哪个工具"(胶囊显示实时秒数用)。
1834
+ // `tools/pre-execute` 同样是 waterfall(兜底 `{ kind: 'allow' }`),必须原样透传判定。
1835
+ ctx.on('tools/pre-execute', (exec, next) => {
1836
+ noteToolStart(exec)
1837
+ return next()
1838
+ }),
1319
1839
  ctx.on('tools/post-execute', (exec, result, next) => {
1320
1840
  noteShellCommand(exec, result)
1841
+ noteToolFinish(exec, result)
1321
1842
  return next()
1322
1843
  }),
1323
1844
  ]
@@ -1351,6 +1872,7 @@ function trackSessionChanges(ctx) {
1351
1872
  for (const stop of stops) if (typeof stop === 'function') stop()
1352
1873
  sessionChanges.clear()
1353
1874
  pendingChangeIntents.clear()
1875
+ sessionTools.clear()
1354
1876
  sessionChangeTrackingSince = null
1355
1877
  }
1356
1878
  }, 'dsh-hud: session change tracking')
@@ -1373,6 +1895,12 @@ const ENDPOINTS = {
1373
1895
  return status
1374
1896
  },
1375
1897
 
1898
+ async 'session-tools'(args) {
1899
+ const input = args && typeof args === 'object' ? args : {}
1900
+ const sessionId = typeof input.sessionId === 'string' && input.sessionId.length > 0 ? input.sessionId : null
1901
+ return readSessionTools(sessionId)
1902
+ },
1903
+
1376
1904
  async 'session-statuses'() {
1377
1905
  return readSessionOverview()
1378
1906
  },
@@ -1394,6 +1922,36 @@ const ENDPOINTS = {
1394
1922
  return readDiff(cwd.value, file.value, origFile)
1395
1923
  },
1396
1924
 
1925
+ /**
1926
+ * 图片预览:一个二进制改动两侧的字节与像素尺寸。
1927
+ *
1928
+ * `diff` 对二进制只回一句 `binary: true`(文本 diff 里没有可看的东西),
1929
+ * 浏览器半靠这个端点把"改了哪张图、尺寸变没变"画出来。只读,不写 index / 工作区。
1930
+ */
1931
+ async image(args) {
1932
+ const input = args && typeof args === 'object' ? args : {}
1933
+ const cwd = validateCwd(input.cwd)
1934
+ if (!cwd.ok) throw Object.assign(new Error(cwd.message), { code: cwd.code })
1935
+ const file = validateDiffPath(input.path)
1936
+ if (!file.ok) throw Object.assign(new Error(file.message), { code: file.code })
1937
+
1938
+ let origFile = null
1939
+ if (input.origPath !== undefined && input.origPath !== null && input.origPath !== '') {
1940
+ const checked = validateDiffPath(input.origPath, 'origPath')
1941
+ if (!checked.ok) throw Object.assign(new Error(checked.message), { code: checked.code })
1942
+ origFile = checked.value
1943
+ }
1944
+
1945
+ let sha = null
1946
+ if (input.sha !== undefined && input.sha !== null && input.sha !== '') {
1947
+ const checked = validateCommitSha(input.sha)
1948
+ if (!checked.ok) throw Object.assign(new Error(checked.message), { code: checked.code })
1949
+ sha = checked.value
1950
+ }
1951
+
1952
+ return readImagePair(cwd.value, file.value, origFile, input.side === 'index' ? 'index' : 'worktree', sha)
1953
+ },
1954
+
1397
1955
  async log(args) {
1398
1956
  const input = args && typeof args === 'object' ? args : {}
1399
1957
  const cwd = validateCwd(input.cwd)
@@ -1401,6 +1959,13 @@ const ENDPOINTS = {
1401
1959
  return readLog(cwd.value, input.count)
1402
1960
  },
1403
1961
 
1962
+ /** 标签列表(只读)。点一个 tag 复用 `commit` 那条管道去看它指向的提交。 */
1963
+ async tags(args) {
1964
+ const input = args && typeof args === 'object' ? args : {}
1965
+ const cwd = validateCwd(input.cwd)
1966
+ if (!cwd.ok) throw Object.assign(new Error(cwd.message), { code: cwd.code })
1967
+ return readTags(cwd.value, input.count)
1968
+ },
1404
1969
  async commit(args) {
1405
1970
  const input = args && typeof args === 'object' ? args : {}
1406
1971
  const cwd = validateCwd(input.cwd)