@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/README.en.md +176 -0
- package/README.md +103 -55
- package/docs/images/capsules.webp +0 -0
- package/docs/images/image-diff.webp +0 -0
- package/docs/images/pill-tip.webp +0 -0
- package/docs/images/turn-images.webp +0 -0
- package/lib/client.js +1226 -17
- package/lib/index.js +463 -2
- package/package.json +11 -2
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
|
-
|
|
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 =
|
|
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.
|
|
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",
|