@xbzbing/dsh-git-panel 1.1.2 → 1.3.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,4 +1,5 @@
1
1
  import type { GitRunner } from './git.ts';
2
+ import type { AgentDefaultModelFace, LlmFace } from './llm-face.ts';
2
3
  import type { DiffViewMode, GitChange, GitErrorCode, GitSnapshotResult } from './types.ts';
3
4
  export interface GitPanelConfig {
4
5
  readonly timeoutMs: number;
@@ -10,6 +11,15 @@ export interface GitPanelConfig {
10
11
  readonly showInputPill: boolean;
11
12
  /** Default diff layout the views open with (user can switch per-diff). */
12
13
  readonly defaultDiffView: DiffViewMode;
14
+ /** Whether the "AI suggest" button shows in the commit box. */
15
+ readonly suggestEnabled: boolean;
16
+ /** Max diff bytes sent to the model; beyond this the diff is truncated. */
17
+ readonly suggestMaxBytes: number;
18
+ readonly suggestMaxOutputTokens: number;
19
+ readonly suggestTimeoutMs: number;
20
+ /** Optional provider/model route override; absent → the agent default. */
21
+ readonly suggestProvider?: string;
22
+ readonly suggestModel?: string;
13
23
  }
14
24
  export declare const DEFAULT_CONFIG: GitPanelConfig;
15
25
  export declare function normalizeConfig(raw: unknown): GitPanelConfig;
@@ -61,6 +71,15 @@ export interface SnapshotDeps {
61
71
  * cwd is picked up soon after.
62
72
  */
63
73
  readonly rootNegCache?: Map<string, number>;
74
+ /**
75
+ * Optional per-request resolvers for the host `llm` and `agentDefaultModel`
76
+ * services (see llm-face.ts). Resolved on every suggest call — never frozen
77
+ * at construction — so a provider fiber that activates late or restarts is
78
+ * picked up. Absent in deployments without a model backend; the suggest
79
+ * endpoint then reports `llm-unavailable` instead of failing activation.
80
+ */
81
+ readonly getLlm?: () => LlmFace | undefined;
82
+ readonly getAgentDefaultModel?: () => AgentDefaultModelFace | undefined;
64
83
  }
65
84
  export type WorkspaceResolution = {
66
85
  readonly ok: true;
@@ -9,13 +9,14 @@
9
9
  import { TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol';
10
10
  import type { Context } from '@deepseek-ai/cordis';
11
11
  import Schema from '@deepseek-ai/schemastery';
12
- import type { GitActionRequest, GitActionResult, GitQueryRequest, GitQueryResponse, GitSnapshotRequest, GitSnapshotResult, GitVersionInfo, GitVersionRequest } from './types.ts';
13
- export type { GitSnapshot, GitSnapshotResult, GitSnapshotRequest, GitFailure, GitCommit, GraphCommit, GitRef, GitChange, GitChangeStatus, GitAction, GitActionRequest, GitActionResult, GitErrorCode, GitQuery, GitQueryRequest, GitQueryResponse, GitQueryResult, GitBranch, GitFileStat, WorktreeStats, GitVersionRequest, GitVersionInfo, DiffViewMode, DirEntry, } from './types.ts';
12
+ import type { GitActionRequest, GitActionResult, GitQueryRequest, GitQueryResponse, GitSnapshotRequest, GitSnapshotResult, GitSuggestRequest, GitSuggestResult, GitVersionInfo, GitVersionRequest } from './types.ts';
13
+ export type { GitSnapshot, GitSnapshotResult, GitSnapshotRequest, GitFailure, GitCommit, GraphCommit, GitRef, GitChange, GitChangeStatus, GitAction, GitActionRequest, GitActionResult, GitErrorCode, GitQuery, GitQueryRequest, GitQueryResponse, GitQueryResult, GitBranch, GitFileStat, WorktreeStats, GitVersionRequest, GitVersionInfo, DiffViewMode, DirEntry, GitSuggestRequest, GitSuggestResult, } from './types.ts';
14
14
  export { normalizeConfig, DEFAULT_CONFIG, snapshotForSession, resolveWorkspace } from './core.ts';
15
15
  export { createGitRunner } from './git.ts';
16
16
  export { parseStatus, parseGraphLog, parseBranches, parseNameStatus, sumNumstat } from './parser.ts';
17
17
  export { isSafePath, planAction, runAction } from './actions.ts';
18
18
  export { runQuery } from './queries.ts';
19
+ export { runSuggest } from './suggest.ts';
19
20
  export { readVersionInfo, checkLatestVersion, compareVersions, parseRepository } from './version.ts';
20
21
  export declare class GitPanelService extends TypertRemoteService {
21
22
  static inject: string[];
@@ -28,9 +29,11 @@ export declare class GitPanelService extends TypertRemoteService {
28
29
  static Config: Schema<Schemastery.ObjectS<NoInfer<{
29
30
  showInputPill: Schema<boolean, boolean, "volatile-defined">;
30
31
  defaultDiffView: Schema<"split" | "unified", "split" | "unified", "volatile-defined">;
32
+ suggestEnabled: Schema<boolean, boolean, "volatile-defined">;
31
33
  }>>, Schemastery.ObjectT<NoInfer<{
32
34
  showInputPill: Schema<boolean, boolean, "volatile-defined">;
33
35
  defaultDiffView: Schema<"split" | "unified", "split" | "unified", "volatile-defined">;
36
+ suggestEnabled: Schema<boolean, boolean, "volatile-defined">;
34
37
  }>>, "plain">;
35
38
  private readonly deps;
36
39
  private config;
@@ -40,6 +43,7 @@ export declare class GitPanelService extends TypertRemoteService {
40
43
  snapshot(request: GitSnapshotRequest, signal?: AbortSignal): Promise<GitSnapshotResult>;
41
44
  run(request: GitActionRequest, signal?: AbortSignal): Promise<GitActionResult>;
42
45
  query(request: GitQueryRequest, signal?: AbortSignal): Promise<GitQueryResponse>;
46
+ suggest(request: GitSuggestRequest, signal?: AbortSignal): Promise<GitSuggestResult>;
43
47
  version(request: GitVersionRequest): Promise<GitVersionInfo>;
44
48
  /** Re-read config so a live-edited volatile field (showInputPill) is current. */
45
49
  private liveConfig;
package/lib/host/index.js CHANGED
@@ -317,18 +317,30 @@ var DEFAULT_CONFIG = {
317
317
  maxChanges: 1e3,
318
318
  refreshIntervalMs: 3e4,
319
319
  showInputPill: true,
320
- defaultDiffView: "unified"
320
+ defaultDiffView: "unified",
321
+ suggestEnabled: true,
322
+ suggestMaxBytes: 64 * 1024,
323
+ suggestMaxOutputTokens: 200,
324
+ suggestTimeoutMs: 15e3
321
325
  };
322
326
  function normalizeConfig(raw) {
323
327
  const c = raw ?? {};
324
328
  const num = (v, d) => typeof v === "number" && Number.isFinite(v) && v >= 1 ? Math.floor(v) : d;
329
+ const hasProvider = typeof c.suggestProvider === "string" && c.suggestProvider !== "";
330
+ const hasModel = typeof c.suggestModel === "string" && c.suggestModel !== "";
331
+ const route = hasProvider && hasModel ? { suggestProvider: c.suggestProvider, suggestModel: c.suggestModel } : {};
325
332
  return {
326
333
  timeoutMs: num(c.timeoutMs, DEFAULT_CONFIG.timeoutMs),
327
334
  maxBytes: num(c.maxBytes, DEFAULT_CONFIG.maxBytes),
328
335
  maxChanges: num(c.maxChanges, DEFAULT_CONFIG.maxChanges),
329
336
  refreshIntervalMs: num(c.refreshIntervalMs, DEFAULT_CONFIG.refreshIntervalMs),
330
337
  showInputPill: readBool(c.showInputPill, DEFAULT_CONFIG.showInputPill),
331
- defaultDiffView: readDiffView(c.defaultDiffView, DEFAULT_CONFIG.defaultDiffView)
338
+ defaultDiffView: readDiffView(c.defaultDiffView, DEFAULT_CONFIG.defaultDiffView),
339
+ suggestEnabled: readBool(c.suggestEnabled, DEFAULT_CONFIG.suggestEnabled),
340
+ suggestMaxBytes: num(c.suggestMaxBytes, DEFAULT_CONFIG.suggestMaxBytes),
341
+ suggestMaxOutputTokens: num(c.suggestMaxOutputTokens, DEFAULT_CONFIG.suggestMaxOutputTokens),
342
+ suggestTimeoutMs: num(c.suggestTimeoutMs, DEFAULT_CONFIG.suggestTimeoutMs),
343
+ ...route
332
344
  };
333
345
  }
334
346
  function unwrapVolatile(value) {
@@ -526,6 +538,7 @@ async function snapshotForSession(deps, config, sessionId) {
526
538
  refreshIntervalMs: config.refreshIntervalMs,
527
539
  showInputPill: config.showInputPill,
528
540
  defaultDiffView: config.defaultDiffView,
541
+ suggestEnabled: config.suggestEnabled,
529
542
  checkedAt: Date.now()
530
543
  };
531
544
  return { ok: true, value: snapshot };
@@ -693,6 +706,7 @@ function imageMimeFor(path) {
693
706
 
694
707
  // src/host/queries.ts
695
708
  var GRAPH_FORMAT = "--format=%H%x1f%h%x1f%P%x1f%an%x1f%aI%x1f%D%x1f%s%x1e";
709
+ var VISIBLE_REFS = ["--branches", "--remotes", "--tags", "HEAD"];
696
710
  function isHexLike(text) {
697
711
  return /^[0-9a-fA-F]{7,40}$/.test(text.trim());
698
712
  }
@@ -741,7 +755,7 @@ async function runQuery(deps, config, request) {
741
755
  async function queryHistory(deps, root, q) {
742
756
  const limit = Number.isFinite(q.limit) ? Math.min(500, Math.max(1, Math.trunc(q.limit))) : 100;
743
757
  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}`];
758
+ const args = ["git", "log", "--topo-order", GRAPH_FORMAT, `--max-count=${limit}`, `--skip=${skip}`];
745
759
  const search = q.search?.trim() ?? "";
746
760
  const hexJump = search !== "" && isHexLike(search);
747
761
  const countArgs = ["git", "rev-list", "--count"];
@@ -754,7 +768,7 @@ async function queryHistory(deps, root, q) {
754
768
  if (!isSafeRev(q.ref)) return { ok: false, error: { code: "invalid-name", message: `unsafe ref: ${q.ref}` } };
755
769
  filters.push("--end-of-options", q.ref);
756
770
  } else {
757
- filters.push("--all");
771
+ filters.push(...VISIBLE_REFS);
758
772
  }
759
773
  args.push(...filters);
760
774
  countArgs.push(...filters);
@@ -1083,7 +1097,7 @@ async function queryTags(deps, root) {
1083
1097
  return { ok: true, value: { kind: "tags", tags } };
1084
1098
  }
1085
1099
  async function queryAuthors(deps, root) {
1086
- const res = await runCommand(deps.run, ["git", "log", "--all", "--format=%an", "--max-count=2000"], root, "authors", deps.signal);
1100
+ const res = await runCommand(deps.run, ["git", "log", ...VISIBLE_REFS, "--format=%an", "--max-count=2000"], root, "authors", deps.signal);
1087
1101
  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
1102
  return { ok: true, value: { kind: "authors", authors } };
1089
1103
  }
@@ -1098,6 +1112,219 @@ async function queryWorktreeStats(deps, config, sessionId) {
1098
1112
  return { ok: true, value: { kind: "worktree-stats", stats: snapshot.value.stats } };
1099
1113
  }
1100
1114
 
1115
+ // src/host/suggest.ts
1116
+ var SYSTEM_PROMPT = [
1117
+ "You are an expert at writing concise, accurate git commit messages.",
1118
+ "Given a repository\u2019s uncommitted changes, write ONE commit message for them.",
1119
+ "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.",
1120
+ "Return only the message text: no quotes, code fences, prefixes, bullet lists, or explanations.",
1121
+ "Match the language and style of the recent commit message when one is provided; otherwise write in English.",
1122
+ "Focus on the most important change; do not mechanically list every file."
1123
+ ].join("\n");
1124
+ var UNTRACKED_FILE_CAP = 16;
1125
+ function resolveRoute(config, agentDefaultModel) {
1126
+ if (config.suggestProvider !== void 0 && config.suggestModel !== void 0) {
1127
+ return { provider: config.suggestProvider, model: config.suggestModel };
1128
+ }
1129
+ return agentDefaultModel?.currentSelection();
1130
+ }
1131
+ function withDeadline(timeoutMs, parent) {
1132
+ const controller = new AbortController();
1133
+ let timedOut = false;
1134
+ const timer = setTimeout(() => {
1135
+ timedOut = true;
1136
+ controller.abort();
1137
+ }, timeoutMs);
1138
+ const onAbort = () => {
1139
+ controller.abort();
1140
+ };
1141
+ if (parent !== void 0) {
1142
+ if (parent.aborted) {
1143
+ controller.abort();
1144
+ } else parent.addEventListener("abort", onAbort, { once: true });
1145
+ }
1146
+ return {
1147
+ signal: controller.signal,
1148
+ expired: () => timedOut,
1149
+ dispose: () => {
1150
+ clearTimeout(timer);
1151
+ parent?.removeEventListener("abort", onAbort);
1152
+ }
1153
+ };
1154
+ }
1155
+ function truncateUtf8(text, maxBytes) {
1156
+ if (Buffer.byteLength(text, "utf8") <= maxBytes) return text;
1157
+ let lo = 0;
1158
+ let hi = text.length;
1159
+ while (lo < hi) {
1160
+ const mid = lo + hi + 1 >> 1;
1161
+ if (Buffer.byteLength(text.slice(0, mid), "utf8") <= maxBytes) lo = mid;
1162
+ else hi = mid - 1;
1163
+ }
1164
+ return text.slice(0, lo);
1165
+ }
1166
+ function frameChanges(files, diff, recentCommitMessage) {
1167
+ return JSON.stringify({
1168
+ files,
1169
+ diff,
1170
+ recentCommitMessage: recentCommitMessage === "" ? null : recentCommitMessage
1171
+ });
1172
+ }
1173
+ function normalizeMessage(raw) {
1174
+ let m = raw.trim();
1175
+ if (m.startsWith("```")) {
1176
+ const firstNl = m.indexOf("\n");
1177
+ if (firstNl !== -1) {
1178
+ m = m.slice(firstNl + 1);
1179
+ m = m.endsWith("```") ? m.slice(0, -3) : m;
1180
+ m = m.trim();
1181
+ }
1182
+ }
1183
+ return m.replace(/\n---+\s*$/, "").trim();
1184
+ }
1185
+ function finishError(reason) {
1186
+ if (reason === void 0 || reason.kind === "stop") return void 0;
1187
+ if (reason.kind === "max-tokens") return { ok: false, error: { code: "llm-output", message: "model output hit the token limit" } };
1188
+ if (reason.kind === "tool-calls") return { ok: false, error: { code: "llm-output", message: "model requested a tool instead of writing a message" } };
1189
+ const failure = "failure" in reason ? reason.failure : void 0;
1190
+ return { ok: false, error: { code: "llm-error", message: failure?.message ?? `model stream finished abnormally (${reason.kind})` } };
1191
+ }
1192
+ async function collectDiff(deps, root, hasHead, pathArgs) {
1193
+ const cmds = hasHead ? [["git", "diff", "HEAD", ...pathArgs]] : [["git", "diff", "--cached", ...pathArgs], ["git", "diff", ...pathArgs]];
1194
+ const runs = await Promise.all(cmds.map((argv) => runCommand(deps.run, argv, root, "suggest-diff", deps.signal)));
1195
+ let diff = "";
1196
+ for (const res of runs) {
1197
+ if ("failure" in res) return { ok: false, error: { code: "git-unavailable", message: "diff failed" } };
1198
+ if (res.run.cancelled) return { ok: false, error: { code: "cancelled" } };
1199
+ if (res.run.timedOut) return { ok: false, error: { code: "timeout" } };
1200
+ if (res.run.exitCode !== 0) {
1201
+ return { ok: false, error: { code: "git-error", message: res.run.stderr.trim() || "git diff failed" } };
1202
+ }
1203
+ diff += res.run.stdout;
1204
+ }
1205
+ return diff;
1206
+ }
1207
+ async function appendUntracked(deps, root, files, diff, budget) {
1208
+ let text = diff;
1209
+ let truncated = false;
1210
+ const candidates = files.filter((c) => c.status === "untracked" && !c.isDirectory && isSafePath(c.path));
1211
+ for (const c of candidates.slice(0, UNTRACKED_FILE_CAP)) {
1212
+ const remaining = budget - Buffer.byteLength(text, "utf8");
1213
+ if (remaining <= 0) {
1214
+ truncated = true;
1215
+ break;
1216
+ }
1217
+ const res = await runCommand(deps.run, ["git", "diff", "--no-index", "--", "/dev/null", c.path], root, "suggest-untracked", deps.signal);
1218
+ if ("failure" in res || res.run.cancelled || res.run.timedOut) continue;
1219
+ const chunk = res.run.stdout;
1220
+ if (chunk === "") continue;
1221
+ if (Buffer.byteLength(chunk, "utf8") > remaining) {
1222
+ text += truncateUtf8(chunk, remaining) + "\n... [diff truncated]";
1223
+ truncated = true;
1224
+ break;
1225
+ }
1226
+ text += chunk;
1227
+ }
1228
+ return { diff: text, truncated };
1229
+ }
1230
+ async function runSuggest(deps, config, request) {
1231
+ if (!config.suggestEnabled) {
1232
+ return { ok: false, error: { code: "suggest-disabled", message: "AI suggest is turned off in the plugin config" } };
1233
+ }
1234
+ const workspace = await resolveWorkspace(deps, request.sessionId);
1235
+ if (!workspace.ok) return { ok: false, error: mapWorkspaceFailure(workspace.failure) };
1236
+ const root = workspace.root;
1237
+ const paths = request.paths ?? [];
1238
+ for (const path of paths) {
1239
+ if (!isSafePath(path)) return { ok: false, error: { code: "invalid-path", message: path } };
1240
+ }
1241
+ const pathArgs = paths.length > 0 ? ["--", ...paths] : [];
1242
+ const pathSet = new Set(paths);
1243
+ const [statusRes, headRes, lastRes] = await Promise.all([
1244
+ runCommand(deps.run, ["git", "status", "--porcelain=v1", "-z"], root, "suggest-status", deps.signal),
1245
+ runCommand(deps.run, ["git", "rev-parse", "--verify", "HEAD"], root, "suggest-head", deps.signal),
1246
+ runCommand(deps.run, ["git", "log", "-1", "--format=%B"], root, "suggest-last", deps.signal)
1247
+ ]);
1248
+ if ("failure" in statusRes) return { ok: false, error: { code: "git-unavailable", message: "status failed" } };
1249
+ if (statusRes.run.cancelled) return { ok: false, error: { code: "cancelled" } };
1250
+ if (statusRes.run.timedOut) return { ok: false, error: { code: "timeout" } };
1251
+ if (statusRes.run.exitCode !== 0) {
1252
+ return { ok: false, error: { code: "git-error", message: statusRes.run.stderr.trim() || "git status failed" } };
1253
+ }
1254
+ const hasHead = "run" in headRes && headRes.run.exitCode === 0;
1255
+ const recentCommitMessage = "run" in lastRes && lastRes.run.exitCode === 0 ? lastRes.run.stdout.replace(/\n+$/, "") : "";
1256
+ const all = statusRes.run.stdoutLossy ? [] : parseStatus(statusRes.run.stdout);
1257
+ const files = pathSet.size > 0 ? all.filter((c) => pathSet.has(c.path)) : all;
1258
+ const collected = await collectDiff(deps, root, hasHead, pathArgs);
1259
+ if (typeof collected !== "string") return collected;
1260
+ if (files.length === 0 && collected.trim() === "") {
1261
+ return { ok: false, error: { code: "empty-diff", message: "no uncommitted changes to summarize" } };
1262
+ }
1263
+ let diff = collected;
1264
+ let truncated = false;
1265
+ if (Buffer.byteLength(diff, "utf8") > config.suggestMaxBytes) {
1266
+ diff = truncateUtf8(diff, config.suggestMaxBytes) + "\n... [diff truncated]";
1267
+ truncated = true;
1268
+ }
1269
+ const untracked = await appendUntracked(deps, root, files, diff, config.suggestMaxBytes);
1270
+ diff = untracked.diff;
1271
+ truncated = truncated || untracked.truncated;
1272
+ const llm = deps.getLlm?.();
1273
+ const route = resolveRoute(config, deps.getAgentDefaultModel?.());
1274
+ if (llm === void 0 || route === void 0) {
1275
+ return { ok: false, error: { code: "llm-unavailable", message: "no model service or route is configured" } };
1276
+ }
1277
+ const frame = frameChanges(
1278
+ files.map((c) => ({ path: c.path, status: c.status })),
1279
+ diff,
1280
+ recentCommitMessage
1281
+ );
1282
+ const deadline = withDeadline(config.suggestTimeoutMs, deps.signal);
1283
+ const options = {
1284
+ provider: route.provider,
1285
+ model: route.model,
1286
+ system: SYSTEM_PROMPT,
1287
+ messages: [{
1288
+ id: "git-panel-suggest",
1289
+ role: "user",
1290
+ content: [{ type: "text", text: frame }],
1291
+ source: { kind: "user" }
1292
+ }],
1293
+ maxTokens: config.suggestMaxOutputTokens,
1294
+ temperature: 0.3,
1295
+ signal: deadline.signal
1296
+ };
1297
+ let output = "";
1298
+ let finish;
1299
+ try {
1300
+ for await (const chunk of llm.stream(options)) {
1301
+ if (chunk.type === "text-delta") output += chunk.text;
1302
+ else if (chunk.type === "finish") finish = chunk.reason;
1303
+ }
1304
+ } catch (error) {
1305
+ if (deps.signal?.aborted === true) return { ok: false, error: { code: "cancelled" } };
1306
+ if (deadline.expired()) return { ok: false, error: { code: "llm-error", message: `model call timed out after ${config.suggestTimeoutMs}ms` } };
1307
+ return { ok: false, error: { code: "llm-error", message: error instanceof Error ? error.message : "model stream failed" } };
1308
+ } finally {
1309
+ deadline.dispose();
1310
+ }
1311
+ if (deps.signal?.aborted === true) return { ok: false, error: { code: "cancelled" } };
1312
+ if (deadline.expired()) return { ok: false, error: { code: "llm-error", message: `model call timed out after ${config.suggestTimeoutMs}ms` } };
1313
+ const terminal = finishError(finish);
1314
+ if (terminal !== void 0) return terminal;
1315
+ const message = normalizeMessage(output);
1316
+ if (message === "") return { ok: false, error: { code: "llm-output", message: "model produced no message text" } };
1317
+ return {
1318
+ ok: true,
1319
+ value: {
1320
+ message,
1321
+ provider: route.provider,
1322
+ model: route.model,
1323
+ ...truncated ? { truncated: true } : {}
1324
+ }
1325
+ };
1326
+ }
1327
+
1101
1328
  // src/host/version.ts
1102
1329
  import { readFile as readFile2 } from "node:fs/promises";
1103
1330
  import { dirname as dirname2, join as join4 } from "node:path";
@@ -1225,8 +1452,8 @@ async function checkLatestVersion(options = {}) {
1225
1452
  }
1226
1453
 
1227
1454
  // 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) {
1455
+ var _version_dec, _suggest_dec, _query_dec, _run_dec, _snapshot_dec, _a, _init;
1456
+ 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
1457
  constructor(ctx, config) {
1231
1458
  super(ctx, "gitPanel");
1232
1459
  __runInitializers(_init, 5, this);
@@ -1272,7 +1499,13 @@ var GitPanelService = class extends (_a = TypertRemoteService, _snapshot_dec = [
1272
1499
  }
1273
1500
  },
1274
1501
  rootCache,
1275
- rootNegCache
1502
+ rootNegCache,
1503
+ // Optional model services, resolved per request so a provider fiber that
1504
+ // activates late or restarts is picked up — absent in deployments without
1505
+ // a model backend, where the suggest endpoint reports `llm-unavailable`
1506
+ // rather than failing activation (kept out of `static inject` on purpose).
1507
+ getLlm: () => get("llm"),
1508
+ getAgentDefaultModel: () => get("agentDefaultModel")
1276
1509
  };
1277
1510
  }
1278
1511
  async snapshot(request, signal) {
@@ -1284,6 +1517,9 @@ var GitPanelService = class extends (_a = TypertRemoteService, _snapshot_dec = [
1284
1517
  async query(request, signal) {
1285
1518
  return runQuery(this.withSignal(signal), this.liveConfig(), request);
1286
1519
  }
1520
+ async suggest(request, signal) {
1521
+ return runSuggest(this.withSignal(signal), this.liveConfig(), request);
1522
+ }
1287
1523
  async version(request) {
1288
1524
  try {
1289
1525
  return request.check === true ? await checkLatestVersion() : await readVersionInfo();
@@ -1305,6 +1541,7 @@ _init = __decoratorStart(_a);
1305
1541
  __decorateElement(_init, 1, "snapshot", _snapshot_dec, GitPanelService);
1306
1542
  __decorateElement(_init, 1, "run", _run_dec, GitPanelService);
1307
1543
  __decorateElement(_init, 1, "query", _query_dec, GitPanelService);
1544
+ __decorateElement(_init, 1, "suggest", _suggest_dec, GitPanelService);
1308
1545
  __decorateElement(_init, 1, "version", _version_dec, GitPanelService);
1309
1546
  __decoratorMetadata(_init, GitPanelService);
1310
1547
  __publicField(GitPanelService, "inject", ["subprocess", "sessions", "sessionPersistence"]);
@@ -1319,7 +1556,8 @@ __publicField(GitPanelService, "Config", Schema.object({
1319
1556
  defaultDiffView: Schema.union([
1320
1557
  Schema.const("unified").description("\u7EDF\u4E00\u89C6\u56FE\uFF08\u5355\u680F\u884C\u5185\u5BF9\u6BD4\uFF09"),
1321
1558
  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")
1559
+ ]).default("unified").volatile().description("\u5DEE\u5F02\u5BF9\u6BD4\u9ED8\u8BA4\u89C6\u56FE"),
1560
+ suggestEnabled: Schema.boolean().default(true).volatile().description("\u63D0\u4EA4\u6846\u663E\u793A AI \u751F\u6210\u6309\u94AE")
1323
1561
  }));
1324
1562
  var index_default = GitPanelService;
1325
1563
  export {
@@ -1341,6 +1579,7 @@ export {
1341
1579
  resolveWorkspace,
1342
1580
  runAction,
1343
1581
  runQuery,
1582
+ runSuggest,
1344
1583
  snapshotForSession,
1345
1584
  sumNumstat
1346
1585
  };
@@ -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
+ }
@@ -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>;
@@ -62,6 +62,8 @@ export interface GitSnapshot {
62
62
  readonly showInputPill: boolean;
63
63
  /** Default diff layout the views open with (user preference). */
64
64
  readonly defaultDiffView: DiffViewMode;
65
+ /** Whether the commit box shows the AI-suggest button (user preference). */
66
+ readonly suggestEnabled: boolean;
65
67
  /** Epoch millis of the snapshot. */
66
68
  readonly checkedAt: number;
67
69
  }
@@ -121,7 +123,7 @@ export type GitAction = {
121
123
  } | {
122
124
  readonly kind: 'fetch';
123
125
  };
124
- export type GitErrorCode = 'cwd-unavailable' | 'not-a-git-repo' | 'git-unavailable' | 'invalid-path' | 'invalid-name' | 'git-error' | 'timeout' | 'cancelled' | 'empty-message' | 'local-changes-block';
126
+ export type GitErrorCode = 'cwd-unavailable' | 'not-a-git-repo' | 'git-unavailable' | 'invalid-path' | 'invalid-name' | 'git-error' | 'timeout' | 'cancelled' | 'empty-message' | 'local-changes-block' | 'empty-diff' | 'llm-unavailable' | 'llm-error' | 'llm-output' | 'suggest-disabled';
125
127
  export type GitActionResult = {
126
128
  readonly ok: true;
127
129
  readonly snapshot: GitSnapshot;
@@ -325,6 +327,34 @@ export interface GitQueryRequest {
325
327
  readonly sessionId: string;
326
328
  readonly query: GitQuery;
327
329
  }
330
+ /**
331
+ * Wire request for the suggest endpoint: ask the configured model to write a
332
+ * commit message from the repository's uncommitted changes. `paths` restricts
333
+ * the diff to the user's selection (bare repo-relative paths); absent means
334
+ * all uncommitted changes.
335
+ */
336
+ export interface GitSuggestRequest {
337
+ readonly sessionId: string;
338
+ readonly paths?: readonly string[];
339
+ }
340
+ export type GitSuggestResult = {
341
+ readonly ok: true;
342
+ readonly value: {
343
+ /** The generated message, ready for the user to edit and commit. */
344
+ readonly message: string;
345
+ /** Actual provider/model route used (display only). */
346
+ readonly provider?: string;
347
+ readonly model?: string;
348
+ /** The diff exceeded the model input cap and was truncated. */
349
+ readonly truncated?: boolean;
350
+ };
351
+ } | {
352
+ readonly ok: false;
353
+ readonly error: {
354
+ readonly code: GitErrorCode;
355
+ readonly message?: string;
356
+ };
357
+ };
328
358
  /**
329
359
  * Extensions the image-diff query serves, mapped to MIME types. Part of the
330
360
  * query's contract, so both halves gate on this one list and cannot drift.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xbzbing/dsh-git-panel",
3
- "version": "1.1.2",
3
+ "version": "1.3.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",
@@ -42,7 +42,7 @@
42
42
  "build": "node build.mjs",
43
43
  "typecheck": "tsc -p tsconfig.json --noEmit",
44
44
  "test:unit": "node build.mjs && node --test --test-force-exit test/unit/*.test.mjs",
45
- "test:e2e": "node build.mjs && node test/e2e/setup.mjs && node test/e2e/run.mjs && node test/e2e/run2.mjs",
45
+ "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",
46
46
  "test": "npm run test:unit && npm run test:e2e",
47
47
  "check": "npm run typecheck && npm run test && git diff --exit-code -- lib/"
48
48
  },