@xbzbing/dsh-git-panel 1.6.2 → 1.8.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.
@@ -1,15 +1,21 @@
1
1
  import { join, sep } from "node:path";
2
2
  import { mapWorkspaceFailure, resolveBrowseRoot, resolveWorkspace, runCommand, snapshotForSession } from "./core.js";
3
3
  import { isSafePath, isSafeRev } from "./validate.js";
4
+ import { walkHistoryTopology } from "./history-topology.js";
4
5
  import { commitFromFields, parseBranches, parseGraphLog, parseNameStatus, parseStashList, parseTags, sumNumstat } from "./parser.js";
5
- import { imageMimeFor, DIFF_CONTEXT_DEFAULT, DIFF_CONTEXT_MAX, DIFF_COUNT_READ_MAX } from "./types.js";
6
+ import { imageMimeFor, isHistoryHashSearch, isHistoryFiltered, DIFF_CONTEXT_DEFAULT, DIFF_CONTEXT_MAX, DIFF_COUNT_READ_MAX, FILE_COPY_MAX_BYTES } from "./types.js";
6
7
  const GRAPH_FORMAT = "--format=%H%x1f%h%x1f%P%x1f%an%x1f%aI%x1f%D%x1f%s%x1e";
8
+ const FIRST_PARENT_DIFF = "--diff-merges=first-parent";
7
9
  const VISIBLE_REFS = ["--branches", "--remotes", "--tags", "HEAD"];
