@konnga/dsh-hud 0.8.0 → 0.9.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/index.js CHANGED
@@ -17,6 +17,13 @@
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`,超限只回报字节数。
23
+ * - `session-time { sessionId }` → 该会话的创建时刻与累计墙钟(`llmMs` / `toolMs` /
24
+ * `turns` / `steps`,来自内置 `sessionStats` 投影;服务缺失时 `available: false`)。
25
+ * - `prompt-history { sessionId, limit? }` → 该会话的用户 prompt(**新的在前**,来自内置
26
+ * `turnOutline` 投影;相邻重复只留一条,默认最多 100 条)。
20
27
  * - `log { cwd, count? }` → 最近提交(短 sha、作者、日期、refs、标题),条数钳制 1..50。
21
28
  * - `commit { cwd, sha }` → 某次提交的元信息与改动清单(相对**第一个父提交**,
22
29
  * 合并提交也按第一父提交看),清单形状与 `status.changes` 一致。
@@ -273,6 +280,90 @@ export async function readStatus(cwd) {
273
280
  /** 一次最多看多少个工作区(多个会话可能共用一个目录,按目录去重之后再算)。 */
274
281
  const ROW_STATUS_MAX_WORKTREES = 8
275
282
 
283
+ /**
284
+ * 一个会话的用户 prompt 历史,**新的在前**(给输入框的 `↑` 召回用)。
285
+ *
286
+ * 来源是内置 `turnOutline` 投影的每条 `prompt` —— 也就是会话日志里真正的用户消息,
287
+ * 不是我们自己记的一份。截断到 `limit` 条,相邻重复(同一句连发两次)只留一条。
288
+ * 投影没注册、会话不在(还没恢复进存储 / 已归档)时回 `available: false` 与空列表。
289
+ *
290
+ * @param sessionId - 会话 id。
291
+ * @param limit - 最多返回多少条(1..200,默认 {@link PROMPT_HISTORY_MAX})。
292
+ */
293
+ export function readPromptHistory(sessionId, limit = PROMPT_HISTORY_MAX) {
294
+ const empty = { available: false, sessionId, prompts: [] }
295
+ if (typeof sessionId !== 'string' || sessionId.length === 0) return empty
296
+ const max = typeof limit === 'number' && Number.isFinite(limit) ? Math.min(200, Math.max(1, Math.trunc(limit))) : PROMPT_HISTORY_MAX
297
+
298
+ const sessionStore = sessionsService()
299
+ if (sessionStore === null) return empty
300
+ let live = []
301
+ try {
302
+ live = sessionStore.list()
303
+ } catch {
304
+ return empty
305
+ }
306
+ const session = live.find(item => item && item.header && item.header.id === sessionId)
307
+ if (session === undefined) return empty
308
+
309
+ const outline = sessionOutlineReader === null ? null : sessionOutlineReader(session)
310
+ const turns = outline !== null && Array.isArray(outline.turns) ? outline.turns : null
311
+ if (turns === null) return empty
312
+
313
+ const prompts = []
314
+ for (let index = turns.length - 1; index >= 0 && prompts.length < max; index -= 1) {
315
+ const turn = turns[index]
316
+ const raw = turn && typeof turn.prompt === 'string' ? turn.prompt.trim() : ''
317
+ if (raw.length === 0) continue
318
+ if (prompts.length > 0 && prompts[prompts.length - 1] === raw) continue
319
+ prompts.push(raw)
320
+ }
321
+ return { available: true, sessionId, prompts }
322
+ }
323
+
324
+ /**
325
+ * 一个会话的"运行时长"事实:创建时刻 + 内置 `sessionStats` 的累计数字。
326
+ *
327
+ * 与 codex-hud 的会话时长口径一致:**挂钟时长 = 现在 − 会话创建时刻**(含等待与空闲),
328
+ * 另外把"真的在干活"的两段(模型墙钟 `llmMs`、工具墙钟 `toolMs`)一并给出,让胶囊的悬停
329
+ * 能说清"74 小时里其实只有 1 小时 52 分在跑"。
330
+ *
331
+ * 只读;`sessions` 服务没到齐或会话已不在(归档、子会话已回收)时回 `available: false`,
332
+ * 客户端据此不画这枚胶囊。
333
+ *
334
+ * @param sessionId - 会话 id。
335
+ * @returns `{ available, sessionId, createdAt, turns, steps, llmMs, toolMs, now }`。
336
+ */
337
+ export function readSessionTime(sessionId) {
338
+ const now = Date.now()
339
+ const empty = { available: false, sessionId, createdAt: null, turns: null, steps: null, llmMs: null, toolMs: null, now }
340
+ if (typeof sessionId !== 'string' || sessionId.length === 0) return empty
341
+
342
+ const sessionStore = sessionsService()
343
+ if (sessionStore === null) return empty
344
+ let live = []
345
+ try {
346
+ live = sessionStore.list()
347
+ } catch {
348
+ return empty
349
+ }
350
+ const session = live.find(item => item && item.header && item.header.id === sessionId)
351
+ if (session === undefined) return empty
352
+
353
+ const createdAt = typeof session.header.createdAt === 'number' ? session.header.createdAt : null
354
+ const stats = sessionStatsReader === null ? null : sessionStatsReader(session)
355
+ return {
356
+ available: true,
357
+ sessionId,
358
+ createdAt,
359
+ turns: stats === null ? null : stats.turns,
360
+ steps: stats === null ? null : stats.steps,
361
+ llmMs: stats === null ? null : stats.llmMs,
362
+ toolMs: stats === null ? null : stats.toolMs,
363
+ now,
364
+ }
365
+ }
366
+
276
367
  /**
277
368
  * 会话一览:每个活着的会话 → (它那个工作区的 git 摘要)+(这个会话自己的改动计数)。
278
369
  *
@@ -281,10 +372,11 @@ const ROW_STATUS_MAX_WORKTREES = 8
281
372
  * 不用再问 git)。**不返回 `changes`**:这一行只要摘要,别把清单也拖过来。
282
373
  */
283
374
  export async function readSessionOverview() {
284
- if (hostSessions === null) return { sessions: [], unavailable: true, truncated: false, worktrees: 0 }
375
+ const sessionStore = sessionsService()
376
+ if (sessionStore === null) return { sessions: [], unavailable: true, truncated: false, worktrees: 0 }
285
377
  let live = []
286
378
  try {
287
- live = hostSessions.list()
379
+ live = sessionStore.list()
288
380
  } catch {
289
381
  return { sessions: [], unavailable: true, truncated: false, worktrees: 0 }
290
382
  }
@@ -916,6 +1008,270 @@ export async function readCommitDiff(cwd, sha, file, origFile = null) {
916
1008
  }
917
1009
  }
918
1010
 
1011
+ /* --------------------------------------------- 二进制预览(图片,只读) */
1012
+
1013
+ /**
1014
+ * 单侧图片预览的字节上限。
1015
+ *
1016
+ * 超限不等于"看不了这个改动":仍然回报真实字节数,只是不把内容塞进 JSON
1017
+ * (base64 还要再涨 1/3,RPC 体量必须有界)。
1018
+ */
1019
+ export const MAX_IMAGE_BYTES = 4 * 1024 * 1024
1020
+
1021
+ /** PNG 的签名(8 字节)。 */
1022
+ const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])
1023
+
1024
+ /**
1025
+ * 从**内容**而不是扩展名认图片类型(扩展名会骗人:`.png` 里放 gif 很常见)。
1026
+ *
1027
+ * @param buffer - 文件头若干字节。
1028
+ * @returns MIME 字符串,认不出返回 null。
1029
+ */
1030
+ export function sniffImageMime(buffer) {
1031
+ if (!Buffer.isBuffer(buffer) || buffer.length < 12) return null
1032
+ if (buffer.length >= 8 && buffer.subarray(0, 8).equals(PNG_SIGNATURE)) return 'image/png'
1033
+ if (buffer[0] === 0xff && buffer[1] === 0xd8 && buffer[2] === 0xff) return 'image/jpeg'
1034
+ const gif = buffer.subarray(0, 6).toString('latin1')
1035
+ if (gif === 'GIF87a' || gif === 'GIF89a') return 'image/gif'
1036
+ if (buffer.subarray(0, 4).toString('latin1') === 'RIFF' && buffer.subarray(8, 12).toString('latin1') === 'WEBP') return 'image/webp'
1037
+ if (buffer.subarray(0, 2).toString('latin1') === 'BM') return 'image/bmp'
1038
+ return null
1039
+ }
1040
+
1041
+ /** PNG:IHDR 紧跟签名,宽高各 4 字节大端。 */
1042
+ function pngSize(buffer) {
1043
+ if (buffer.length < 24) return null
1044
+ return { width: buffer.readUInt32BE(16), height: buffer.readUInt32BE(20) }
1045
+ }
1046
+
1047
+ /** GIF:逻辑屏幕描述符,宽高各 2 字节小端。 */
1048
+ function gifSize(buffer) {
1049
+ if (buffer.length < 10) return null
1050
+ return { width: buffer.readUInt16LE(6), height: buffer.readUInt16LE(8) }
1051
+ }
1052
+
1053
+ /**
1054
+ * JPEG:从头扫段,找 SOFn(C0–CF,但 C4/C8/CC 不是)里的高/宽。
1055
+ *
1056
+ * 段长字段是**含自身两字节**的长度,所以下一段起点是 `index + 2 + length`;
1057
+ * 填充 0xFF、TEM 与 RSTn 都没有长度字段,只前进一字节。
1058
+ */
1059
+ function jpegSize(buffer) {
1060
+ let index = 2
1061
+ while (index + 9 < buffer.length) {
1062
+ if (buffer[index] !== 0xff) {
1063
+ index += 1
1064
+ continue
1065
+ }
1066
+ const marker = buffer[index + 1]
1067
+ if (marker === 0xff || marker === 0x01 || (marker >= 0xd0 && marker <= 0xd9)) {
1068
+ index += 1
1069
+ continue
1070
+ }
1071
+ const isFrame = marker >= 0xc0 && marker <= 0xcf && marker !== 0xc4 && marker !== 0xc8 && marker !== 0xcc
1072
+ if (isFrame) return { width: buffer.readUInt16BE(index + 7), height: buffer.readUInt16BE(index + 5) }
1073
+ const length = buffer.readUInt16BE(index + 2)
1074
+ if (length < 2) return null
1075
+ index += 2 + length
1076
+ }
1077
+ return null
1078
+ }
1079
+
1080
+ /** WebP:VP8X 是 24 位减一、VP8(有损)内嵌 14 位、VP8L(无损)打包在 32 位里。 */
1081
+ function webpSize(buffer) {
1082
+ if (buffer.length < 30) return null
1083
+ const chunk = buffer.subarray(12, 16).toString('latin1')
1084
+ if (chunk === 'VP8X') return { width: buffer.readUIntLE(24, 3) + 1, height: buffer.readUIntLE(27, 3) + 1 }
1085
+ if (chunk === 'VP8 ' && buffer[23] === 0x9d && buffer[24] === 0x01 && buffer[25] === 0x2a) {
1086
+ return { width: buffer.readUInt16LE(26) & 0x3fff, height: buffer.readUInt16LE(28) & 0x3fff }
1087
+ }
1088
+ if (chunk === 'VP8L' && buffer[20] === 0x2f) {
1089
+ const packed = buffer.readUInt32LE(21)
1090
+ return { width: (packed & 0x3fff) + 1, height: ((packed >> 14) & 0x3fff) + 1 }
1091
+ }
1092
+ return null
1093
+ }
1094
+
1095
+ /** BMP:BITMAPINFOHEADER 的宽为有符号、高可为负(自上而下)。 */
1096
+ function bmpSize(buffer) {
1097
+ if (buffer.length < 26) return null
1098
+ const width = buffer.readInt32LE(18)
1099
+ const height = buffer.readInt32LE(22)
1100
+ if (width <= 0 || height === 0) return null
1101
+ return { width, height: Math.abs(height) }
1102
+ }
1103
+
1104
+ /**
1105
+ * 读出图片的像素尺寸。纯函数,只读头部,认不出返回 `null`(绝不抛)。
1106
+ *
1107
+ * @param buffer - 文件内容(头部足够即可,但这里拿的是完整内容)。
1108
+ * @param mime - `sniffImageMime` 的结果。
1109
+ * @returns `{ width, height }` 或 null。
1110
+ */
1111
+ export function readImageSize(buffer, mime) {
1112
+ try {
1113
+ if (mime === 'image/png') return pngSize(buffer)
1114
+ if (mime === 'image/jpeg') return jpegSize(buffer)
1115
+ if (mime === 'image/gif') return gifSize(buffer)
1116
+ if (mime === 'image/webp') return webpSize(buffer)
1117
+ if (mime === 'image/bmp') return bmpSize(buffer)
1118
+ return null
1119
+ }
1120
+ catch {
1121
+ return null
1122
+ }
1123
+ }
1124
+
1125
+ /**
1126
+ * `runGit` 的字节版。
1127
+ *
1128
+ * `cat-file blob` 的输出是任意二进制,按 utf8 解码会毁掉它(非法序列变成 U+FFFD,
1129
+ * 再编码回去就不是原文件了),所以这条路径必须单独走 buffer 编码。
1130
+ */
1131
+ function runGitBytes(cwd, args, maxBytes) {
1132
+ return new Promise((resolve) => {
1133
+ execFile(
1134
+ 'git',
1135
+ args,
1136
+ { cwd, timeout: GIT_TIMEOUT_MS, maxBuffer: maxBytes, windowsHide: true, encoding: 'buffer' },
1137
+ (error, stdout, stderr) => {
1138
+ if (!error) {
1139
+ resolve({ ok: true, stdout })
1140
+ return
1141
+ }
1142
+ resolve({
1143
+ ok: false,
1144
+ stdout,
1145
+ stderr: stderr ? stderr.toString('utf8') : '',
1146
+ killed: Boolean(error.killed) || Boolean(error.signal),
1147
+ code: typeof error.code === 'number' ? error.code : (error.code ?? null),
1148
+ message: error instanceof Error ? error.message : String(error),
1149
+ })
1150
+ },
1151
+ )
1152
+ })
1153
+ }
1154
+
1155
+ /** 一个 git 对象的字节数;对象不存在(新文件还没有索引副本)返回 null。 */
1156
+ async function gitBlobSize(repoRoot, spec) {
1157
+ const result = await runGit(repoRoot, ['cat-file', '-s', spec])
1158
+ if (!result.ok) return null
1159
+ const size = Number.parseInt(result.stdout.trim(), 10)
1160
+ return Number.isFinite(size) && size >= 0 ? size : null
1161
+ }
1162
+
1163
+ /** 判断一侧的内容:能预览就带上 base64 与像素尺寸,否则只说"为什么不能"。 */
1164
+ function describeSide(buffer) {
1165
+ const bytes = buffer.length
1166
+ const mime = sniffImageMime(buffer)
1167
+ if (mime === null) return { kind: 'not-image', bytes }
1168
+ const size = readImageSize(buffer, mime)
1169
+ return {
1170
+ kind: 'image',
1171
+ mime,
1172
+ bytes,
1173
+ width: size === null ? null : size.width,
1174
+ height: size === null ? null : size.height,
1175
+ base64: buffer.toString('base64'),
1176
+ }
1177
+ }
1178
+
1179
+ /** 读工作区里的一个文件;不是普通文件、或超过上限,都只回报状态。 */
1180
+ async function readWorktreeSide(repoRoot, file) {
1181
+ const full = path.resolve(repoRoot, file)
1182
+ const rootPrefix = repoRoot.endsWith(path.sep) ? repoRoot : repoRoot + path.sep
1183
+ // validateDiffPath 已经挡掉绝对路径与 `..`;这里再确认落在仓库内(软链接不越界)。
1184
+ if (!full.startsWith(rootPrefix)) return { kind: 'missing' }
1185
+ let stats
1186
+ try {
1187
+ stats = await fs.promises.stat(full)
1188
+ }
1189
+ catch {
1190
+ return { kind: 'missing' }
1191
+ }
1192
+ if (!stats.isFile()) return { kind: 'missing' }
1193
+ if (stats.size > MAX_IMAGE_BYTES) return { kind: 'too-large', bytes: stats.size, limit: MAX_IMAGE_BYTES }
1194
+ try {
1195
+ return describeSide(await fs.promises.readFile(full))
1196
+ }
1197
+ catch {
1198
+ return { kind: 'missing' }
1199
+ }
1200
+ }
1201
+
1202
+ /** 读一个 git 对象(`<rev>:<path>`,rev 为空串即索引):不存在即 missing,超限只回报字节数。 */
1203
+ async function readGitSide(repoRoot, rev, file) {
1204
+ const spec = `${rev}:${file}`
1205
+ const size = await gitBlobSize(repoRoot, spec)
1206
+ if (size === null) return { kind: 'missing' }
1207
+ if (size > MAX_IMAGE_BYTES) return { kind: 'too-large', bytes: size, limit: MAX_IMAGE_BYTES }
1208
+ const result = await runGitBytes(repoRoot, ['cat-file', 'blob', spec], MAX_IMAGE_BYTES + 64 * 1024)
1209
+ if (!result.ok) {
1210
+ if (result.killed) throw Object.assign(new Error('git cat-file timed out'), { code: 'timeout' })
1211
+ return { kind: 'missing' }
1212
+ }
1213
+ return describeSide(result.stdout)
1214
+ }
1215
+
1216
+ /**
1217
+ * 读一个二进制(图片)改动的两侧字节与像素尺寸,全部只读。
1218
+ *
1219
+ * `side` 与文本 diff 的两枚页签一一对应,这样"未暂存 / 已暂存"在两个视图里指的是同一件事:
1220
+ * - `worktree`(未暂存):基线 = 索引 `:<path>`,新 = 工作区文件;
1221
+ * - `index`(已暂存):基线 = `HEAD`,新 = 索引 `:<path>`;
1222
+ * - 给了 `sha`(提交明细):基线 = 第一父提交(根提交则为空),新 = 该提交。
1223
+ *
1224
+ * 两侧各自回答"能不能预览":`kind` 为 `image` / `too-large` / `not-image` / `missing`。
1225
+ * 任一侧不可预览都不算失败 —— 另一侧照样能看。
1226
+ *
1227
+ * @param cwd - 会话工作区(绝对路径)。
1228
+ * @param file - 仓库内相对路径。
1229
+ * @param origFile - 重命名/复制的原路径,可选。
1230
+ * @param side - `'worktree'` | `'index'`,给了 sha 时忽略。
1231
+ * @param sha - 提交 sha(提交明细用),可选。
1232
+ * @returns `{ repo, path, origPath, side, sha, before, after }`。
1233
+ */
1234
+ export async function readImagePair(cwd, file, origFile = null, side = 'worktree', sha = null) {
1235
+ const base = { path: file, origPath: origFile ?? null, side: sha === null ? side : 'commit', sha }
1236
+ const empty = { repo: null, ...base, before: { kind: 'missing' }, after: { kind: 'missing' } }
1237
+
1238
+ if (sha !== null) {
1239
+ const commit = await resolveCommit(cwd, sha)
1240
+ if (!commit.repo) return empty
1241
+ return {
1242
+ repo: commit.repo,
1243
+ shortSha: commit.shortSha,
1244
+ ...base,
1245
+ before: commit.base === null ? { kind: 'missing' } : await readGitSide(commit.repo, commit.base, origFile ?? file),
1246
+ after: await readGitSide(commit.repo, commit.sha, file),
1247
+ }
1248
+ }
1249
+
1250
+ const root = await runGit(cwd, ['rev-parse', '--show-toplevel'])
1251
+ if (!root.ok) {
1252
+ if (root.killed) throw Object.assign(new Error('git rev-parse timed out'), { code: 'timeout' })
1253
+ if (root.code === 'ENOENT' && !fs.existsSync(cwd)) throw Object.assign(new Error('cwd does not exist'), { code: 'no-cwd' })
1254
+ if (root.code === 'ENOENT') throw Object.assign(new Error('git executable not found'), { code: 'git-missing' })
1255
+ return empty
1256
+ }
1257
+
1258
+ const repoRoot = root.stdout.split('\n')[0].trim()
1259
+ if (side === 'index') {
1260
+ return {
1261
+ repo: repoRoot,
1262
+ ...base,
1263
+ before: await readGitSide(repoRoot, 'HEAD', origFile ?? file),
1264
+ after: await readGitSide(repoRoot, '', file),
1265
+ }
1266
+ }
1267
+ return {
1268
+ repo: repoRoot,
1269
+ ...base,
1270
+ before: await readGitSide(repoRoot, '', origFile ?? file),
1271
+ after: await readWorktreeSide(repoRoot, file),
1272
+ }
1273
+ }
1274
+
919
1275
  /* ------------------------------------------------- 会话改动(按会话归因) */
920
1276
 
921
1277
  /**
@@ -1119,11 +1475,46 @@ const pendingChangeIntents = new Map()
1119
1475
  * 归因照常工作 —— 绝不因为少一个服务把整块降级掉。
1120
1476
  */
1121
1477
  let sessionTurnReader = null
1478
+ /**
1479
+ * `sessionStats` 投影的读数器(内置 `@deepseek-ai/dsh-session-stats` 提供)。
1480
+ *
1481
+ * 会话计时胶囊要的是"这个会话跑了多久、模型/工具各占多少"—— 这些正是它的字段,
1482
+ * 而且是**从完整持久日志折叠**出来的,分页/压实都不会让数字跳变。
1483
+ * 同样是软依赖:这个键没注册(插件没装/被组合掉)就回 null,胶囊只显示挂钟时长。
1484
+ */
1485
+ let sessionStatsReader = null
1486
+ /**
1487
+ * `turnOutline` 投影的读数器:每条 turn 都带着原始 `prompt`。
1488
+ *
1489
+ * 这是 prompt 历史最好的来源 —— 比在客户端自己记一份可靠:分页、压实、重启、换设备都不影响,
1490
+ * 因为它就是会话日志折叠出来的。同样是软依赖(投影没注册就回 null,功能整个不启用)。
1491
+ */
1492
+ let sessionOutlineReader = null
1493
+
1494
+ /** prompt 历史的条数上限(与客户端的 `↑` 召回共用这一个数)。 */
1495
+ export const PROMPT_HISTORY_MAX = 100
1122
1496
  /**
1123
1497
  * 宿主 `sessions` 服务(枚举所有活着的会话)—— 同样是软依赖:拿不到时
1124
1498
  * `session-statuses` 端点报"不可用",侧栏那枚 chip 就不画,其余功能照旧。
1125
1499
  */
1126
1500
  let hostSessions = null
1501
+ /**
1502
+ * 宿主 ctx 的引用。用于**兜底**取 `sessions`:注入回调没到齐、或者被别的挂载实例的
1503
+ * 卸载清掉时(模块级状态 + 每个挂载各自 return 清理,本来就有这个缝),
1504
+ * 按内置契约用 `ctx.get("sessions")` 再问一次(Service 目录里写明的可选取法)。
1505
+ */
1506
+ let hostContext = null
1507
+
1508
+ /** 会话服务:先信注入给的那份,再按契约兜底问 ctx。两个调用方共用这一处。 */
1509
+ function sessionsService() {
1510
+ if (hostSessions !== null) return hostSessions
1511
+ try {
1512
+ const fallback = hostContext !== null && typeof hostContext.get === 'function' ? hostContext.get('sessions') : undefined
1513
+ return fallback === undefined ? null : fallback
1514
+ } catch {
1515
+ return null
1516
+ }
1517
+ }
1127
1518
 
1128
1519
  /** 这次调用发生在第几回合(拿不到就是 null,界面据此不显示回合信息)。 */
1129
1520
  function currentTurnOf(exec) {
@@ -1587,6 +1978,29 @@ function trackSessionChanges(ctx) {
1587
1978
  }))
1588
1979
  }
1589
1980
  stops.push(ctx.inject(['sessionProjections'], (scope) => {
1981
+ sessionOutlineReader = (session) => {
1982
+ try {
1983
+ const state = scope.sessionProjections.stateOf(session, 'turnOutline')
1984
+ return state === undefined || state === null ? null : state
1985
+ } catch {
1986
+ return null
1987
+ }
1988
+ }
1989
+ sessionStatsReader = (session) => {
1990
+ try {
1991
+ const state = scope.sessionProjections.stateOf(session, 'sessionStats')
1992
+ if (state === undefined || state === null) return null
1993
+ const num = value => (typeof value === 'number' && Number.isFinite(value) ? value : 0)
1994
+ return {
1995
+ turns: num(state.turns),
1996
+ steps: num(state.steps),
1997
+ llmMs: num(state.llmMs),
1998
+ toolMs: num(state.toolMs),
1999
+ }
2000
+ } catch {
2001
+ return null
2002
+ }
2003
+ }
1590
2004
  sessionTurnReader = (session) => {
1591
2005
  try {
1592
2006
  const state = scope.sessionProjections.stateOf(session, 'turnBoundary')
@@ -1598,6 +2012,8 @@ function trackSessionChanges(ctx) {
1598
2012
  }
1599
2013
  return () => {
1600
2014
  sessionTurnReader = null
2015
+ sessionStatsReader = null
2016
+ sessionOutlineReader = null
1601
2017
  }
1602
2018
  }))
1603
2019
  }
@@ -1638,6 +2054,20 @@ const ENDPOINTS = {
1638
2054
  return readSessionOverview()
1639
2055
  },
1640
2056
 
2057
+ /** 一个会话的 prompt 历史(新的在前)—— 给输入框的 `↑` 召回用。 */
2058
+ async 'prompt-history'(args) {
2059
+ const input = args && typeof args === 'object' ? args : {}
2060
+ const sessionId = typeof input.sessionId === 'string' && input.sessionId.length > 0 ? input.sessionId : null
2061
+ return readPromptHistory(sessionId, input.limit)
2062
+ },
2063
+
2064
+ /** 一个会话的运行时长(挂钟 + 模型/工具墙钟)—— 给会话计时胶囊用。 */
2065
+ async 'session-time'(args) {
2066
+ const input = args && typeof args === 'object' ? args : {}
2067
+ const sessionId = typeof input.sessionId === 'string' && input.sessionId.length > 0 ? input.sessionId : null
2068
+ return readSessionTime(sessionId)
2069
+ },
2070
+
1641
2071
  async diff(args) {
1642
2072
  const input = args && typeof args === 'object' ? args : {}
1643
2073
  const cwd = validateCwd(input.cwd)
@@ -1655,6 +2085,36 @@ const ENDPOINTS = {
1655
2085
  return readDiff(cwd.value, file.value, origFile)
1656
2086
  },
1657
2087
 
2088
+ /**
2089
+ * 图片预览:一个二进制改动两侧的字节与像素尺寸。
2090
+ *
2091
+ * `diff` 对二进制只回一句 `binary: true`(文本 diff 里没有可看的东西),
2092
+ * 浏览器半靠这个端点把"改了哪张图、尺寸变没变"画出来。只读,不写 index / 工作区。
2093
+ */
2094
+ async image(args) {
2095
+ const input = args && typeof args === 'object' ? args : {}
2096
+ const cwd = validateCwd(input.cwd)
2097
+ if (!cwd.ok) throw Object.assign(new Error(cwd.message), { code: cwd.code })
2098
+ const file = validateDiffPath(input.path)
2099
+ if (!file.ok) throw Object.assign(new Error(file.message), { code: file.code })
2100
+
2101
+ let origFile = null
2102
+ if (input.origPath !== undefined && input.origPath !== null && input.origPath !== '') {
2103
+ const checked = validateDiffPath(input.origPath, 'origPath')
2104
+ if (!checked.ok) throw Object.assign(new Error(checked.message), { code: checked.code })
2105
+ origFile = checked.value
2106
+ }
2107
+
2108
+ let sha = null
2109
+ if (input.sha !== undefined && input.sha !== null && input.sha !== '') {
2110
+ const checked = validateCommitSha(input.sha)
2111
+ if (!checked.ok) throw Object.assign(new Error(checked.message), { code: checked.code })
2112
+ sha = checked.value
2113
+ }
2114
+
2115
+ return readImagePair(cwd.value, file.value, origFile, input.side === 'index' ? 'index' : 'worktree', sha)
2116
+ },
2117
+
1658
2118
  async log(args) {
1659
2119
  const input = args && typeof args === 'object' ? args : {}
1660
2120
  const cwd = validateCwd(input.cwd)
@@ -1860,6 +2320,7 @@ async function handleRequest(ctx, req, res) {
1860
2320
  * @param ctx - 已注入 webServer 与 connection 的 Cordis 上下文。
1861
2321
  */
1862
2322
  export function apply(ctx) {
2323
+ hostContext = ctx
1863
2324
  trackSessionChanges(ctx)
1864
2325
  ctx.effect(() => ctx.webServer.register({
1865
2326
  kind: 'prefix',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@konnga/dsh-hud",
3
- "version": "0.8.0",
3
+ "version": "0.9.1",
4
4
  "description": "DeepSeek Harness 的只读工作区 Git 面板:分支、ahead/behind、改动清单与差异,外加「这个会话改过哪些文件、审到哪了、跑了什么工具」。A read-only workspace Git panel for DeepSeek Harness — branch, changed files, diffs, plus per-session change attribution, review progress, and tool activity.",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -13,6 +13,7 @@
13
13
  "lib",
14
14
  "cordis.patch.yml",
15
15
  "README.md",
16
+ "README.en.md",
16
17
  "LICENSE",
17
18
  "docs/images"
18
19
  ],
@@ -33,7 +34,15 @@
33
34
  "review",
34
35
  "sidebar",
35
36
  "floating-panel",
36
- "web-gui"
37
+ "web-gui",
38
+ "deepseek",
39
+ "diff",
40
+ "image-diff",
41
+ "session-timer",
42
+ "prompt-history",
43
+ "review-progress",
44
+ "tool-activity",
45
+ "dock"
37
46
  ],
38
47
  "license": "MIT",
39
48
  "author": "konnga",