@xbzbing/dsh-git-panel 1.7.0 → 1.8.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.
@@ -9,6 +9,13 @@ const FIRST_PARENT_DIFF = "--diff-merges=first-parent";
9
9
  const VISIBLE_REFS = ["--branches", "--remotes", "--tags", "HEAD"];
10
10
  async function runQuery(deps, config, request) {
11
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
+ }
12
19
  if (q.kind === "dir-list" || q.kind === "file-content") {
13
20
  const browse = await resolveBrowseRoot(deps, request.sessionId);
14
21
  if (!browse.ok) return { ok: false, error: browse.error };
@@ -307,7 +314,8 @@ async function queryDirList(deps, root, q, isGitRepo) {
307
314
  if (rel !== "" && !isSafePath(rel) || hasGitSegment(rel)) return { ok: false, error: { code: "invalid-path", message: rel } };
308
315
  const dir = rel === "" ? root : join(root, rel);
309
316
  const inside = await isInsideRoot(deps, root, dir, true);
310
- 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 } };
311
319
  let raw;
312
320
  try {
313
321
  raw = await deps.fs.readdir(dir);
@@ -358,12 +366,13 @@ async function queryFileContent(deps, config, root, q) {
358
366
  if (!isSafePath(q.path) || hasGitSegment(q.path)) return { ok: false, error: { code: "invalid-path", message: q.path } };
359
367
  const file = join(root, q.path);
360
368
  const inside = await isInsideRoot(deps, root, file, true);
361
- if (!inside) return { ok: false, error: { code: "invalid-path", message: q.path } };
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 } };
362
371
  let info;
363
372
  try {
364
373
  info = await deps.fs.stat(file);
365
374
  } catch (error) {
366
- 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" } };
367
376
  }
368
377
  const mime = imageMimeFor(q.path);
369
378
  const cap = mime !== null ? config.maxBytes : Math.max(config.maxBytes, FILE_COPY_MAX_BYTES);
@@ -374,7 +383,7 @@ async function queryFileContent(deps, config, root, q) {
374
383
  try {
375
384
  buf = await deps.fs.readFile(file);
376
385
  } catch (error) {
377
- 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" } };
378
387
  }
379
388
  if (buf.length > cap) return tooLarge;
380
389
  if (mime !== null) {
@@ -383,19 +392,22 @@ async function queryFileContent(deps, config, root, q) {
383
392
  if (isBinaryBuffer(buf)) return { ok: true, value: { kind: "file-content", path: q.path, variant: "binary", ...statFields } };
384
393
  const content = buf.toString("utf8");
385
394
  const lines = content === "" ? 0 : content.split("\n").length - (content.endsWith("\n") ? 1 : 0);
386
- 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 } };
387
396
  }
388
397
  function hasGitSegment(path) {
389
398
  return path.split(/[\\/]/).includes(".git");
390
399
  }
400
+ function isMissingPath(error) {
401
+ return error?.code === "ENOENT";
402
+ }
391
403
  async function isInsideRoot(deps, root, path, hideGit = false) {
392
404
  try {
393
405
  const real = await deps.fs.realpath(path);
394
406
  const rootReal = await deps.fs.realpath(root);
395
- if (real !== rootReal && !real.startsWith(rootReal + sep)) return false;
396
- return !hideGit || !hasGitSegment(real.slice(rootReal.length + 1));
397
- } catch {
398
- 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";
399
411
  }
400
412
  }