8
- function isHexLike(text) {
9
- return /^[0-9a-fA-F]{7,40}$/.test(text.trim());
10
- }
11
10
  async function runQuery(deps, config, request) {
12
11
  const q = request.query;
12
+ if (q.kind === "model-catalog") {
13
+ try {
14
+ return await queryModelCatalog(deps);
15
+ } catch (error) {
16
+ return { ok: false, error: { code: "git-error", message: error instanceof Error ? error.message : String(error) } };
17
+ }
18
+ }
13
19
  if (q.kind === "dir-list" || q.kind === "file-content") {
14
20
  const browse = await resolveBrowseRoot(deps, request.sessionId);
15
21
  if (!browse.ok) return { ok: false, error: browse.error };
@@ -61,13 +67,19 @@ async function queryHistory(deps, root, q) {
61
67
  const skip = Number.isFinite(q.skip) ? Math.max(0, Math.trunc(q.skip)) : 0;
62
68
  const args = ["git", "log", "--topo-order", "--decorate=full", GRAPH_FORMAT, `--max-count=${limit}`, `--skip=${skip}`];
63
69
  const search = q.search?.trim() ?? "";
64
- const hexJump = search !== "" && isHexLike(search);
70
+ const hexJump = isHistoryHashSearch(search);
71
+ const filtered = isHistoryFiltered(q);
72
+ const author = q.author ?? "";
73
+ const since = q.since ?? "";
74
+ const searchFilter = search !== "";
75
+ const authorFilter = author !== "";
76
+ const sinceFilter = since !== "";
65
77
  const countArgs = ["git", "rev-list", "--count"];
66
78
  if (!hexJump) {
67
79
  const filters = [];
68
- if (search !== "") filters.push("-i", "-E", `--grep=${search}`);
69
- if (q.author !== void 0 && q.author !== "") filters.push(`--author=${q.author}`);
70
- if (q.since !== void 0 && q.since !== "") filters.push(`--since=${q.since}`);
80
+ if (searchFilter) filters.push("-i", "-E", `--grep=${search}`);
81
+ if (authorFilter) filters.push(`--author=${author}`);
82
+ if (sinceFilter) filters.push(`--since=${since}`);
71
83
  if (q.ref !== void 0 && q.ref !== "") {
72
84
  if (!isSafeRev(q.ref)) return { ok: false, error: { code: "invalid-name", message: `unsafe ref: ${q.ref}` } };
73
85
  filters.push("--end-of-options", q.ref);
@@ -89,7 +101,7 @@ async function queryHistory(deps, root, q) {
89
101
  if (res.run.exitCode !== 0) {
90
102
  const stderr = res.run.stderr.trim();
91
103
  if (hexJump || /unknown revision|bad revision|does not have any commits|ambiguous argument/i.test(stderr)) {
92
- return { ok: true, value: { kind: "history", commits: [], total: 0 } };
104
+ return { ok: true, value: { kind: "history", commits: [], total: 0, ...filtered ? { topology: [] } : {} } };
93
105
  }
94
106
  return { ok: false, error: { code: "git-error", message: stderr || `git exited ${res.run.exitCode}` } };
95
107
  }
@@ -99,7 +111,8 @@ async function queryHistory(deps, root, q) {
99
111
  const n = Number(countRes.run.stdout.trim());
100
112
  if (Number.isFinite(n)) total = n;
101
113
  }
102
- return { ok: true, value: { kind: "history", commits, total } };
114
+ const topology = filtered ? commits.length === 0 ? [] : await walkHistoryTopology(deps.run, root, commits.map((c) => c.hash), deps.signal) : void 0;
115
+ return { ok: true, value: { kind: "history", commits, total, ...topology !== void 0 ? { topology } : {} } };
103
116
  }
104
117
  async function queryDiff(deps, root, q) {
105
118
  if (!isSafePath(q.path)) return { ok: false, error: { code: "invalid-path", message: q.path } };
@@ -110,7 +123,7 @@ async function queryDiff(deps, root, q) {
110
123
  args = ["git", "diff", unified, "--cached", "--", q.path];
111
124
  } else if (q.base === "commit") {
112
125
  if (!isSafeRev(q.commit)) return { ok: false, error: { code: "invalid-name", message: `unsafe commit: ${q.commit}` } };
113
- args = ["git", "show", unified, "--end-of-options", q.commit, "--", q.path];
126
+ args = ["git", "show", unified, FIRST_PARENT_DIFF, "--end-of-options", q.commit, "--", q.path];
114
127
  } else {
115
128
  args = ["git", "diff", unified, "--", q.path];
116
129
  }
@@ -301,7 +314,8 @@ async function queryDirList(deps, root, q, isGitRepo) {
301
314
  if (rel !== "" && !isSafePath(rel) || hasGitSegment(rel)) return { ok: false, error: { code: "invalid-path", message: rel } };
302
315
  const dir = rel === "" ? root : join(root, rel);
303
316
  const inside = await isInsideRoot(deps, root, dir, true);
304
- if (!inside) return { ok: false, error: { code: "invalid-path", message: rel } };
317
+ if (inside === "missing") return { ok: false, error: { code: "file-not-found", message: rel } };
318
+ if (inside === "outside") return { ok: false, error: { code: "invalid-path", message: rel } };
305
319
  let raw;
306
320
  try {
307
321
  raw = await deps.fs.readdir(dir);
@@ -352,15 +366,16 @@ async function queryFileContent(deps, config, root, q) {
352
366
  if (!isSafePath(q.path) || hasGitSegment(q.path)) return { ok: false, error: { code: "invalid-path", message: q.path } };
353
367
  const file = join(root, q.path);
354
368
  const inside = await isInsideRoot(deps, root, file, true);
355
- if (!inside) return { ok: false, error: { code: "invalid-path", message: q.path } };
356
- const cap = config.maxBytes;
369
+ if (inside === "missing") return { ok: false, error: { code: "file-not-found", message: q.path } };
370
+ if (inside === "outside") return { ok: false, error: { code: "invalid-path", message: q.path } };
357
371
  let info;
358
372
  try {
359
373
  info = await deps.fs.stat(file);
360
374
  } catch (error) {
361
- return { ok: false, error: { code: "git-error", message: error instanceof Error ? error.message : "stat failed" } };
375
+ return { ok: false, error: { code: isMissingPath(error) ? "file-not-found" : "git-error", message: error instanceof Error ? error.message : "stat failed" } };
362
376
  }
363
377
  const mime = imageMimeFor(q.path);
378
+ const cap = mime !== null ? config.maxBytes : Math.max(config.maxBytes, FILE_COPY_MAX_BYTES);
364
379
  const statFields = { size: info.size, mtimeMs: Math.round(info.mtimeMs) };
365
380
  const tooLarge = { ok: true, value: { kind: "file-content", path: q.path, variant: mime !== null ? "image" : "text", tooLarge: true, ...statFields } };
366
381
  if (info.size > cap) return tooLarge;
@@ -368,7 +383,7 @@ async function queryFileContent(deps, config, root, q) {
368
383
  try {
369
384
  buf = await deps.fs.readFile(file);
370
385
  } catch (error) {
371
- return { ok: false, error: { code: "git-error", message: error instanceof Error ? error.message : "read failed" } };
386
+ return { ok: false, error: { code: isMissingPath(error) ? "file-not-found" : "git-error", message: error instanceof Error ? error.message : "read failed" } };
372
387
  }
373
388
  if (buf.length > cap) return tooLarge;
374
389
  if (mime !== null) {
@@ -377,19 +392,22 @@ async function queryFileContent(deps, config, root, q) {
377
392
  if (isBinaryBuffer(buf)) return { ok: true, value: { kind: "file-content", path: q.path, variant: "binary", ...statFields } };
378
393
  const content = buf.toString("utf8");
379
394
  const lines = content === "" ? 0 : content.split("\n").length - (content.endsWith("\n") ? 1 : 0);
380
- return { ok: true, value: { kind: "file-content", path: q.path, variant: "text", content, lines, ...statFields } };
395
+ return { ok: true, value: { kind: "file-content", path: q.path, variant: "text", content, lines, absolutePath: file, ...statFields } };
381
396
  }
382
397
  function hasGitSegment(path) {
383
398
  return path.split(/[\\/]/).includes(".git");
384
399
  }
400
+ function isMissingPath(error) {
401
+ return error?.code === "ENOENT";
402
+ }
385
403
  async function isInsideRoot(deps, root, path, hideGit = false) {
386
404
  try {
387
405
  const real = await deps.fs.realpath(path);
388
406
  const rootReal = await deps.fs.realpath(root);
389
- if (real !== rootReal && !real.startsWith(rootReal + sep)) return false;
390
- return !hideGit || !hasGitSegment(real.slice(rootReal.length + 1));
391
- } catch {
392
- return false;
407
+ if (real !== rootReal && !real.startsWith(rootReal + sep)) return "outside";
408
+ return !hideGit || !hasGitSegment(real.slice(rootReal.length + 1)) ? "inside" : "outside";
409
+ } catch (error) {
410
+ return isMissingPath(error) ? "missing" : "outside";
393
411
  }
394
412
  }
395
413
  function isBinaryBuffer(buf) {
@@ -402,8 +420,8 @@ async function queryShow(deps, root, ref) {
402
420
  const metaFormat = "--format=%H%x1f%h%x1f%s%x1f%an%x1f%aI%x1f%b";
403
421
  const [metaRes, statRes, numstatRes] = await Promise.all([
404
422
  runCommand(deps.run, ["git", "show", "-s", metaFormat, "--end-of-options", ref], root, "show-meta", deps.signal),
405
- runCommand(deps.run, ["git", "show", "--name-status", "-z", "--format=", "--end-of-options", ref], root, "show-stat", deps.signal),
406
- runCommand(deps.run, ["git", "show", "--numstat", "--format=", "--end-of-options", ref], root, "show-numstat", deps.signal)
423
+ runCommand(deps.run, ["git", "show", FIRST_PARENT_DIFF, "--name-status", "-z", "--format=", "--end-of-options", ref], root, "show-stat", deps.signal),
424
+ runCommand(deps.run, ["git", "show", FIRST_PARENT_DIFF, "--numstat", "--format=", "--end-of-options", ref], root, "show-numstat", deps.signal)
407
425
  ]);
408
426
  if (!("run" in metaRes)) return { ok: false, error: { code: "git-unavailable" } };
409
427
  if (metaRes.run.cancelled) return { ok: false, error: { code: "cancelled" } };
@@ -495,6 +513,53 @@ async function queryWorktreeStats(deps, config, sessionId) {
495
513
  if (!snapshot.ok) return { ok: false, error: { code: "git-error", message: "snapshot failed" } };
496
514
  return { ok: true, value: { kind: "worktree-stats", stats: snapshot.value.stats } };
497
515
  }
516
+ async function queryModelCatalog(deps) {
517
+ const llm = deps.getLlm?.();
518
+ const empty = { kind: "model-catalog", groups: [], failures: [] };
519
+ if (llm === void 0 || typeof llm.listModels !== "function") {
520
+ return { ok: true, value: empty };
521
+ }
522
+ const listModels = llm.listModels.bind(llm);
523
+ const resolveModelInfo = typeof llm.resolveModelInfo === "function" ? llm.resolveModelInfo.bind(llm) : void 0;
524
+ const providers = llm.listProviders?.() ?? [];
525
+ const results = await Promise.all(providers.map(async (provider) => {
526
+ const name = provider.name ?? provider.id;
527
+ let listed;
528
+ try {
529
+ listed = await listModels(provider.id);
530
+ } catch (error) {
531
+ return { id: provider.id, name, message: error instanceof Error ? error.message : String(error) };
532
+ }
533
+ const models = await Promise.all(listed.map(async (model) => {
534
+ const entry = { id: model.id, name: model.name ?? model.id };
535
+ if (resolveModelInfo === void 0) return entry;
536
+ try {
537
+ const info = await resolveModelInfo(provider.id, model.id, deps.signal);
538
+ const reasoning = info.reasoning;
539
+ if (reasoning !== void 0 && reasoning.efforts.length > 0) {
540
+ return {
541
+ ...entry,
542
+ reasoning: {
543
+ efforts: reasoning.efforts.map((effort) => ({ id: effort.id, name: effort.name }))
544
+ }
545
+ };
546
+ }
547
+ } catch {
548
+ }
549
+ return entry;
550
+ }));
551
+ if (models.length === 0) return null;
552
+ return { id: provider.id, name, models };
553
+ }));
554
+ const groups = [];
555
+ const failures = [];
556
+ for (const one of results) {
557
+ if (one === null) continue;
558
+ if ("models" in one) groups.push(one);
559
+ else failures.push(one);
560
+ }
561
+ return { ok: true, value: { kind: "model-catalog", groups, failures } };
562
+ }
498
563
  export {
499
564
  runQuery
500
565
  };
@@ -0,0 +1,3 @@
1
+ import type { GitSnapshot } from './types.js';
2
+ /** Hash the final wire state; sampling time and the digest itself are not state. */
3
+ export declare function snapshotFingerprint(snapshot: GitSnapshot): string;
@@ -0,0 +1,12 @@
1
+ import { createHash } from "node:crypto";
2
+ function snapshotFingerprint(snapshot) {
3
+ const { checkedAt: _checkedAt, stateFingerprint: _stateFingerprint, ...state } = snapshot;
4
+ const encoded = JSON.stringify(state, (_key, value) => {
5
+ if (value === null || typeof value !== "object" || Array.isArray(value)) return value;
6
+ return Object.fromEntries(Object.entries(value).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0));
7
+ });
8
+ return createHash("sha256").update(encoded).digest("hex");
9
+ }
10
+ export {
11
+ snapshotFingerprint
12
+ };
@@ -19,5 +19,18 @@ export declare function parseStyleHistory(stdout: string, ownEmail: string | und
19
19
  * neutral fallback.
20
20
  */
21
21
  export declare function resolveSuggestLanguage(setting: SuggestLanguage, samples: readonly string[]): ResolvedLanguage;
22
+ /** A diff line longer than this is replaced by a one-line marker before the
23
+ * model-input budget is applied. Build output is minified into single lines (a
24
+ * bundled `lib/client.js` line can be hundreds of KB), so shipping it verbatim
25
+ * spends the whole `suggestMaxBytes` budget on one line and cuts off the hunks
26
+ * that actually describe the change. Line count and the diff prefix are kept,
27
+ * so `@@` headers still describe the same regions. */
28
+ export declare const LONG_LINE_MAX_CHARS = 400;
29
+ /** Fold over-long lines into `<prefix>… [long line: N chars omitted]`.
30
+ * `N` counts UTF-16 code units (what `String.length` reports), not bytes. */
31
+ export declare function compactLongLines(text: string, maxChars?: number): {
32
+ text: string;
33
+ folded: number;
34
+ };
22
35
  /** Generate a commit message for the session's uncommitted changes. */
23
36
  export declare function runSuggest(deps: SnapshotDeps, config: GitPanelConfig, request: GitSuggestRequest): Promise<GitSuggestResult>;
@@ -59,9 +59,21 @@ function systemPrompt(language) {
59
59
  ].join("\n");
60
60
  }
61
61
  const UNTRACKED_FILE_CAP = 16;
62
- function resolveRoute(config, agentDefaultModel) {
62
+ const PROMPT_FILE_CAP = 200;
63
+ function resolveRoute(config, sessionModel, agentDefaultModel) {
63
64
  if (config.suggestProvider !== void 0 && config.suggestModel !== void 0) {
64
- return { provider: config.suggestProvider, model: config.suggestModel };
65
+ return {
66
+ provider: config.suggestProvider,
67
+ model: config.suggestModel,
68
+ ...config.suggestReasoningEffort !== void 0 ? { reasoningEffort: config.suggestReasoningEffort } : {}
69
+ };
70
+ }
71
+ if (sessionModel !== void 0) {
72
+ return {
73
+ provider: sessionModel.provider,
74
+ model: sessionModel.model,
75
+ ...sessionModel.reasoningEffort !== void 0 ? { reasoningEffort: sessionModel.reasoningEffort } : {}
76
+ };
65
77
  }
66
78
  return agentDefaultModel?.currentSelection();
67
79
  }
@@ -100,9 +112,23 @@ function truncateUtf8(text, maxBytes) {
100
112
  }
101
113
  return text.slice(0, lo);
102
114
  }
103
- function frameChanges(files, diff, styleSamples) {
115
+ const LONG_LINE_MAX_CHARS = 400;
116
+ const STRUCTURAL_LINE = /^(?:@@|diff --git |index |--- (?:a\/|\/dev\/null)|\+\+\+ (?:b\/|\/dev\/null)|new file mode |deleted file mode |old mode |new mode |similarity index |dissimilarity index |rename from |rename to |copy from |copy to |Binary files |GIT binary patch|literal |delta |\\ )/;
117
+ function compactLongLines(text, maxChars = LONG_LINE_MAX_CHARS) {
118
+ if (maxChars <= 0) return { text, folded: 0 };
119
+ let folded = 0;
120
+ const lines = text.split("\n").map((line) => {
121
+ if (line.length <= maxChars || STRUCTURAL_LINE.test(line)) return line;
122
+ folded += 1;
123
+ const prefix = line[0] === "+" || line[0] === "-" || line[0] === " " || line[0] === "\\" ? line[0] : "";
124
+ return `${prefix}\u2026 [long line: ${line.length} chars omitted]`;
125
+ });
126
+ return { text: lines.join("\n"), folded };
127
+ }
128
+ function frameChanges(files, filesOmitted, diff, styleSamples) {
104
129
  return JSON.stringify({
105
130
  files,
131
+ ...filesOmitted > 0 ? { filesOmitted } : {},
106
132
  diff,
107
133
  commitStyleSamples: styleSamples.length === 0 ? null : styleSamples
108
134
  });
@@ -160,7 +186,7 @@ async function appendUntracked(deps, root, files, diff, budget) {
160
186
  }
161
187
  const res = await runCommand(deps.run, ["git", "diff", "--no-index", "--", "/dev/null", c.path], root, "suggest-untracked", deps.signal);
162
188
  if ("failure" in res || res.run.cancelled || res.run.timedOut) continue;
163
- const chunk = res.run.stdout;
189
+ const chunk = compactLongLines(res.run.stdout).text;
164
190
  if (chunk === "") continue;
165
191
  const lossy = res.run.stdoutLossy;
166
192
  if (lossy || Buffer.byteLength(chunk, "utf8") > remaining) {
@@ -186,7 +212,7 @@ async function runSuggest(deps, config, request) {
186
212
  const pathArgs = paths.length > 0 ? ["--", ...paths] : [];
187
213
  const pathSet = new Set(paths);
188
214
  const [statusRes, headRes, logRes, emailRes] = await Promise.all([
189
- runCommand(deps.run, ["git", "status", "--porcelain=v1", "-z"], root, "suggest-status", deps.signal),
215
+ runCommand(deps.run, ["git", "status", "--porcelain=v1", "-z", "--untracked-files=all"], root, "suggest-status", deps.signal),
190
216
  runCommand(deps.run, ["git", "rev-parse", "--verify", "HEAD"], root, "suggest-head", deps.signal),
191
217
  runCommand(deps.run, ["git", "log", `-n${HISTORY_SCAN}`, "-z", "--format=%ae%s"], root, "suggest-log", deps.signal),
192
218
  runCommand(deps.run, ["git", "config", "user.email"], root, "suggest-email", deps.signal)
@@ -201,13 +227,19 @@ async function runSuggest(deps, config, request) {
201
227
  const styleSamples = "run" in logRes && logRes.run.exitCode === 0 ? parseStyleHistory(logRes.run.stdout, "run" in emailRes && emailRes.run.exitCode === 0 ? emailRes.run.stdout.trim() : void 0) : [];
202
228
  const language = resolveSuggestLanguage(config.suggestLanguage, styleSamples);
203
229
  const all = statusRes.run.stdoutLossy ? [] : parseStatus(statusRes.run.stdout);
204
- const files = pathSet.size > 0 ? all.filter((c) => pathSet.has(c.path)) : all;
230
+ const files = pathSet.size > 0 ? all.filter((c) => {
231
+ if (pathSet.has(c.path)) return true;
232
+ for (let slash = c.path.indexOf("/"); slash !== -1; slash = c.path.indexOf("/", slash + 1)) {
233
+ if (pathSet.has(c.path.slice(0, slash))) return true;
234
+ }
235
+ return false;
236
+ }) : all;
205
237
  const collected = await collectDiff(deps, root, hasHead, pathArgs);
206
238
  if (!("diff" in collected)) return collected;
207
239
  if (files.length === 0 && collected.diff.trim() === "") {
208
240
  return { ok: false, error: { code: "empty-diff", message: "no uncommitted changes to summarize" } };
209
241
  }
210
- let diff = collected.diff;
242
+ let diff = compactLongLines(collected.diff).text;
211
243
  let truncated = collected.lossy;
212
244
  if (Buffer.byteLength(diff, "utf8") > config.suggestMaxBytes) {
213
245
  diff = truncateUtf8(diff, config.suggestMaxBytes) + "\n... [diff truncated]";
@@ -217,12 +249,13 @@ async function runSuggest(deps, config, request) {
217
249
  diff = untracked.diff;
218
250
  truncated = truncated || untracked.truncated;
219
251
  const llm = deps.getLlm?.();
220
- const route = resolveRoute(config, deps.getAgentDefaultModel?.());
252
+ const route = resolveRoute(config, deps.getSessionModel?.(request.sessionId), deps.getAgentDefaultModel?.());
221
253
  if (llm === void 0 || route === void 0) {
222
254
  return { ok: false, error: { code: "llm-unavailable", message: "no model service or route is configured" } };
223
255
  }
224
256
  const frame = frameChanges(
225
- files.map((c) => ({ path: c.path, status: c.status })),
257
+ files.slice(0, PROMPT_FILE_CAP).map((c) => ({ path: c.path, status: c.status })),
258
+ Math.max(0, files.length - PROMPT_FILE_CAP),
226
259
  diff,
227
260
  styleSamples
228
261
  );
@@ -230,6 +263,10 @@ async function runSuggest(deps, config, request) {
230
263
  const options = {
231
264
  provider: route.provider,
232
265
  model: route.model,
266
+ // Catalog effort ids come from the same adapter surface GenerateOptions
267
+ // accepts; the brand is compile-time only (the runtime factory lives in
268
+ // @deepseek-ai/dsh-llm, which is externalized), so a cast is the boundary.
269
+ ...route.reasoningEffort !== void 0 ? { reasoningEffort: route.reasoningEffort } : {},
233
270
  system: systemPrompt(language),
234
271
  messages: [{
235
272
  id: "git-panel-suggest",
@@ -270,11 +307,14 @@ async function runSuggest(deps, config, request) {
270
307
  message,
271
308
  provider: route.provider,
272
309
  model: route.model,
310
+ ...route.reasoningEffort !== void 0 ? { reasoningEffort: route.reasoningEffort } : {},
273
311
  ...truncated ? { truncated: true } : {}
274
312
  }
275
313
  };
276
314
  }
277
315
  export {
316
+ LONG_LINE_MAX_CHARS,
317
+ compactLongLines,
278
318
  isCjkText,
279
319
  parseStyleHistory,
280
320
  resolveSuggestLanguage,
@@ -72,6 +72,9 @@ export interface GitRemote {
72
72
  /** Known-host classification, for the status-bar icon. */
73
73
  readonly hostKind: RemoteHostKind;
74
74
  }
75
+ /** Why the change list is incomplete: capped at `maxChanges`, or the status
76
+ * stream itself was clipped (a lossy read drops the head). */
77
+ export type TruncationReason = 'cap' | 'lossy';
75
78
  /** Immutable snapshot of one repository's status at `checkedAt`. */
76
79
  export interface GitSnapshot {
77
80
  /** Realpath of the repository root (work tree top). */
@@ -97,8 +100,14 @@ export interface GitSnapshot {
97
100
  readonly changes: readonly GitChange[];
98
101
  /** Working-tree statistics for the changes-page header (single source). */
99
102
  readonly stats: WorktreeStats;
100
- /** True when the change list was capped at maxChanges. */
103
+ /** True when the change list is incomplete (capped or clipped). */
101
104
  readonly truncated: boolean;
105
+ /** Which limit produced `truncated`; absent on older hosts. */
106
+ readonly truncatedReason?: TruncationReason;
107
+ /** True when untracked files fell back to git's collapsed `?? dir/` entries
108
+ * because expanding them would overflow `maxChanges` (or the status stream).
109
+ * Older hosts never set it. */
110
+ readonly untrackedCollapsed?: boolean;
102
111
  /** Polling interval the client should use after this snapshot (0 = off). */
103
112
  readonly refreshIntervalMs: number;
104
113
  /** Whether the input-bar git marker pill should render (user preference). */
@@ -109,6 +118,9 @@ export interface GitSnapshot {
109
118
  readonly graphStyle: GraphStyle;
110
119
  /** Whether the commit box shows the AI-suggest button (user preference). */
111
120
  readonly suggestEnabled: boolean;
121
+ /** SHA-256 of the final business snapshot, excluding checkedAt and itself.
122
+ * Optional for compatibility with hosts that do not provide fingerprints. */
123
+ readonly stateFingerprint?: string;
112
124
  /** Epoch millis of the snapshot. */
113
125
  readonly checkedAt: number;
114
126
  }
@@ -119,9 +131,15 @@ export interface GitCommit {
119
131
  readonly author: string;
120
132
  readonly dateIso: string;
121
133
  }
122
- /** A commit with parent links and ref decorations (graph rendering). */
123
- export interface GraphCommit extends GitCommit {
134
+ /** One node of the commit graph's raw ancestry: a full hash and its raw parent
135
+ * hashes. `parents` is empty for a true root — and equally for a shallow
136
+ * boundary, which git cannot tell apart from a root. */
137
+ export interface GitGraphNode {
138
+ readonly hash: string;
124
139
  readonly parents: readonly string[];
140
+ }
141
+ /** A commit with parent links and ref decorations (graph rendering). */
142
+ export interface GraphCommit extends GitCommit, GitGraphNode {
125
143
  readonly refs: readonly GitRef[];
126
144
  }
127
145
  export interface GitRef {
@@ -210,7 +228,7 @@ export type GitAction = {
210
228
  /** `git reset` modes, from least to most destructive. Single source so the
211
229
  * client dialog and the command builder cannot drift. */
212
230
  export type ResetMode = 'soft' | 'mixed' | 'hard';
213
- export type GitErrorCode = 'cwd-unavailable' | 'not-a-git-repo' | 'git-unavailable' | 'invalid-path' | 'invalid-name' | 'invalid-index' | 'git-error' | 'timeout' | 'cancelled' | 'empty-message' | 'local-changes-block' | 'not-ff' | 'push-rejected' | 'auth-failed' | 'conflict' | 'revert-conflict' | 'revert-stuck' | 'revert-merge' | 'not-found' | 'branch-in-use' | 'stash-switch-failed' | 'index-busy' | 'empty-diff' | 'llm-unavailable' | 'llm-error' | 'llm-output' | 'suggest-disabled';
231
+ export type GitErrorCode = 'cwd-unavailable' | 'not-a-git-repo' | 'git-unavailable' | 'invalid-path' | 'invalid-name' | 'invalid-index' | 'git-error' | 'timeout' | 'cancelled' | 'empty-message' | 'local-changes-block' | 'not-ff' | 'push-rejected' | 'auth-failed' | 'conflict' | 'revert-conflict' | 'revert-stuck' | 'revert-merge' | 'not-found' | 'file-not-found' | 'branch-in-use' | 'stash-switch-failed' | 'index-busy' | 'nested-repo' | 'nested-repo-partial' | 'empty-diff' | 'llm-unavailable' | 'llm-error' | 'llm-output' | 'suggest-disabled';
214
232
  export type GitActionResult = {
215
233
  readonly ok: true;
216
234
  readonly snapshot: GitSnapshot;
@@ -226,6 +244,16 @@ export interface GitActionRequest {
226
244
  readonly sessionId: string;
227
245
  readonly action: GitAction;
228
246
  }
247
+ /**
248
+ * True when a history `search` is a commit-hash prefix jump rather than a
249
+ * message regex: 7–40 hex chars after trimming.
250
+ */
251
+ export declare function isHistoryHashSearch(search: string): boolean;
252
+ /** Whether Git's effective content filters hide commits; hash jumps ignore them.
253
+ * Shared with clients receiving history from a host that omits topology. */
254
+ export declare function isHistoryFiltered(query: Pick<Extract<GitQuery, {
255
+ kind: 'history';
256
+ }>, 'search' | 'author' | 'since'>): boolean;
229
257
  export type GitQuery = {
230
258
  readonly kind: 'history';
231
259
  readonly limit: number;
@@ -296,6 +324,8 @@ export type GitQuery = {
296
324
  readonly kind: 'pull-preview';
297
325
  } | {
298
326
  readonly kind: 'push-preview';
327
+ } | {
328
+ readonly kind: 'model-catalog';
299
329
  };
300
330
  /** One entry in a `dir-list` result. */
301
331
  export interface DirEntry {
@@ -330,6 +360,33 @@ export interface StashEntry {
330
360
  /** Human relative time (e.g. "2 hours ago"). */
331
361
  readonly relTime: string;
332
362
  }
363
+ /** Selectable reasoning effort for one exact model route (ids go straight
364
+ * into `GenerateOptions.reasoningEffort`). */
365
+ export interface ModelCatalogEffort {
366
+ readonly id: string;
367
+ readonly name: string;
368
+ }
369
+ /** One model inside a provider group of the host-level catalog. */
370
+ export interface ModelCatalogModel {
371
+ readonly id: string;
372
+ readonly name: string;
373
+ /** Adapter-advertised efforts; absent when the model exposes none. */
374
+ readonly reasoning?: {
375
+ readonly efforts: readonly ModelCatalogEffort[];
376
+ };
377
+ }
378
+ /** One provider and its successfully listed models. */
379
+ export interface ModelCatalogGroup {
380
+ readonly id: string;
381
+ readonly name: string;
382
+ readonly models: readonly ModelCatalogModel[];
383
+ }
384
+ /** One provider / model whose catalog lookup failed in isolation. */
385
+ export interface ModelCatalogFailure {
386
+ readonly id: string;
387
+ readonly name: string;
388
+ readonly message: string;
389
+ }
333
390
  /** Working-tree statistics for the changes page header. */
334
391
  export interface WorktreeStats {
335
392
  /** Distinct changed-file paths (staged ∪ modified ∪ untracked). */
@@ -357,6 +414,11 @@ export type GitQueryResult = {
357
414
  readonly kind: 'history';
358
415
  readonly commits: readonly GraphCommit[];
359
416
  readonly total: number;
417
+ /** Raw ancestry for a filtered page: the page's commits plus the ancestors
418
+ * an effective search/author/since filter hid from the page itself. Absent
419
+ * when no effective filter is active (or on a hash jump); `[]` means the
420
+ * bounded auxiliary walk failed, so the graph is incomplete. */
421
+ readonly topology?: readonly GitGraphNode[];
360
422
  }
361
423
  /**
362
424
  * Unified diff text plus the new-side line count. The count is the fact the
@@ -459,6 +521,10 @@ export type GitQueryResult = {
459
521
  readonly files: number;
460
522
  readonly insertions: number;
461
523
  readonly deletions: number;
524
+ } | {
525
+ readonly kind: 'model-catalog';
526
+ readonly groups: readonly ModelCatalogGroup[];
527
+ readonly failures: readonly ModelCatalogFailure[];
462
528
  } | {
463
529
  readonly kind: 'dir-list';
464
530
  readonly path: string;
@@ -472,6 +538,11 @@ export type GitQueryResult = {
472
538
  readonly content?: string;
473
539
  /** text variant: total line count. */
474
540
  readonly lines?: number;
541
+ /** text variant: absolute path of this file — the rendered-Markdown
542
+ * image resolver needs the document's own location to resolve relative
543
+ * destinations onto the authenticated `api/file` route. Absent on older
544
+ * hosts; the client then keeps the alt-text fallback. */
545
+ readonly absolutePath?: string;
475
546
  /** image variant: a `data:<mime>;base64,…` URL. */
476
547
  readonly dataUrl?: string;
477
548
  /** The file exceeded the byte cap, so no content is returned. */
@@ -481,6 +552,14 @@ export type GitQueryResult = {
481
552
  /** Modification time (epoch ms) from the same stat (all variants). */
482
553
  readonly mtimeMs?: number;
483
554
  };
555
+ /**
556
+ * Byte envelope of the file browser's "copy file content" action: a text
557
+ * document is copyable strictly below this size. Shared by both halves —
558
+ * the host floors its text read here (so every copyable document can
559
+ * actually load) and the client hides the copy button at/above it, keeping
560
+ * one boundary for load and copy.
561
+ */
562
+ export declare const FILE_COPY_MAX_BYTES: number;
484
563
  export type GitQueryResponse = {
485
564
  readonly ok: true;
486
565
  readonly value: GitQueryResult;
@@ -550,6 +629,8 @@ export type GitSuggestResult = {
550
629
  /** Actual provider/model route used (display only). */
551
630
  readonly provider?: string;
552
631
  readonly model?: string;
632
+ /** Effort actually sent; absent when the request omitted it. */
633
+ readonly reasoningEffort?: string;
553
634
  /** The diff reached the model truncated: over the input cap, or cut
554
635
  * by the per-command runner stdout cap before it. */
555
636
  readonly truncated?: boolean;
package/lib/host/types.js CHANGED
@@ -2,8 +2,16 @@ const DIFF_CONTEXT_DEFAULT = 3;
2
2
  const DIFF_CONTEXT_MAX = 1e5;
3
3
  const DIFF_EXPAND_CONTEXT = 2e3;
4
4
  const DIFF_COUNT_READ_MAX = 8 * 1024 * 1024;
5
+ function isHistoryHashSearch(search) {
6
+ return /^[0-9a-fA-F]{7,40}$/.test(search.trim());
7
+ }
8
+ function isHistoryFiltered(query) {
9
+ const search = query.search?.trim() ?? "";
10
+ return !isHistoryHashSearch(search) && (search !== "" || (query.author ?? "") !== "" || (query.since ?? "") !== "");
11
+ }
12
+ const FILE_COPY_MAX_BYTES = 10 * 1024 * 1024;
5
13
  const SUGGEST_LIMITS = {
6
- maxBytes: { min: 16 * 1024, max: 512 * 1024, default: 128 * 1024 },
14
+ maxBytes: { min: 16 * 1024, max: 512 * 1024, default: 200 * 1024 },
7
15
  maxOutputTokens: { min: 256, max: 16384, default: 4096 },
8
16
  timeoutMs: { min: 5e3, max: 12e4, default: 15e3 }
9
17
  };
@@ -32,9 +40,12 @@ export {
32
40
  DIFF_CONTEXT_MAX,
33
41
  DIFF_COUNT_READ_MAX,
34
42
  DIFF_EXPAND_CONTEXT,
43
+ FILE_COPY_MAX_BYTES,
35
44
  IMAGE_MIME,
36
45
  SUGGEST_LANGUAGES,
37
46
  SUGGEST_LANGUAGE_DEFAULT,
38
47
  SUGGEST_LIMITS,
39
- imageMimeFor
48
+ imageMimeFor,
49
+ isHistoryFiltered,
50
+ isHistoryHashSearch
40
51
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xbzbing/dsh-git-panel",
3
- "version": "1.6.2",
3
+ "version": "1.8.0",
4
4
  "description": "DeepSeek Harness (dsh) plugin: an IDE-style Git panel in the Web GUI — branch/history overview, uncommitted changes with commit & amend, diff stats, and a zsh-style branch marker in the input bar.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -31,6 +31,7 @@
31
31
  },
32
32
  "files": [
33
33
  "lib/client.js",
34
+ "lib/client.mermaid.js",
34
35
  "lib/host",
35
36
  "locale/*.json",
36
37
  "icon.svg",
@@ -41,11 +42,12 @@
41
42
  ],
42
43
  "scripts": {
43
44
  "build": "node build.mjs",
45
+ "lint": "eslint .",
44
46
  "typecheck": "tsc -p tsconfig.json --noEmit",
45
- "test:unit": "node build.mjs && node --test --test-force-exit test/unit/*.test.mjs",
46
- "test:e2e": "node build.mjs && node test/e2e/setup.mjs && node test/e2e/run.mjs && node test/e2e/run2.mjs && node test/e2e/run3.mjs && node test/e2e/run4.mjs && node test/e2e/run5.mjs",
47
+ "test:unit": "node build.mjs && node --test test/unit/*.test.mjs",
48
+ "test:e2e": "node build.mjs && node test/e2e/setup.mjs && node test/e2e/all.mjs",
47
49
  "test": "npm run test:unit && npm run test:e2e",
48
- "check": "npm run typecheck && npm run test && git diff --exit-code -- lib/"
50
+ "check": "npm run lint && npm run typecheck && npm run test"
49
51
  },
50
52
  "dsh": {
51
53
  "bundle": {
@@ -63,7 +65,10 @@
63
65
  "compatibility": {
64
66
  "dsh": ">=0.1.7-rc.2",
65
67
  "dshReleases": {
66
- "0.2.1-alpha.2": "compatible"
68
+ "0.1.7-rc.2": "compatible",
69
+ "0.2.0-rc.2": "compatible",
70
+ "0.2.1-rc.1": "compatible",
71
+ "0.2.1-rc.2": "compatible"
67
72
  }
68
73
  }
69
74
  },
@@ -84,14 +89,23 @@
84
89
  "@deepseek-ai/dsh-typert-protocol": ">=0.1.2-rc.1",
85
90
  "@deepseek-ai/schemastery": ">=3.18.4"
86
91
  },
92
+ "peerDependenciesMeta": {
93
+ "@deepseek-ai/dsh": {
94
+ "optional": true
95
+ }
96
+ },
87
97
  "devDependencies": {
88
98
  "@types/node": "22.10.5",
89
99
  "@types/react": "18.3.18",
90
100
  "@types/react-dom": "18.3.5",
91
101
  "esbuild": "0.24.2",
102
+ "eslint": "9.39.5",
103
+ "eslint-plugin-react-hooks": "7.1.1",
104
+ "mermaid": "12.2.0",
92
105
  "playwright-core": "1.63.0",
93
106
  "react": "18.3.1",
94
107
  "react-dom": "18.3.1",
95
- "typescript": "5.7.3"
108
+ "typescript": "5.7.3",
109
+ "typescript-eslint": "8.71.2"
96
110
  }
97
111
  }