@xbzbing/dsh-git-panel 1.2.0 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/host/index.js CHANGED
@@ -250,6 +250,25 @@ function parseTags(stdout) {
250
250
  }
251
251
  return out;
252
252
  }
253
+ function parseStashList(stdout) {
254
+ const out = [];
255
+ for (const record of stdout.split("\0")) {
256
+ if (record.trim() === "") continue;
257
+ const [selector = "", sha = "", subject = "", relTime = ""] = record.split("");
258
+ const idxMatch = /stash@\{(\d+)\}/.exec(selector);
259
+ if (idxMatch === null) continue;
260
+ const index = Number(idxMatch[1]);
261
+ let branch = null;
262
+ let message = subject;
263
+ const m = /^(?:WIP on|On) (.+?): (.*)$/s.exec(subject);
264
+ if (m !== null) {
265
+ branch = m[1] === "(no branch)" ? null : m[1] ?? null;
266
+ message = m[2] ?? "";
267
+ }
268
+ out.push({ index, sha: sha.trim(), message: message.trim(), branch, relTime: relTime.trim() });
269
+ }
270
+ return out;
271
+ }
253
272
  function parseNameStatus(stdout) {
254
273
  const out = [];
255
274
  const tokens = stdout.split("\0");
@@ -317,18 +336,32 @@ var DEFAULT_CONFIG = {
317
336
  maxChanges: 1e3,
318
337
  refreshIntervalMs: 3e4,
319
338
  showInputPill: true,
320
- defaultDiffView: "unified"
339
+ defaultDiffView: "unified",
340
+ graphStyle: "parallel",
341
+ suggestEnabled: true,
342
+ suggestMaxBytes: 64 * 1024,
343
+ suggestMaxOutputTokens: 200,
344
+ suggestTimeoutMs: 15e3
321
345
  };
322
346
  function normalizeConfig(raw) {
323
347
  const c = raw ?? {};
324
348
  const num = (v, d) => typeof v === "number" && Number.isFinite(v) && v >= 1 ? Math.floor(v) : d;
349
+ const hasProvider = typeof c.suggestProvider === "string" && c.suggestProvider !== "";
350
+ const hasModel = typeof c.suggestModel === "string" && c.suggestModel !== "";
351
+ const route = hasProvider && hasModel ? { suggestProvider: c.suggestProvider, suggestModel: c.suggestModel } : {};
325
352
  return {
326
353
  timeoutMs: num(c.timeoutMs, DEFAULT_CONFIG.timeoutMs),
327
354
  maxBytes: num(c.maxBytes, DEFAULT_CONFIG.maxBytes),
328
355
  maxChanges: num(c.maxChanges, DEFAULT_CONFIG.maxChanges),
329
356
  refreshIntervalMs: num(c.refreshIntervalMs, DEFAULT_CONFIG.refreshIntervalMs),
330
357
  showInputPill: readBool(c.showInputPill, DEFAULT_CONFIG.showInputPill),
331
- defaultDiffView: readDiffView(c.defaultDiffView, DEFAULT_CONFIG.defaultDiffView)
358
+ defaultDiffView: readDiffView(c.defaultDiffView, DEFAULT_CONFIG.defaultDiffView),
359
+ graphStyle: readGraphStyle(c.graphStyle, DEFAULT_CONFIG.graphStyle),
360
+ suggestEnabled: readBool(c.suggestEnabled, DEFAULT_CONFIG.suggestEnabled),
361
+ suggestMaxBytes: num(c.suggestMaxBytes, DEFAULT_CONFIG.suggestMaxBytes),
362
+ suggestMaxOutputTokens: num(c.suggestMaxOutputTokens, DEFAULT_CONFIG.suggestMaxOutputTokens),
363
+ suggestTimeoutMs: num(c.suggestTimeoutMs, DEFAULT_CONFIG.suggestTimeoutMs),
364
+ ...route
332
365
  };
333
366
  }
334
367
  function unwrapVolatile(value) {
@@ -338,11 +371,16 @@ function readDiffView(value, fallback) {
338
371
  const raw = unwrapVolatile(value);
339
372
  return raw === "unified" || raw === "split" ? raw : fallback;
340
373
  }
374
+ function readGraphStyle(value, fallback) {
375
+ const raw = unwrapVolatile(value);
376
+ return raw === "parallel" || raw === "compact" ? raw : fallback;
377
+ }
341
378
  function readBool(value, fallback) {
342
379
  const raw = unwrapVolatile(value);
343
380
  return typeof raw === "boolean" ? raw : fallback;
344
381
  }
345
382
  var NEG_CACHE_MS = 15e3;
383
+ var STATS_FANOUT_LIMIT = 200;
346
384
  async function resolveBrowseRoot(deps, sessionId) {
347
385
  const ws = await resolveWorkspace(deps, sessionId);
348
386
  if (ws.ok) return { ok: true, root: ws.root, isGitRepo: true };
@@ -487,16 +525,19 @@ async function snapshotForSession(deps, config, sessionId) {
487
525
  }
488
526
  const wt = "run" in worktreeNumRes && worktreeNumRes.run.exitCode === 0 ? sumNumstat(worktreeNumRes.run.stdout) : { insertions: 0, deletions: 0 };
489
527
  const stg = "run" in stagedNumRes && stagedNumRes.run.exitCode === 0 ? sumNumstat(stagedNumRes.run.stdout) : { insertions: 0, deletions: 0 };
528
+ const statsDetailed = allChanges.length <= STATS_FANOUT_LIMIT;
490
529
  let untrackedInsertions = 0;
491
- const untrackedPaths = allChanges.filter((c) => c.status === "untracked" && !c.isDirectory).map((c) => c.path);
492
- const safeUntracked = untrackedPaths.filter(isSafePath).slice(0, 200);
493
- if (safeUntracked.length > 0) {
494
- const perFile = await Promise.all(
495
- safeUntracked.map((p) => runCommand(deps.run, ["git", "diff", "--numstat", "--no-index", "--", "/dev/null", p], root, "numstat-untracked", deps.signal))
496
- );
497
- for (const r of perFile) if ("run" in r) untrackedInsertions += sumNumstat(r.run.stdout).insertions;
498
- }
499
- const lastChangeAt = await maxChangeMtime(deps, root, allChanges);
530
+ if (statsDetailed) {
531
+ const untrackedPaths = allChanges.filter((c) => c.status === "untracked" && !c.isDirectory).map((c) => c.path);
532
+ const safeUntracked = untrackedPaths.filter(isSafePath);
533
+ if (safeUntracked.length > 0) {
534
+ const perFile = await Promise.all(
535
+ safeUntracked.map((p) => runCommand(deps.run, ["git", "diff", "--numstat", "--no-index", "--", "/dev/null", p], root, "numstat-untracked", deps.signal))
536
+ );
537
+ for (const r of perFile) if ("run" in r) untrackedInsertions += sumNumstat(r.run.stdout).insertions;
538
+ }
539
+ }
540
+ const lastChangeAt = statsDetailed ? await maxChangeMtime(deps, root, allChanges) : null;
500
541
  const distinct = new Set(allChanges.map((c) => c.path));
501
542
  const stats = {
502
543
  fileCount: distinct.size,
@@ -506,7 +547,8 @@ async function snapshotForSession(deps, config, sessionId) {
506
547
  insertions: wt.insertions + stg.insertions + untrackedInsertions,
507
548
  deletions: wt.deletions + stg.deletions,
508
549
  lastChangeAt,
509
- headCommittedAt: lastCommit?.dateIso ?? null
550
+ headCommittedAt: lastCommit?.dateIso ?? null,
551
+ partial: !statsDetailed
510
552
  };
511
553
  const snapshot = {
512
554
  root,
@@ -526,6 +568,8 @@ async function snapshotForSession(deps, config, sessionId) {
526
568
  refreshIntervalMs: config.refreshIntervalMs,
527
569
  showInputPill: config.showInputPill,
528
570
  defaultDiffView: config.defaultDiffView,
571
+ graphStyle: config.graphStyle,
572
+ suggestEnabled: config.suggestEnabled,
529
573
  checkedAt: Date.now()
530
574
  };
531
575
  return { ok: true, value: snapshot };
@@ -586,14 +630,86 @@ function planAction(action, unborn) {
586
630
  return { argv: [["git", "checkout", "--end-of-options", action.name]] };
587
631
  case "fetch":
588
632
  return { argv: [["git", "fetch", "--all", "--prune"]] };
633
+ case "tag-create": {
634
+ if (!isSafeBranchName(action.name)) return { error: "invalid-name", message: `unsafe tag name: ${action.name}` };
635
+ if (!isSafeRev(action.commit)) return { error: "invalid-name", message: `unsafe commit: ${action.commit}` };
636
+ const msg = action.message?.trim() ?? "";
637
+ const flags = msg === "" ? [] : ["-a", "-m", msg];
638
+ return { argv: [["git", "tag", ...flags, "--end-of-options", action.name, action.commit]] };
639
+ }
640
+ case "tag-delete":
641
+ if (!isSafeBranchName(action.name)) return { error: "invalid-name", message: `unsafe tag name: ${action.name}` };
642
+ return { argv: [["git", "tag", "-d", "--end-of-options", action.name]] };
643
+ case "stash-push": {
644
+ const msg = action.message?.trim() ?? "";
645
+ return { argv: [msg === "" ? ["git", "stash", "push"] : ["git", "stash", "push", "-m", msg]] };
646
+ }
647
+ case "stash-apply":
648
+ case "stash-pop":
649
+ case "stash-drop": {
650
+ if (!Number.isInteger(action.index) || action.index < 0) {
651
+ return { error: "invalid-index", message: `invalid stash index: ${action.index}` };
652
+ }
653
+ const verb = action.kind === "stash-apply" ? "apply" : action.kind === "stash-pop" ? "pop" : "drop";
654
+ return { argv: [["git", "stash", verb, "--end-of-options", `stash@{${action.index}}`]] };
655
+ }
656
+ case "revert": {
657
+ if (!isSafeRev(action.commit)) return { error: "invalid-name", message: `unsafe commit: ${action.commit}` };
658
+ return { argv: [["git", "revert", "--no-edit", "--end-of-options", action.commit]] };
659
+ }
660
+ case "reset": {
661
+ if (!isSafeRev(action.commit)) return { error: "invalid-name", message: `unsafe commit: ${action.commit}` };
662
+ if (action.mode !== "soft" && action.mode !== "mixed" && action.mode !== "hard") {
663
+ return { error: "invalid-name", message: `invalid reset mode: ${String(action.mode)}` };
664
+ }
665
+ return { argv: [["git", "reset", `--${action.mode}`, "--end-of-options", action.commit]] };
666
+ }
589
667
  }
590
668
  }
669
+ function isIndexBusy(outcome) {
670
+ if (!("run" in outcome) || outcome.run.exitCode === 0) return false;
671
+ return /index\.lock|Unable to create.*index|another git process/i.test(outcome.run.stderr + outcome.run.stdout);
672
+ }
673
+ function classifyActionFailure(stdout, stderr, exitCode) {
674
+ const combined = stderr + stdout;
675
+ const err = stderr.trim();
676
+ if (/CONFLICT|Merge conflict|needs merge|could not restore untracked/i.test(combined)) {
677
+ return { code: "conflict", message: err || "merge conflict" };
678
+ }
679
+ if (/index\.lock|Unable to create.*index|another git process/i.test(combined)) {
680
+ return { code: "index-busy", message: err };
681
+ }
682
+ if (/nothing to commit|no changes added/i.test(combined)) {
683
+ return { code: "git-error", message: err || "nothing to commit" };
684
+ }
685
+ if (/No such ref|not a valid reference|is not a stash|no tag|tag .* not found|unknown revision|bad revision|Could not parse object|ambiguous argument/i.test(combined)) {
686
+ return { code: "not-found", message: err || "not found" };
687
+ }
688
+ if (/would be overwritten by (checkout|merge)|local changes|overwritten by merge|Your local changes/i.test(stderr)) {
689
+ return { code: "local-changes-block", message: err };
690
+ }
691
+ if (/is a merge but no -m|mainline parent/i.test(combined)) {
692
+ return { code: "revert-merge", message: err || "cannot revert a merge commit from the panel" };
693
+ }
694
+ return { code: "git-error", message: err || `git exited ${exitCode}` };
695
+ }
591
696
  async function runAction(deps, config, request) {
592
697
  const workspace = await resolveWorkspace(deps, request.sessionId);
593
698
  if (!workspace.ok) return { ok: false, error: mapWorkspaceFailure(workspace.failure) };
594
699
  const root = workspace.root;
595
700
  const headProbe = await runCommand(deps.run, ["git", "rev-parse", "--verify", "HEAD"], root, "head-probe", deps.signal);
596
701
  const unborn = !("run" in headProbe) || headProbe.run.exitCode !== 0;
702
+ const act = request.action;
703
+ if (act.kind === "stash-apply" || act.kind === "stash-pop" || act.kind === "stash-drop") {
704
+ if (!Number.isInteger(act.index) || act.index < 0) {
705
+ return { ok: false, error: { code: "invalid-index", message: `invalid stash index: ${act.index}` } };
706
+ }
707
+ const probe = await runCommand(deps.run, ["git", "rev-parse", "--verify", "--quiet", "--end-of-options", `stash@{${act.index}}`], root, "stash-verify", deps.signal);
708
+ const sha = "run" in probe && probe.run.exitCode === 0 ? probe.run.stdout.trim() : "";
709
+ if (sha === "" || sha !== act.sha) {
710
+ return { ok: false, error: { code: "not-found", message: "stash entry changed; refresh and retry" } };
711
+ }
712
+ }
597
713
  if (request.action.kind === "discard") {
598
714
  const removed = await discardUntracked(deps, config, root, request.sessionId, request.action.paths);
599
715
  if (removed !== null) {
@@ -611,7 +727,11 @@ async function runAction(deps, config, request) {
611
727
  let lastOutput = "";
612
728
  for (let step = 0; step < plan.argv.length; step += 1) {
613
729
  const argv = plan.argv[step];
614
- const outcome = await runCommand(deps.run, argv, root, "action", deps.signal);
730
+ let outcome = await runCommand(deps.run, argv, root, "action", deps.signal);
731
+ if (isIndexBusy(outcome)) {
732
+ await new Promise((resolve) => setTimeout(resolve, 150));
733
+ outcome = await runCommand(deps.run, argv, root, "action-retry", deps.signal);
734
+ }
615
735
  const where = plan.argv.length > 1 ? ` (step ${step + 1}/${plan.argv.length}: ${argv.join(" ")})` : "";
616
736
  if ("failure" in outcome) {
617
737
  const message = outcome.failure instanceof Error ? outcome.failure.message : String(outcome.failure);
@@ -621,14 +741,20 @@ async function runAction(deps, config, request) {
621
741
  if (outcome.run.timedOut) return { ok: false, error: { code: "timeout" } };
622
742
  lastOutput = outcome.run.stdout || outcome.run.stderr;
623
743
  if (outcome.run.exitCode !== 0) {
624
- const stderr = outcome.run.stderr;
625
- if (/nothing to commit|no changes added/i.test(stderr + lastOutput)) {
626
- return { ok: false, error: { code: "git-error", message: (stderr.trim() || "nothing to commit") + where } };
627
- }
628
- if (/would be overwritten by checkout|local changes/i.test(stderr)) {
629
- return { ok: false, error: { code: "local-changes-block", message: stderr.trim() + where } };
744
+ const failure = classifyActionFailure(outcome.run.stdout, outcome.run.stderr, outcome.run.exitCode ?? -1);
745
+ if (request.action.kind === "revert" && failure.code === "conflict") {
746
+ let abort = await runCommand(deps.run, ["git", "revert", "--abort"], root, "revert-abort", deps.signal);
747
+ if (isIndexBusy(abort)) {
748
+ await new Promise((resolve) => setTimeout(resolve, 150));
749
+ abort = await runCommand(deps.run, ["git", "revert", "--abort"], root, "revert-abort-retry", deps.signal);
750
+ }
751
+ const aborted = "run" in abort && abort.run.exitCode === 0;
752
+ return aborted ? { ok: false, error: { code: "revert-conflict", message: failure.message + where } } : { ok: false, error: { code: "revert-stuck", message: failure.message + where } };
630
753
  }
631
- return { ok: false, error: { code: "git-error", message: (stderr.trim() || `git exited ${outcome.run.exitCode}`) + where } };
754
+ return { ok: false, error: { code: failure.code, message: failure.message + where } };
755
+ }
756
+ if (request.action.kind === "stash-push" && /No local changes to save/i.test(outcome.run.stdout + outcome.run.stderr)) {
757
+ return { ok: false, error: { code: "git-error", message: (outcome.run.stdout.trim() || "No local changes to save") + where } };
632
758
  }
633
759
  }
634
760
  const snapshot = await snapshotForSession(deps, config, request.sessionId);
@@ -693,6 +819,7 @@ function imageMimeFor(path) {
693
819
 
694
820
  // src/host/queries.ts
695
821
  var GRAPH_FORMAT = "--format=%H%x1f%h%x1f%P%x1f%an%x1f%aI%x1f%D%x1f%s%x1e";
822
+ var VISIBLE_REFS = ["--branches", "--remotes", "--tags", "HEAD"];
696
823
  function isHexLike(text) {
697
824
  return /^[0-9a-fA-F]{7,40}$/.test(text.trim());
698
825
  }
@@ -726,6 +853,8 @@ async function runQuery(deps, config, request) {
726
853
  return await queryBranches(deps, root);
727
854
  case "tags":
728
855
  return await queryTags(deps, root);
856
+ case "stash-list":
857
+ return await queryStashList(deps, root);
729
858
  case "authors":
730
859
  return await queryAuthors(deps, root);
731
860
  case "last-commit-message":
@@ -741,7 +870,7 @@ async function runQuery(deps, config, request) {
741
870
  async function queryHistory(deps, root, q) {
742
871
  const limit = Number.isFinite(q.limit) ? Math.min(500, Math.max(1, Math.trunc(q.limit))) : 100;
743
872
  const skip = Number.isFinite(q.skip) ? Math.max(0, Math.trunc(q.skip)) : 0;
744
- const args = ["git", "log", GRAPH_FORMAT, `--max-count=${limit}`, `--skip=${skip}`];
873
+ const args = ["git", "log", "--topo-order", GRAPH_FORMAT, `--max-count=${limit}`, `--skip=${skip}`];
745
874
  const search = q.search?.trim() ?? "";
746
875
  const hexJump = search !== "" && isHexLike(search);
747
876
  const countArgs = ["git", "rev-list", "--count"];
@@ -754,7 +883,7 @@ async function queryHistory(deps, root, q) {
754
883
  if (!isSafeRev(q.ref)) return { ok: false, error: { code: "invalid-name", message: `unsafe ref: ${q.ref}` } };
755
884
  filters.push("--end-of-options", q.ref);
756
885
  } else {
757
- filters.push("--all");
886
+ filters.push(...VISIBLE_REFS);
758
887
  }
759
888
  args.push(...filters);
760
889
  countArgs.push(...filters);
@@ -1082,8 +1211,13 @@ async function queryTags(deps, root) {
1082
1211
  const tags = "run" in res && res.run.exitCode === 0 ? parseTags(res.run.stdout) : [];
1083
1212
  return { ok: true, value: { kind: "tags", tags } };
1084
1213
  }
1214
+ async function queryStashList(deps, root) {
1215
+ const res = await runCommand(deps.run, ["git", "stash", "list", "-z", "--format=%gd%x1f%H%x1f%gs%x1f%cr"], root, "stash-list", deps.signal);
1216
+ const entries = "run" in res && res.run.exitCode === 0 ? parseStashList(res.run.stdout) : [];
1217
+ return { ok: true, value: { kind: "stash-list", entries } };
1218
+ }
1085
1219
  async function queryAuthors(deps, root) {
1086
- const res = await runCommand(deps.run, ["git", "log", "--all", "--format=%an", "--max-count=2000"], root, "authors", deps.signal);
1220
+ const res = await runCommand(deps.run, ["git", "log", ...VISIBLE_REFS, "--format=%an", "--max-count=2000"], root, "authors", deps.signal);
1087
1221
  const authors = "run" in res && res.run.exitCode === 0 ? [...new Set(res.run.stdout.split("\n").map((s) => s.trim()).filter((s) => s !== ""))].sort((a, b) => a.localeCompare(b)) : [];
1088
1222
  return { ok: true, value: { kind: "authors", authors } };
1089
1223
  }
@@ -1098,6 +1232,219 @@ async function queryWorktreeStats(deps, config, sessionId) {
1098
1232
  return { ok: true, value: { kind: "worktree-stats", stats: snapshot.value.stats } };
1099
1233
  }
1100
1234
 
1235
+ // src/host/suggest.ts
1236
+ var SYSTEM_PROMPT = [
1237
+ "You are an expert at writing concise, accurate git commit messages.",
1238
+ "Given a repository\u2019s uncommitted changes, write ONE commit message for them.",
1239
+ "The message must start with a short imperative subject line (72 chars max), optionally followed by a blank line and a brief body explaining what changed and why.",
1240
+ "Return only the message text: no quotes, code fences, prefixes, bullet lists, or explanations.",
1241
+ "Match the language and style of the recent commit message when one is provided; otherwise write in English.",
1242
+ "Focus on the most important change; do not mechanically list every file."
1243
+ ].join("\n");
1244
+ var UNTRACKED_FILE_CAP = 16;
1245
+ function resolveRoute(config, agentDefaultModel) {
1246
+ if (config.suggestProvider !== void 0 && config.suggestModel !== void 0) {
1247
+ return { provider: config.suggestProvider, model: config.suggestModel };
1248
+ }
1249
+ return agentDefaultModel?.currentSelection();
1250
+ }
1251
+ function withDeadline(timeoutMs, parent) {
1252
+ const controller = new AbortController();
1253
+ let timedOut = false;
1254
+ const timer = setTimeout(() => {
1255
+ timedOut = true;
1256
+ controller.abort();
1257
+ }, timeoutMs);
1258
+ const onAbort = () => {
1259
+ controller.abort();
1260
+ };
1261
+ if (parent !== void 0) {
1262
+ if (parent.aborted) {
1263
+ controller.abort();
1264
+ } else parent.addEventListener("abort", onAbort, { once: true });
1265
+ }
1266
+ return {
1267
+ signal: controller.signal,
1268
+ expired: () => timedOut,
1269
+ dispose: () => {
1270
+ clearTimeout(timer);
1271
+ parent?.removeEventListener("abort", onAbort);
1272
+ }
1273
+ };
1274
+ }
1275
+ function truncateUtf8(text, maxBytes) {
1276
+ if (Buffer.byteLength(text, "utf8") <= maxBytes) return text;
1277
+ let lo = 0;
1278
+ let hi = text.length;
1279
+ while (lo < hi) {
1280
+ const mid = lo + hi + 1 >> 1;
1281
+ if (Buffer.byteLength(text.slice(0, mid), "utf8") <= maxBytes) lo = mid;
1282
+ else hi = mid - 1;
1283
+ }
1284
+ return text.slice(0, lo);
1285
+ }
1286
+ function frameChanges(files, diff, recentCommitMessage) {
1287
+ return JSON.stringify({
1288
+ files,
1289
+ diff,
1290
+ recentCommitMessage: recentCommitMessage === "" ? null : recentCommitMessage
1291
+ });
1292
+ }
1293
+ function normalizeMessage(raw) {
1294
+ let m = raw.trim();
1295
+ if (m.startsWith("```")) {
1296
+ const firstNl = m.indexOf("\n");
1297
+ if (firstNl !== -1) {
1298
+ m = m.slice(firstNl + 1);
1299
+ m = m.endsWith("```") ? m.slice(0, -3) : m;
1300
+ m = m.trim();
1301
+ }
1302
+ }
1303
+ return m.replace(/\n---+\s*$/, "").trim();
1304
+ }
1305
+ function finishError(reason) {
1306
+ if (reason === void 0 || reason.kind === "stop") return void 0;
1307
+ if (reason.kind === "max-tokens") return { ok: false, error: { code: "llm-output", message: "model output hit the token limit" } };
1308
+ if (reason.kind === "tool-calls") return { ok: false, error: { code: "llm-output", message: "model requested a tool instead of writing a message" } };
1309
+ const failure = "failure" in reason ? reason.failure : void 0;
1310
+ return { ok: false, error: { code: "llm-error", message: failure?.message ?? `model stream finished abnormally (${reason.kind})` } };
1311
+ }
1312
+ async function collectDiff(deps, root, hasHead, pathArgs) {
1313
+ const cmds = hasHead ? [["git", "diff", "HEAD", ...pathArgs]] : [["git", "diff", "--cached", ...pathArgs], ["git", "diff", ...pathArgs]];
1314
+ const runs = await Promise.all(cmds.map((argv) => runCommand(deps.run, argv, root, "suggest-diff", deps.signal)));
1315
+ let diff = "";
1316
+ for (const res of runs) {
1317
+ if ("failure" in res) return { ok: false, error: { code: "git-unavailable", message: "diff failed" } };
1318
+ if (res.run.cancelled) return { ok: false, error: { code: "cancelled" } };
1319
+ if (res.run.timedOut) return { ok: false, error: { code: "timeout" } };
1320
+ if (res.run.exitCode !== 0) {
1321
+ return { ok: false, error: { code: "git-error", message: res.run.stderr.trim() || "git diff failed" } };
1322
+ }
1323
+ diff += res.run.stdout;
1324
+ }
1325
+ return diff;
1326
+ }
1327
+ async function appendUntracked(deps, root, files, diff, budget) {
1328
+ let text = diff;
1329
+ let truncated = false;
1330
+ const candidates = files.filter((c) => c.status === "untracked" && !c.isDirectory && isSafePath(c.path));
1331
+ for (const c of candidates.slice(0, UNTRACKED_FILE_CAP)) {
1332
+ const remaining = budget - Buffer.byteLength(text, "utf8");
1333
+ if (remaining <= 0) {
1334
+ truncated = true;
1335
+ break;
1336
+ }
1337
+ const res = await runCommand(deps.run, ["git", "diff", "--no-index", "--", "/dev/null", c.path], root, "suggest-untracked", deps.signal);
1338
+ if ("failure" in res || res.run.cancelled || res.run.timedOut) continue;
1339
+ const chunk = res.run.stdout;
1340
+ if (chunk === "") continue;
1341
+ if (Buffer.byteLength(chunk, "utf8") > remaining) {
1342
+ text += truncateUtf8(chunk, remaining) + "\n... [diff truncated]";
1343
+ truncated = true;
1344
+ break;
1345
+ }
1346
+ text += chunk;
1347
+ }
1348
+ return { diff: text, truncated };
1349
+ }
1350
+ async function runSuggest(deps, config, request) {
1351
+ if (!config.suggestEnabled) {
1352
+ return { ok: false, error: { code: "suggest-disabled", message: "AI suggest is turned off in the plugin config" } };
1353
+ }
1354
+ const workspace = await resolveWorkspace(deps, request.sessionId);
1355
+ if (!workspace.ok) return { ok: false, error: mapWorkspaceFailure(workspace.failure) };
1356
+ const root = workspace.root;
1357
+ const paths = request.paths ?? [];
1358
+ for (const path of paths) {
1359
+ if (!isSafePath(path)) return { ok: false, error: { code: "invalid-path", message: path } };
1360
+ }
1361
+ const pathArgs = paths.length > 0 ? ["--", ...paths] : [];
1362
+ const pathSet = new Set(paths);
1363
+ const [statusRes, headRes, lastRes] = await Promise.all([
1364
+ runCommand(deps.run, ["git", "status", "--porcelain=v1", "-z"], root, "suggest-status", deps.signal),
1365
+ runCommand(deps.run, ["git", "rev-parse", "--verify", "HEAD"], root, "suggest-head", deps.signal),
1366
+ runCommand(deps.run, ["git", "log", "-1", "--format=%B"], root, "suggest-last", deps.signal)
1367
+ ]);
1368
+ if ("failure" in statusRes) return { ok: false, error: { code: "git-unavailable", message: "status failed" } };
1369
+ if (statusRes.run.cancelled) return { ok: false, error: { code: "cancelled" } };
1370
+ if (statusRes.run.timedOut) return { ok: false, error: { code: "timeout" } };
1371
+ if (statusRes.run.exitCode !== 0) {
1372
+ return { ok: false, error: { code: "git-error", message: statusRes.run.stderr.trim() || "git status failed" } };
1373
+ }
1374
+ const hasHead = "run" in headRes && headRes.run.exitCode === 0;
1375
+ const recentCommitMessage = "run" in lastRes && lastRes.run.exitCode === 0 ? lastRes.run.stdout.replace(/\n+$/, "") : "";
1376
+ const all = statusRes.run.stdoutLossy ? [] : parseStatus(statusRes.run.stdout);
1377
+ const files = pathSet.size > 0 ? all.filter((c) => pathSet.has(c.path)) : all;
1378
+ const collected = await collectDiff(deps, root, hasHead, pathArgs);
1379
+ if (typeof collected !== "string") return collected;
1380
+ if (files.length === 0 && collected.trim() === "") {
1381
+ return { ok: false, error: { code: "empty-diff", message: "no uncommitted changes to summarize" } };
1382
+ }
1383
+ let diff = collected;
1384
+ let truncated = false;
1385
+ if (Buffer.byteLength(diff, "utf8") > config.suggestMaxBytes) {
1386
+ diff = truncateUtf8(diff, config.suggestMaxBytes) + "\n... [diff truncated]";
1387
+ truncated = true;
1388
+ }
1389
+ const untracked = await appendUntracked(deps, root, files, diff, config.suggestMaxBytes);
1390
+ diff = untracked.diff;
1391
+ truncated = truncated || untracked.truncated;
1392
+ const llm = deps.getLlm?.();
1393
+ const route = resolveRoute(config, deps.getAgentDefaultModel?.());
1394
+ if (llm === void 0 || route === void 0) {
1395
+ return { ok: false, error: { code: "llm-unavailable", message: "no model service or route is configured" } };
1396
+ }
1397
+ const frame = frameChanges(
1398
+ files.map((c) => ({ path: c.path, status: c.status })),
1399
+ diff,
1400
+ recentCommitMessage
1401
+ );
1402
+ const deadline = withDeadline(config.suggestTimeoutMs, deps.signal);
1403
+ const options = {
1404
+ provider: route.provider,
1405
+ model: route.model,
1406
+ system: SYSTEM_PROMPT,
1407
+ messages: [{
1408
+ id: "git-panel-suggest",
1409
+ role: "user",
1410
+ content: [{ type: "text", text: frame }],
1411
+ source: { kind: "user" }
1412
+ }],
1413
+ maxTokens: config.suggestMaxOutputTokens,
1414
+ temperature: 0.3,
1415
+ signal: deadline.signal
1416
+ };
1417
+ let output = "";
1418
+ let finish;
1419
+ try {
1420
+ for await (const chunk of llm.stream(options)) {
1421
+ if (chunk.type === "text-delta") output += chunk.text;
1422
+ else if (chunk.type === "finish") finish = chunk.reason;
1423
+ }
1424
+ } catch (error) {
1425
+ if (deps.signal?.aborted === true) return { ok: false, error: { code: "cancelled" } };
1426
+ if (deadline.expired()) return { ok: false, error: { code: "llm-error", message: `model call timed out after ${config.suggestTimeoutMs}ms` } };
1427
+ return { ok: false, error: { code: "llm-error", message: error instanceof Error ? error.message : "model stream failed" } };
1428
+ } finally {
1429
+ deadline.dispose();
1430
+ }
1431
+ if (deps.signal?.aborted === true) return { ok: false, error: { code: "cancelled" } };
1432
+ if (deadline.expired()) return { ok: false, error: { code: "llm-error", message: `model call timed out after ${config.suggestTimeoutMs}ms` } };
1433
+ const terminal = finishError(finish);
1434
+ if (terminal !== void 0) return terminal;
1435
+ const message = normalizeMessage(output);
1436
+ if (message === "") return { ok: false, error: { code: "llm-output", message: "model produced no message text" } };
1437
+ return {
1438
+ ok: true,
1439
+ value: {
1440
+ message,
1441
+ provider: route.provider,
1442
+ model: route.model,
1443
+ ...truncated ? { truncated: true } : {}
1444
+ }
1445
+ };
1446
+ }
1447
+
1101
1448
  // src/host/version.ts
1102
1449
  import { readFile as readFile2 } from "node:fs/promises";
1103
1450
  import { dirname as dirname2, join as join4 } from "node:path";
@@ -1225,8 +1572,8 @@ async function checkLatestVersion(options = {}) {
1225
1572
  }
1226
1573
 
1227
1574
  // src/host/index.ts
1228
- var _version_dec, _query_dec, _run_dec, _snapshot_dec, _a, _init;
1229
- var GitPanelService = class extends (_a = TypertRemoteService, _snapshot_dec = [Remote("snapshot")], _run_dec = [Remote("run")], _query_dec = [Remote("query")], _version_dec = [Remote("version")], _a) {
1575
+ var _version_dec, _suggest_dec, _query_dec, _run_dec, _snapshot_dec, _a, _init;
1576
+ var GitPanelService = class extends (_a = TypertRemoteService, _snapshot_dec = [Remote("snapshot")], _run_dec = [Remote("run")], _query_dec = [Remote("query")], _suggest_dec = [Remote("suggest")], _version_dec = [Remote("version")], _a) {
1230
1577
  constructor(ctx, config) {
1231
1578
  super(ctx, "gitPanel");
1232
1579
  __runInitializers(_init, 5, this);
@@ -1272,7 +1619,13 @@ var GitPanelService = class extends (_a = TypertRemoteService, _snapshot_dec = [
1272
1619
  }
1273
1620
  },
1274
1621
  rootCache,
1275
- rootNegCache
1622
+ rootNegCache,
1623
+ // Optional model services, resolved per request so a provider fiber that
1624
+ // activates late or restarts is picked up — absent in deployments without
1625
+ // a model backend, where the suggest endpoint reports `llm-unavailable`
1626
+ // rather than failing activation (kept out of `static inject` on purpose).
1627
+ getLlm: () => get("llm"),
1628
+ getAgentDefaultModel: () => get("agentDefaultModel")
1276
1629
  };
1277
1630
  }
1278
1631
  async snapshot(request, signal) {
@@ -1284,6 +1637,9 @@ var GitPanelService = class extends (_a = TypertRemoteService, _snapshot_dec = [
1284
1637
  async query(request, signal) {
1285
1638
  return runQuery(this.withSignal(signal), this.liveConfig(), request);
1286
1639
  }
1640
+ async suggest(request, signal) {
1641
+ return runSuggest(this.withSignal(signal), this.liveConfig(), request);
1642
+ }
1287
1643
  async version(request) {
1288
1644
  try {
1289
1645
  return request.check === true ? await checkLatestVersion() : await readVersionInfo();
@@ -1305,6 +1661,7 @@ _init = __decoratorStart(_a);
1305
1661
  __decorateElement(_init, 1, "snapshot", _snapshot_dec, GitPanelService);
1306
1662
  __decorateElement(_init, 1, "run", _run_dec, GitPanelService);
1307
1663
  __decorateElement(_init, 1, "query", _query_dec, GitPanelService);
1664
+ __decorateElement(_init, 1, "suggest", _suggest_dec, GitPanelService);
1308
1665
  __decorateElement(_init, 1, "version", _version_dec, GitPanelService);
1309
1666
  __decoratorMetadata(_init, GitPanelService);
1310
1667
  __publicField(GitPanelService, "inject", ["subprocess", "sessions", "sessionPersistence"]);
@@ -1319,13 +1676,19 @@ __publicField(GitPanelService, "Config", Schema.object({
1319
1676
  defaultDiffView: Schema.union([
1320
1677
  Schema.const("unified").description("\u7EDF\u4E00\u89C6\u56FE\uFF08\u5355\u680F\u884C\u5185\u5BF9\u6BD4\uFF09"),
1321
1678
  Schema.const("split").description("\u5E76\u6392\u89C6\u56FE\uFF08\u5DE6\u53F3\u5206\u680F\u5BF9\u6BD4\uFF09")
1322
- ]).default("unified").volatile().description("\u5DEE\u5F02\u5BF9\u6BD4\u9ED8\u8BA4\u89C6\u56FE")
1679
+ ]).default("unified").volatile().description("\u5DEE\u5F02\u5BF9\u6BD4\u9ED8\u8BA4\u89C6\u56FE"),
1680
+ graphStyle: Schema.union([
1681
+ Schema.const("parallel").description("\u5E73\u884C\u7EBF\uFF08VSCode \u98CE\u683C\uFF0C\u5206\u652F\u5404\u5360\u4E00\u5217\uFF09"),
1682
+ Schema.const("compact").description("\u7D27\u51D1\uFF08git log \u98CE\u683C\uFF0C\u5171\u4EAB\u7956\u5148\u5408\u5E76\u4E3A\u4E00\u5217\uFF09")
1683
+ ]).default("parallel").volatile().description("\u63D0\u4EA4\u5386\u53F2\u56FE\u7EBF\u6761\u6837\u5F0F"),
1684
+ suggestEnabled: Schema.boolean().default(true).volatile().description("\u63D0\u4EA4\u6846\u663E\u793A AI \u751F\u6210\u6309\u94AE")
1323
1685
  }));
1324
1686
  var index_default = GitPanelService;
1325
1687
  export {
1326
1688
  DEFAULT_CONFIG,
1327
1689
  GitPanelService,
1328
1690
  checkLatestVersion,
1691
+ classifyActionFailure,
1329
1692
  compareVersions,
1330
1693
  createGitRunner,
1331
1694
  index_default as default,
@@ -1335,12 +1698,15 @@ export {
1335
1698
  parseGraphLog,
1336
1699
  parseNameStatus,
1337
1700
  parseRepository,
1701
+ parseStashList,
1338
1702
  parseStatus,
1703
+ parseTags,
1339
1704
  planAction,
1340
1705
  readVersionInfo,
1341
1706
  resolveWorkspace,
1342
1707
  runAction,
1343
1708
  runQuery,
1709
+ runSuggest,
1344
1710
  snapshotForSession,
1345
1711
  sumNumstat
1346
1712
  };
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Structural faces of the host `llm` and `agentDefaultModel` services.
3
+ *
4
+ * The `llm` face is typed against the real `@deepseek-ai/dsh-llm` contract
5
+ * via type-only imports — the build externalizes the whole `@deepseek-ai/*`
6
+ * scope, so this adds no runtime dependency, and any wire-shape drift (e.g.
7
+ * the finish chunk's `reason` envelope) breaks typecheck instead of silently
8
+ * passing. Both services are shipped by `@deepseek-ai/dsh-base`; they are
9
+ * resolved per request (never frozen at construction) and absence degrades
10
+ * the suggest endpoint to a typed `llm-unavailable` failure instead of
11
+ * breaking plugin activation (kept out of `static inject` on purpose).
12
+ */
13
+ import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm';
14
+ /** Minimal face of the host `llm` service (`ctx.llm`). */
15
+ export interface LlmFace {
16
+ stream(options: GenerateOptions): AsyncIterable<StreamChunk>;
17
+ listProviders?(): readonly {
18
+ readonly id: string;
19
+ }[];
20
+ }
21
+ /** Minimal face of the host `agentDefaultModel` service. */
22
+ export interface AgentDefaultModelFace {
23
+ currentSelection(): {
24
+ readonly provider: string;
25
+ readonly model: string;
26
+ } | undefined;
27
+ }
@@ -2,7 +2,7 @@
2
2
  * git output parsers: porcelain status, log, branch, numstat, name-status.
3
3
  * Pure functions over raw stdout, no I/O.
4
4
  */
5
- import type { GitBranch, GitChange, GitCommit, GitFileStat, GraphCommit, GitRef } from './types.ts';
5
+ import type { GitBranch, GitChange, GitCommit, GitFileStat, GraphCommit, GitRef, StashEntry } from './types.ts';
6
6
  /**
7
7
  * Build a GitCommit from a `%H\x1f%h\x1f%s\x1f%an\x1f%aI`-ordered field array
8
8
  * (the body, when present, is the caller's `parts.slice(5)`). Null when the
@@ -27,6 +27,14 @@ export declare function parseRefs(decoration: string): GitRef[];
27
27
  export declare function parseBranches(stdout: string): GitBranch[];
28
28
  /** Parse `git for-each-ref` tag lines: `name\0shortHash` per line. */
29
29
  export declare function parseTags(stdout: string): GitBranch[];
30
+ /**
31
+ * Parse `git stash list -z --format=%gd%x1f%H%x1f%gs%x1f%cr` into stash entries.
32
+ * Each NUL-separated record is: selector (`stash@{N}`), the stash commit SHA, the
33
+ * reflog subject, and a relative time. The subject is either auto ("WIP on
34
+ * <branch>: <sha> <subj>") or custom ("On <branch>: <message>"); the branch is
35
+ * the text between "on " and the first ": ", and the message is after it.
36
+ */
37
+ export declare function parseStashList(stdout: string): StashEntry[];
30
38
  /**
31
39
  * Parse `git show --name-status -z` into stats with an explicit state machine:
32
40
  * read a status token, then consume exactly the paths it owns (2 for R/C, 1
@@ -0,0 +1,4 @@
1
+ import type { GitPanelConfig, SnapshotDeps } from './core.ts';
2
+ import type { GitSuggestRequest, GitSuggestResult } from './types.ts';
3
+ /** Generate a commit message for the session's uncommitted changes. */
4
+ export declare function runSuggest(deps: SnapshotDeps, config: GitPanelConfig, request: GitSuggestRequest): Promise<GitSuggestResult>;