401
413
  function isBinaryBuffer(buf) {
@@ -501,6 +513,54 @@ async function queryWorktreeStats(deps, config, sessionId) {
501
513
  if (!snapshot.ok) return { ok: false, error: { code: "git-error", message: "snapshot failed" } };
502
514
  return { ok: true, value: { kind: "worktree-stats", stats: snapshot.value.stats } };
503
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
+ }
504
563
  export {
564
+ isInsideRoot,
505
565
  runQuery
506
566
  };
@@ -60,9 +60,23 @@ function systemPrompt(language) {
60
60
  }
61
61
  const UNTRACKED_FILE_CAP = 16;
62
62
  const PROMPT_FILE_CAP = 200;
63
- function resolveRoute(config, agentDefaultModel) {
63
+ function routeNeedsSessionId(provider) {
64
+ return provider.toLowerCase().includes("opencode");
65
+ }
66
+ function resolveRoute(config, sessionModel, agentDefaultModel) {
64
67
  if (config.suggestProvider !== void 0 && config.suggestModel !== void 0) {
65
- return { provider: config.suggestProvider, model: config.suggestModel };
68
+ return {
69
+ provider: config.suggestProvider,
70
+ model: config.suggestModel,
71
+ ...config.suggestReasoningEffort !== void 0 ? { reasoningEffort: config.suggestReasoningEffort } : {}
72
+ };
73
+ }
74
+ if (sessionModel !== void 0) {
75
+ return {
76
+ provider: sessionModel.provider,
77
+ model: sessionModel.model,
78
+ ...sessionModel.reasoningEffort !== void 0 ? { reasoningEffort: sessionModel.reasoningEffort } : {}
79
+ };
66
80
  }
67
81
  return agentDefaultModel?.currentSelection();
68
82
  }
@@ -238,7 +252,7 @@ async function runSuggest(deps, config, request) {
238
252
  diff = untracked.diff;
239
253
  truncated = truncated || untracked.truncated;
240
254
  const llm = deps.getLlm?.();
241
- const route = resolveRoute(config, deps.getAgentDefaultModel?.());
255
+ const route = resolveRoute(config, deps.getSessionModel?.(request.sessionId), deps.getAgentDefaultModel?.());
242
256
  if (llm === void 0 || route === void 0) {
243
257
  return { ok: false, error: { code: "llm-unavailable", message: "no model service or route is configured" } };
244
258
  }
@@ -252,6 +266,10 @@ async function runSuggest(deps, config, request) {
252
266
  const options = {
253
267
  provider: route.provider,
254
268
  model: route.model,
269
+ // Catalog effort ids come from the same adapter surface GenerateOptions
270
+ // accepts; the brand is compile-time only (the runtime factory lives in
271
+ // @deepseek-ai/dsh-llm, which is externalized), so a cast is the boundary.
272
+ ...route.reasoningEffort !== void 0 ? { reasoningEffort: route.reasoningEffort } : {},
255
273
  system: systemPrompt(language),
256
274
  messages: [{
257
275
  id: "git-panel-suggest",
@@ -264,7 +282,8 @@ async function runSuggest(deps, config, request) {
264
282
  }],
265
283
  maxTokens: config.suggestMaxOutputTokens,
266
284
  temperature: 0.3,
267
- signal: deadline.signal
285
+ signal: deadline.signal,
286
+ ...routeNeedsSessionId(route.provider) ? { sessionId: request.sessionId } : {}
268
287
  };
269
288
  let output = "";
270
289
  let finish;
@@ -292,6 +311,7 @@ async function runSuggest(deps, config, request) {
292
311
  message,
293
312
  provider: route.provider,
294
313
  model: route.model,
314
+ ...route.reasoningEffort !== void 0 ? { reasoningEffort: route.reasoningEffort } : {},
295
315
  ...truncated ? { truncated: true } : {}
296
316
  }
297
317
  };
@@ -9,6 +9,8 @@ export type DiffViewMode = 'unified' | 'split';
9
9
  * lane (git log --graph); `parallel` = keep each merge's first parent in its
10
10
  * own lane, converging only at the ancestor (VSCode / GUI tools). */
11
11
  export type GraphStyle = 'compact' | 'parallel';
12
+ /** Panel font-size step: -1 smaller, 0 default, +1 larger (CSS zoom on .gp-panel). */
13
+ export type FontDelta = -1 | 0 | 1;
12
14
  /**
13
15
  * Diff context (-U) values shared by both halves. `DEFAULT` matches git's own
14
16
  * default and is what the host applies when a request omits `context`.
@@ -29,9 +31,16 @@ export declare const DIFF_EXPAND_CONTEXT = 2000;
29
31
  * size the count reports 0 (unknown) and no trailing control is offered.
30
32
  */
31
33
  export declare const DIFF_COUNT_READ_MAX: number;
32
- /** Wire request: the browser sends only session identity, never a path. */
34
+ /** Hard ceiling for a `fullChanges` snapshot (the client's "expand all"):
35
+ * well above the default `maxChanges` (1000) so a big-but-sane change set
36
+ * expands completely, while an absurd one stays bounded on the wire. */
37
+ export declare const FULL_CHANGES_MAX = 10000;
38
+ /** Wire request: the browser sends only session identity, never a path.
39
+ * `fullChanges` asks the host to skip the `maxChanges` slice (still bounded
40
+ * by `FULL_CHANGES_MAX`) so the client can expand a truncated list once. */
33
41
  export interface GitSnapshotRequest {
34
42
  readonly sessionId: string;
43
+ readonly fullChanges?: boolean;
35
44
  }
36
45
  export type GitSnapshotResult = {
37
46
  readonly ok: true;
@@ -116,6 +125,8 @@ export interface GitSnapshot {
116
125
  readonly defaultDiffView: DiffViewMode;
117
126
  /** Commit-graph line style (user preference). */
118
127
  readonly graphStyle: GraphStyle;
128
+ /** Panel font-size step (user preference). */
129
+ readonly fontDelta: FontDelta;
119
130
  /** Whether the commit box shows the AI-suggest button (user preference). */
120
131
  readonly suggestEnabled: boolean;
121
132
  /** SHA-256 of the final business snapshot, excluding checkedAt and itself.
@@ -173,6 +184,8 @@ export type GitAction = {
173
184
  } | {
174
185
  readonly kind: 'discard';
175
186
  readonly paths: readonly string[];
187
+ } | {
188
+ readonly kind: 'discard-all';
176
189
  } | {
177
190
  readonly kind: 'commit';
178
191
  readonly message: string;
@@ -228,7 +241,7 @@ export type GitAction = {
228
241
  /** `git reset` modes, from least to most destructive. Single source so the
229
242
  * client dialog and the command builder cannot drift. */
230
243
  export type ResetMode = 'soft' | 'mixed' | 'hard';
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' | 'branch-in-use' | 'stash-switch-failed' | 'index-busy' | 'nested-repo' | 'nested-repo-partial' | 'empty-diff' | 'llm-unavailable' | 'llm-error' | 'llm-output' | 'suggest-disabled';
244
+ export type GitErrorCode = 'cwd-unavailable' | 'not-a-git-repo' | 'git-unavailable' | 'invalid-path' | 'invalid-name' | 'invalid-index' | 'git-error' | 'timeout' | 'cancelled' | 'empty-message' | 'nothing-to-commit' | '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' | 'status-lossy' | 'no-branch' | 'empty-diff' | 'llm-unavailable' | 'llm-error' | 'llm-output' | 'suggest-disabled';
232
245
  export type GitActionResult = {
233
246
  readonly ok: true;
234
247
  readonly snapshot: GitSnapshot;
@@ -324,6 +337,8 @@ export type GitQuery = {
324
337
  readonly kind: 'pull-preview';
325
338
  } | {
326
339
  readonly kind: 'push-preview';
340
+ } | {
341
+ readonly kind: 'model-catalog';
327
342
  };
328
343
  /** One entry in a `dir-list` result. */
329
344
  export interface DirEntry {
@@ -358,6 +373,33 @@ export interface StashEntry {
358
373
  /** Human relative time (e.g. "2 hours ago"). */
359
374
  readonly relTime: string;
360
375
  }
376
+ /** Selectable reasoning effort for one exact model route (ids go straight
377
+ * into `GenerateOptions.reasoningEffort`). */
378
+ export interface ModelCatalogEffort {
379
+ readonly id: string;
380
+ readonly name: string;
381
+ }
382
+ /** One model inside a provider group of the host-level catalog. */
383
+ export interface ModelCatalogModel {
384
+ readonly id: string;
385
+ readonly name: string;
386
+ /** Adapter-advertised efforts; absent when the model exposes none. */
387
+ readonly reasoning?: {
388
+ readonly efforts: readonly ModelCatalogEffort[];
389
+ };
390
+ }
391
+ /** One provider and its successfully listed models. */
392
+ export interface ModelCatalogGroup {
393
+ readonly id: string;
394
+ readonly name: string;
395
+ readonly models: readonly ModelCatalogModel[];
396
+ }
397
+ /** One provider / model whose catalog lookup failed in isolation. */
398
+ export interface ModelCatalogFailure {
399
+ readonly id: string;
400
+ readonly name: string;
401
+ readonly message: string;
402
+ }
361
403
  /** Working-tree statistics for the changes page header. */
362
404
  export interface WorktreeStats {
363
405
  /** Distinct changed-file paths (staged ∪ modified ∪ untracked). */
@@ -492,6 +534,10 @@ export type GitQueryResult = {
492
534
  readonly files: number;
493
535
  readonly insertions: number;
494
536
  readonly deletions: number;
537
+ } | {
538
+ readonly kind: 'model-catalog';
539
+ readonly groups: readonly ModelCatalogGroup[];
540
+ readonly failures: readonly ModelCatalogFailure[];
495
541
  } | {
496
542
  readonly kind: 'dir-list';
497
543
  readonly path: string;
@@ -505,6 +551,11 @@ export type GitQueryResult = {
505
551
  readonly content?: string;
506
552
  /** text variant: total line count. */
507
553
  readonly lines?: number;
554
+ /** text variant: absolute path of this file — the rendered-Markdown
555
+ * image resolver needs the document's own location to resolve relative
556
+ * destinations onto the authenticated `api/file` route. Absent on older
557
+ * hosts; the client then keeps the alt-text fallback. */
558
+ readonly absolutePath?: string;
508
559
  /** image variant: a `data:<mime>;base64,…` URL. */
509
560
  readonly dataUrl?: string;
510
561
  /** The file exceeded the byte cap, so no content is returned. */
@@ -591,6 +642,8 @@ export type GitSuggestResult = {
591
642
  /** Actual provider/model route used (display only). */
592
643
  readonly provider?: string;
593
644
  readonly model?: string;
645
+ /** Effort actually sent; absent when the request omitted it. */
646
+ readonly reasoningEffort?: string;
594
647
  /** The diff reached the model truncated: over the input cap, or cut
595
648
  * by the per-command runner stdout cap before it. */
596
649
  readonly truncated?: boolean;
@@ -609,6 +662,26 @@ export type GitSuggestResult = {
609
662
  export declare const IMAGE_MIME: Readonly<Record<string, string>>;
610
663
  /** MIME for a path's extension; null when it is not a served image type. */
611
664
  export declare function imageMimeFor(path: string): string | null;
665
+ /**
666
+ * Hand one browsable file to the operating system so it opens outside the
667
+ * panel. The path is validated and confirmed inside the browse root before the
668
+ * OS sees it; `sessionId` picks that root.
669
+ */
670
+ export interface GitOpenFileRequest {
671
+ readonly sessionId: string;
672
+ /** Browse-root-relative path of the file to open. */
673
+ readonly path: string;
674
+ }
675
+ export type GitOpenFileResult = {
676
+ readonly ok: true;
677
+ readonly absolutePath: string;
678
+ } | {
679
+ readonly ok: false;
680
+ readonly error: {
681
+ readonly code: GitErrorCode;
682
+ readonly message?: string;
683
+ };
684
+ };
612
685
  export interface GitVersionRequest {
613
686
  /** True → query the GitHub releases API; false/absent → local view only. */
614
687
  readonly check?: boolean;
package/lib/host/types.js CHANGED
@@ -2,6 +2,7 @@ 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
+ const FULL_CHANGES_MAX = 1e4;
5
6
  function isHistoryHashSearch(search) {
6
7
  return /^[0-9a-fA-F]{7,40}$/.test(search.trim());
7
8
  }
@@ -41,6 +42,7 @@ export {
41
42
  DIFF_COUNT_READ_MAX,
42
43
  DIFF_EXPAND_CONTEXT,
43
44
  FILE_COPY_MAX_BYTES,
45
+ FULL_CHANGES_MAX,
44
46
  IMAGE_MIME,
45
47
  SUGGEST_LANGUAGES,
46
48
  SUGGEST_LANGUAGE_DEFAULT,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xbzbing/dsh-git-panel",
3
- "version": "1.7.0",
3
+ "version": "1.8.1",
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",
47
+ "test:unit": "node build.mjs && node --test test/unit/*.test.mjs",
46
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
  }