@xia-sc/dsh-git 0.6.1 → 0.7.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/README.en.md CHANGED
@@ -35,6 +35,9 @@ new-branch-from-base.
35
35
  (the top row with the Git title — press, drag, release; it stays where
36
36
  dropped and is clamped inside the viewport; header buttons/inputs never
37
37
  start a drag; double-click the header to snap back to center).
38
+ The header's right side is, in order, **⚙ Settings** (the commit-message
39
+ language — see "The commit area and AI drafting"), **↻ Refresh** and
40
+ **– Collapse**.
38
41
  A "+ New branch" button beside the branch switcher opens an inline form:
39
42
  new branch name + base-branch picker (local branches or full remote refs
40
43
  like `origin/feature/x`) — confirming creates the branch from the base and
@@ -119,6 +122,20 @@ The commit area follows the real order of operations: **stage all → draft →
119
122
  - **✨ AI draft**: sends the selected change set (diffstat + diff, truncated)
120
123
  to the model the current session has selected and drops the generated
121
124
  message into the input. Edit it, or just write your own.
125
+ - **Commit-message language (⚙ in the header)**: `Auto` by default — the model
126
+ follows the language already used by the repository's commits and comments
127
+ (the historical behaviour). Pick one of the presets (简体中文 / 繁體中文 /
128
+ English / 日本語 / 한국어 / Français / Deutsch / Español / Русский) or
129
+ `Custom…` and type a language name, and the draft is **forced** to write the
130
+ whole message — subject and body — in that language, with the system prompt
131
+ saying outright that it overrides the code comments, the past commit subjects
132
+ and the diff. A repository whose comments are English is exactly what drags
133
+ "Auto" off course, which is what this switch is for. The choice lives in the
134
+ browser's `localStorage` (key `dsh-git.commitLanguage`), applies to every
135
+ workspace and survives a reload; when storage is unavailable it silently
136
+ falls back to `Auto`. A custom name is validated by the host for shape and
137
+ length (≤ 60 chars, no line feed, quote or colon that could open a prompt
138
+ rule of its own) and reports `invalid-language` when it does not fit.
122
139
 
123
140
  The model route comes from the current session's `modelSelection` projection
124
141
  (pending pick first, then last used), falling back to the host's first
@@ -237,7 +254,7 @@ shell metacharacters, and leading dashes are all recorded verbatim.
237
254
  | `commit` | `{ cwd, message }` | `{ message }`; `missing-author` error when `user.name/email` unset |
238
255
  | `push` | `{ cwd }` | `{ message }` (120s timeout) |
239
256
  | `log` | `{ cwd, count? }` | `{ repo, commits: [{sha, author, subject, refs}] }` (clamped 1..50) |
240
- | `generateMessage` | `{ cwd, mode?, provider?, model?, sessionId? }` | `{ message, mode, provider, model }`. `mode` is `staged` (default) / `unstaged` / `all`; anything else is `invalid-mode`. `sessionId` is an optional non-empty string (over 200 chars is `invalid-session`); the panel sends the current session id so session-affine gateways can route the call. Failure code in `error.details.code`: `no-changes`, `no-provider`, `no-model`, `llm-truncated` (the output cap ran out before any text was written), `llm-empty`, `cancelled`, `llm-failed`. |
257
+ | `generateMessage` | `{ cwd, mode?, provider?, model?, sessionId?, language? }` | `{ message, mode, provider, model }`. `mode` is `staged` (default) / `unstaged` / `all`; anything else is `invalid-mode`. `sessionId` is an optional non-empty string (over 200 chars is `invalid-session`); the panel sends the current session id so session-affine gateways can route the call. `language` is an optional **language name** (the ⚙ setting; absent, `null`, an empty string and `auto` all mean "follow the repository") written into the system prompt to force the whole message into that language; a name that is too long (> 60 chars) or carries a line feed, quote or colon is `invalid-language`. Failure code in `error.details.code`: `no-changes`, `no-provider`, `no-model`, `llm-truncated` (the output cap ran out before any text was written), `llm-empty`, `cancelled`, `llm-failed`. |
241
258
 
242
259
  > A failed result carries `error.code === "internal"` on the wire (the Connection
243
260
  > envelope only requires a string), with the plugin's own diagnostic in
@@ -298,7 +315,7 @@ shell metacharacters, and leading dashes are all recorded verbatim.
298
315
  - The browser bundle is hand-written (no build step); edits to `lib/client.js`
299
316
  are picked up on refresh (no-cache), host-side edits need a `dsh web`
300
317
  restart.
301
- - Tests (`npm test` runs all four):
318
+ - Tests (`npm test` runs all five):
302
319
  - `node test/smoke.mjs` — route, envelope, endpoint dispatch and input
303
320
  validation (no git spawn: the session sandbox blocks child-process piped
304
321
  stdio);
@@ -313,6 +330,14 @@ shell metacharacters, and leading dashes are all recorded verbatim.
313
330
  removed line starting with `--`), the row renderer, the diff pane's header,
314
331
  and `act()`'s result plumbing and localization (needs a react/react-dom copy,
315
332
  e.g. via `DSH_GIT_REACT_ROOT`; SKIPs without one).
333
+ - `node test/slot-mount.mjs` — mounts both seats on the real
334
+ `SlotCore`/`SlotRegistry` with the real renderer (SKIPs without a host profile).
335
+ - `npm run test:ui:settings` — **real browser** offline regression
336
+ (playwright-core + local Chrome; needs neither `dsh web` nor authentication):
337
+ the script serves this checkout over loopback, mounts the client half with the
338
+ React UMD build, and then really clicks the ⚙ settings popover, switches the
339
+ language, and checks both the `localStorage` persistence and the `language`
340
+ the draft request carries; it writes `test/ui/settings-popover.png`.
316
341
  - `npm run test:commit` — **end-to-end**: really spawns git in a throwaway
317
342
  repository, commits through the plugin's own `/dsh-git-rpc/commit` route, and
318
343
  reads the message back with `git log --format=%B` (multi-line, CRLF, non-ASCII,
package/README.md CHANGED
@@ -24,7 +24,8 @@ DeepSeek Harness Web GUI 的完整 Git 管理插件,形态为一个**可折叠
24
24
  (push 的 sideband banner、`LF will be replaced by CRLF` 这类提示)收在右侧的"详情 ▾"里,点开才展开,
25
25
  不会被变更列表和最近提交顶到看不见的地方。面板**可通过顶栏拖动**(按住带 Git 标题的
26
26
  那一行,拖到哪里就停在哪里,不会拖出视口;顶栏上的按钮/输入框不会触发
27
- 拖动;双击顶栏回到居中位置)。
27
+ 拖动;双击顶栏回到居中位置)。顶栏右侧依次是 **⚙ 设置**(提交信息语言,
28
+ 见下文「提交区与 AI 起草」)、**↻ 刷新**、**– 收起**。
28
29
  分支切换器旁边的"+ 新建分支"按钮会展开一个内联表单:新分支名 + 基分支
29
30
  选择器(本地分支或 `origin/feature/x` 这样的完整远端引用)——确认后从
30
31
  该基分支创建新分支并切换过去。
@@ -83,6 +84,15 @@ DeepSeek Harness Web GUI 的完整 Git 管理插件,形态为一个**可折叠
83
84
  提交真正会记录的内容**——用其他依据生成的描述可能与实际提交不符。
84
85
  - **✨ AI 生成**:把选中的改动(diffstat + diff,截断后)交给当前会话所选
85
86
  模型,生成的提交信息直接填入输入框;不满意可改,也可以直接手写。
87
+ - **提交信息语言(顶栏 ⚙ 设置)**:默认「自动」——让模型跟随仓库现有提交与
88
+ 注释的语言(历史行为)。选中某个语言(简体中文 / 繁體中文 / English / 日本語 /
89
+ 한국어 / Français / Deutsch / Español / Русский)或「自定义…」手填一个语言名后,
90
+ 生成时会在 system prompt 里**强制**整条提交信息(主题 + 正文)只用该语言,
91
+ 并明确写清它覆盖代码注释、历史提交与 diff 的语言——仓库注释是英文时,
92
+ 「自动」会被它们带跑,这正是这个开关要解决的问题。设置存在浏览器的
93
+ `localStorage`(键 `dsh-git.commitLanguage`),对所有工作区生效、刷新后保留;
94
+ 存储不可用时静默回落到「自动」。自定义语言名由宿主按形状与长度(≤ 60 字符、
95
+ 无换行/引号/冒号等可自开规则的字符)校验,非法时报 `invalid-language`。
86
96
 
87
97
  生成用的模型路由取当前会话的 `modelSelection` 投影(待生效的选择优先,
88
98
  其次是上次实际使用),取不到时回落到宿主注册的第一条路由。请求会带上**当前会话 id**
@@ -183,7 +193,7 @@ dsh plugin --profile web remove @xia-sc/dsh-git
183
193
  | `commit` | `{ cwd, message }` | `{ message }`;未配置 `user.name/email` 时报 `missing-author` 错误 |
184
194
  | `push` | `{ cwd }` | `{ message }`(120s 超时) |
185
195
  | `log` | `{ cwd, count? }` | `{ repo, commits: [{sha, author, subject, refs}] }`(钳制 1..50) |
186
- | `generateMessage` | `{ cwd, mode?, provider?, model?, sessionId? }` | `{ message, mode, provider, model }`。`mode` 为 `staged`(默认)/`unstaged`/`all`,非法值报 `invalid-mode`;`sessionId` 为可选的非空字符串(超过 200 字符报 `invalid-session`),面板会带上当前会话 id 供需要会话亲和的网关路由。失败码见 `error.details.code`:`no-changes`、`no-provider`、`no-model`、`llm-truncated`(输出上限用尽、一个字都没写出来)、`llm-empty`、`cancelled`、`llm-failed`。 |
196
+ | `generateMessage` | `{ cwd, mode?, provider?, model?, sessionId?, language? }` | `{ message, mode, provider, model }`。`mode` 为 `staged`(默认)/`unstaged`/`all`,非法值报 `invalid-mode`;`sessionId` 为可选的非空字符串(超过 200 字符报 `invalid-session`),面板会带上当前会话 id 供需要会话亲和的网关路由。`language` 是可选的**语言名**(顶栏 ⚙ 里的设置;缺失、`null`、空串与 `auto` 都表示「跟随仓库」),它会被写进 system prompt 强制整条信息只用该语言;形状或长度不合法(> 60 字符、含换行/引号/冒号等)报 `invalid-language`。失败码见 `error.details.code`:`no-changes`、`no-provider`、`no-model`、`llm-truncated`(输出上限用尽、一个字都没写出来)、`llm-empty`、`cancelled`、`llm-failed`。 |
187
197
 
188
198
  > 失败结果的 `error.code` 在线路上固定为 `"internal"`(Connection 信封只要求它是字符串),
189
199
  > 插件自己的诊断码放在 `error.details.code`;客户端按该码做本地化文案。
@@ -237,7 +247,14 @@ dsh plugin --profile web remove @xia-sc/dsh-git
237
247
  diff 解析器(行号 / 分类 / `--` 开头的删除行)、行渲染、差异面板标题栏与
238
248
  `act()` 的结果回传/本地化(需要一份 react/react-dom,可用
239
249
  `DSH_GIT_REACT_ROOT` 指定,找不到则 SKIP)。
240
- - 也提供 `npm test`(依次跑四个)。
250
+ - `node test/slot-mount.mjs` —— 用真实 `SlotCore`/`SlotRegistry` 与真渲染器把两个
251
+ 座位挂起来(找不到宿主包则 SKIP)。
252
+ - `npm run test:ui:settings` —— **真实浏览器**离线回归(playwright-core + 本机
253
+ Chrome,既不需要 `dsh web` 也不需要认证):脚本自己起回环 http 服务,用
254
+ React UMD 挂载客户端半边,真点 ⚙ 设置弹层、切换语言、验证 `localStorage`
255
+ 持久化与「✨ AI 生成」请求里带的 `language`;截图落在
256
+ `test/ui/settings-popover.png`。
257
+ - 也提供 `npm test`(依次跑五个)。
241
258
  - `npm run test:commit` —— **端到端**:在临时仓库里真起 git,走插件的
242
259
  `/dsh-git-rpc/commit` 路由提交,再用 `git log --format=%B` 逐字节比对提交
243
260
  信息(多行、CRLF、中文、前导 `-`、shell 元字符等),并确认非法信息被拒且
package/lib/client.js CHANGED
@@ -78,6 +78,26 @@ window.__ModuleLoader__.load({
78
78
  "generate.failed": "生成失败",
79
79
  "generate.failedDetail": "生成失败:{message}",
80
80
  "generate.failedRoute": "生成失败:{message}(模型路由 {provider} / {model})",
81
+ // Settings popover (the header's ⚙). The commit-message language is a
82
+ // forced output rule, not a hint: the host turns it into an explicit
83
+ // override in the system prompt.
84
+ "settings.aria": "Git 设置",
85
+ "settings.title": "设置",
86
+ "settings.close": "关闭",
87
+ "settings.language": "提交信息语言",
88
+ "settings.language.auto": "自动(跟随仓库)",
89
+ "settings.language.zh-CN": "简体中文",
90
+ "settings.language.zh-TW": "繁體中文",
91
+ "settings.language.en": "English",
92
+ "settings.language.ja": "日本語",
93
+ "settings.language.ko": "한국어",
94
+ "settings.language.fr": "Français",
95
+ "settings.language.de": "Deutsch",
96
+ "settings.language.es": "Español",
97
+ "settings.language.ru": "Русский",
98
+ "settings.language.custom": "自定义…",
99
+ "settings.language.customPlaceholder": "例如:Esperanto",
100
+ "settings.language.hint": "「AI 生成」只用所选语言写提交信息,不受代码注释与历史提交影响;「自动」跟随仓库。",
81
101
  "commit.placeholder": "提交信息(可多行,Ctrl+Enter 提交)…",
82
102
  "commit.empty": "请输入提交信息",
83
103
  "changes.title": "变更",
@@ -184,6 +204,26 @@ window.__ModuleLoader__.load({
184
204
  "generate.failed": "Draft failed",
185
205
  "generate.failedDetail": "Draft failed: {message}",
186
206
  "generate.failedRoute": "Draft failed: {message} (route {provider} / {model})",
207
+ // Settings popover (the header's ⚙). The commit-message language is a
208
+ // forced output rule, not a hint: the host turns it into an explicit
209
+ // override in the system prompt.
210
+ "settings.aria": "Git settings",
211
+ "settings.title": "Settings",
212
+ "settings.close": "Close",
213
+ "settings.language": "Commit message language",
214
+ "settings.language.auto": "Auto (follow the repository)",
215
+ "settings.language.zh-CN": "简体中文",
216
+ "settings.language.zh-TW": "繁體中文",
217
+ "settings.language.en": "English",
218
+ "settings.language.ja": "日本語",
219
+ "settings.language.ko": "한국어",
220
+ "settings.language.fr": "Français",
221
+ "settings.language.de": "Deutsch",
222
+ "settings.language.es": "Español",
223
+ "settings.language.ru": "Русский",
224
+ "settings.language.custom": "Custom…",
225
+ "settings.language.customPlaceholder": "e.g. Esperanto",
226
+ "settings.language.hint": "The AI draft is written in the chosen language only, whatever the comments and past commits use; Auto follows the repository.",
187
227
  "commit.placeholder": "Commit message (multi-line allowed, Ctrl+Enter commits)…",
188
228
  "commit.empty": "Enter a commit message",
189
229
  "changes.title": "Changes",
@@ -244,6 +284,90 @@ window.__ModuleLoader__.load({
244
284
  "status.changed": "changed"
245
285
  };
246
286
 
287
+ /**
288
+ * Commit-message languages the settings panel offers: first the `auto`
289
+ * entry (the historical "follow the repository" behaviour), then the
290
+ * presets.
291
+ *
292
+ * `directive` is the language NAME handed to the host, which turns it
293
+ * into an explicit override in the system prompt. The panel owns this
294
+ * mapping so the host needs no language table of its own and a custom
295
+ * entry can send any name at all. The names are bilingual on purpose:
296
+ * the model reads them unambiguously whichever UI locale is active.
297
+ */
298
+ var LANGUAGE_PRESETS = [
299
+ { id: "auto", directive: undefined },
300
+ { id: "zh-CN", directive: "Simplified Chinese (简体中文)" },
301
+ { id: "zh-TW", directive: "Traditional Chinese (繁體中文)" },
302
+ { id: "en", directive: "English" },
303
+ { id: "ja", directive: "Japanese (日本語)" },
304
+ { id: "ko", directive: "Korean (한국어)" },
305
+ { id: "fr", directive: "French (français)" },
306
+ { id: "de", directive: "German (Deutsch)" },
307
+ { id: "es", directive: "Spanish (español)" },
308
+ { id: "ru", directive: "Russian (русский)" }
309
+ ];
310
+ /** The custom entry: not a preset, its directive is the user's own text. */
311
+ var LANGUAGE_CUSTOM_ID = "custom";
312
+ /** Every id the select can carry, `custom` included. */
313
+ var LANGUAGE_IDS = LANGUAGE_PRESETS.map(function (preset) { return preset.id; }).concat([LANGUAGE_CUSTOM_ID]);
314
+ /**
315
+ * Where the setting lives. Global by design: it is a user preference about
316
+ * how the panel writes, not state of any one repository.
317
+ */
318
+ var LANGUAGE_STORAGE_KEY = "dsh-git.commitLanguage";
319
+
320
+ /**
321
+ * The page's `localStorage`, or null when there is none.
322
+ *
323
+ * Reached through `window`, never as a bare `localStorage`: a test or
324
+ * server-side render has no storage (Node >= 22 even ships an experimental
325
+ * global that logs a warning and then throws), and a browser can refuse it
326
+ * outright (private mode, blocked site storage). None of that is an error
327
+ * here — the setting falls back to `auto`.
328
+ */
329
+ function pageStorage() {
330
+ try {
331
+ if (typeof window === "undefined" || window === null) return null;
332
+ var store = window.localStorage;
333
+ return store === undefined || store === null ? null : store;
334
+ } catch (error) {
335
+ return null;
336
+ }
337
+ }
338
+
339
+ /**
340
+ * Read the persisted language setting.
341
+ * @returns `{ id, text }`, or null when nothing is stored/readable.
342
+ */
343
+ function readStoredLanguage() {
344
+ try {
345
+ var store = pageStorage();
346
+ if (store === null) return null;
347
+ var raw = store.getItem(LANGUAGE_STORAGE_KEY);
348
+ if (raw === null || raw === "") return null;
349
+ var parsed = JSON.parse(raw);
350
+ if (parsed === null || typeof parsed !== "object") return null;
351
+ return {
352
+ id: typeof parsed.id === "string" ? parsed.id : "auto",
353
+ text: typeof parsed.text === "string" ? parsed.text : ""
354
+ };
355
+ } catch (error) {
356
+ return null;
357
+ }
358
+ }
359
+
360
+ /** Persist the language setting, ignoring a storage that refuses to write. */
361
+ function writeStoredLanguage(id, text) {
362
+ try {
363
+ var store = pageStorage();
364
+ if (store === null) return;
365
+ store.setItem(LANGUAGE_STORAGE_KEY, JSON.stringify({ id: id, text: text }));
366
+ } catch (error) {
367
+ // Quota or private mode: the choice still holds for this page.
368
+ }
369
+ }
370
+
247
371
  /** React.createElement shorthand. */
248
372
  function h(type, props) {
249
373
  var children = Array.prototype.slice.call(arguments, 2);
@@ -335,12 +459,85 @@ window.__ModuleLoader__.load({
335
459
  boxSizing: "border-box",
336
460
  cursor: "pointer"
337
461
  },
462
+ // The same ghost button while its popover is open: an accent fill, not a
463
+ // second button shape, so the header keeps one visual language.
464
+ iconButtonActive: {
465
+ background: "color-mix(in srgb, var(--dsw-alias-state-business-primary, #4d6bfe) 16%, transparent)",
466
+ border: "1px solid color-mix(in srgb, var(--dsw-alias-state-business-primary, #4d6bfe) 45%, transparent)",
467
+ color: "var(--dsw-alias-state-business-primary, #4d6bfe)"
468
+ },
338
469
  body: {
339
470
  flex: "1",
340
471
  minHeight: 0,
341
472
  padding: "10px 12px 12px",
342
473
  overflowY: "auto"
343
474
  },
475
+ // Covers the whole panel — header included — while the popover is open, so
476
+ // a click on anything else inside the panel closes the settings. Kept
477
+ // `absolute` on purpose: the panel rides `transform: translateX(-50%)`
478
+ // while centered, and a transformed ancestor both re-parents a `fixed`
479
+ // child and clips it to the panel's `overflow: hidden` anyway.
480
+ settingsBackdrop: {
481
+ position: "absolute",
482
+ top: 0,
483
+ right: 0,
484
+ bottom: 0,
485
+ left: 0,
486
+ zIndex: 2,
487
+ background: "transparent"
488
+ },
489
+ // Anchored under the header's right edge, inside the panel's own fixed
490
+ // box. Absolute (not fixed) so it moves with the panel when dragged.
491
+ settingsPopover: {
492
+ position: "absolute",
493
+ top: 42,
494
+ right: 8,
495
+ zIndex: 3,
496
+ width: 236,
497
+ // Never taller than the panel that holds it (the short-window case),
498
+ // in which case the popover scrolls instead of being clipped.
499
+ maxHeight: "calc(100% - 52px)",
500
+ overflowY: "auto",
501
+ display: "flex",
502
+ flexDirection: "column",
503
+ gap: 8,
504
+ padding: 10,
505
+ border: "1px solid var(--dsw-alias-border-l1, rgba(127,127,127,0.3))",
506
+ borderRadius: 10,
507
+ background: "var(--dsw-alias-bg-base, #1e1e1e)",
508
+ boxShadow: "var(--dsw-shadow-lv2, 0 8px 32px rgba(0,0,0,0.45))",
509
+ boxSizing: "border-box",
510
+ cursor: "default",
511
+ textAlign: "left"
512
+ },
513
+ settingsHead: {
514
+ display: "flex",
515
+ alignItems: "center",
516
+ gap: 8
517
+ },
518
+ settingsTitle: {
519
+ flex: "1",
520
+ minWidth: 0,
521
+ fontSize: 12,
522
+ fontWeight: 600,
523
+ color: "var(--dsw-alias-label-primary, #e5e7eb)"
524
+ },
525
+ settingsRow: {
526
+ display: "flex",
527
+ alignItems: "center",
528
+ gap: 8,
529
+ minWidth: 0
530
+ },
531
+ settingsLabel: {
532
+ flex: "none",
533
+ fontSize: 12,
534
+ color: "var(--dsw-alias-label-secondary, #c8ccd2)"
535
+ },
536
+ settingsHint: {
537
+ fontSize: 11,
538
+ lineHeight: "16px",
539
+ color: "var(--dsw-alias-label-secondary, #c8ccd2)"
540
+ },
344
541
  row: {
345
542
  display: "flex",
346
543
  alignItems: "center",
@@ -1136,11 +1333,13 @@ window.__ModuleLoader__.load({
1136
1333
  commit: function (cwd, message) { return rpc(ctx, "commit", { cwd: cwd, message: message }); },
1137
1334
  push: function (cwd) { return rpc(ctx, "push", { cwd: cwd }); },
1138
1335
  log: function (cwd) { return rpc(ctx, "log", { cwd: cwd, count: 10 }); },
1139
- generateMessage: function (cwd, mode, provider, model, sessionId) {
1336
+ generateMessage: function (cwd, mode, provider, model, sessionId, language) {
1140
1337
  // The session travels with the request: session-labelled
1141
1338
  // providers reject an unidentified call, and the host forwards
1142
1339
  // a session id to its adapter only when one is present.
1143
- return rpc(ctx, "generateMessage", { cwd: cwd, mode: mode, provider: provider, model: model, sessionId: sessionId });
1340
+ // `language` is the forced output language from the settings
1341
+ // popover; absent means "let the model follow the repository".
1342
+ return rpc(ctx, "generateMessage", { cwd: cwd, mode: mode, provider: provider, model: model, sessionId: sessionId, language: language });
1144
1343
  }
1145
1344
  };
1146
1345
 
@@ -1152,6 +1351,16 @@ window.__ModuleLoader__.load({
1152
1351
  */
1153
1352
  var sessionId = null;
1154
1353
 
1354
+ // The commit-message language setting, read once from storage. It is
1355
+ // deliberately NOT state of one Session: it is a preference of the user
1356
+ // about how the panel writes, shared by every workspace and kept across
1357
+ // reloads. idleState() republishes it on every emit, so a refresh, a
1358
+ // workspace switch and a status read all keep it — a hard-coded `auto`
1359
+ // in idleState() would silently reset the setting on the next read.
1360
+ var storedLanguage = readStoredLanguage();
1361
+ var languageId = storedLanguage !== null && LANGUAGE_IDS.indexOf(storedLanguage.id) >= 0 ? storedLanguage.id : "auto";
1362
+ var languageText = storedLanguage !== null ? storedLanguage.text : "";
1363
+
1155
1364
  function idleState() {
1156
1365
  return {
1157
1366
  sessionId: sessionId,
@@ -1173,7 +1382,10 @@ window.__ModuleLoader__.load({
1173
1382
  busy: null,
1174
1383
  refreshing: false,
1175
1384
  lastResult: null,
1176
- panelOpen: false
1385
+ panelOpen: false,
1386
+ settingsOpen: false,
1387
+ generateLanguage: languageId,
1388
+ generateLanguageText: languageText
1177
1389
  };
1178
1390
  }
1179
1391
 
@@ -1208,6 +1420,7 @@ window.__ModuleLoader__.load({
1208
1420
  phase: "error",
1209
1421
  error: status && status.error ? String(status.error.message || "git status failed") : "git status failed",
1210
1422
  panelOpen: state.panelOpen,
1423
+ settingsOpen: state.settingsOpen,
1211
1424
  lastResult: state.lastResult
1212
1425
  });
1213
1426
  }
@@ -1227,6 +1440,7 @@ window.__ModuleLoader__.load({
1227
1440
  remote: bv !== null && Array.isArray(bv.remote) ? bv.remote : [],
1228
1441
  commits: lv !== null && Array.isArray(lv.commits) ? lv.commits : [],
1229
1442
  panelOpen: state.panelOpen,
1443
+ settingsOpen: state.settingsOpen,
1230
1444
  lastResult: state.lastResult
1231
1445
  });
1232
1446
  }
@@ -1261,6 +1475,7 @@ window.__ModuleLoader__.load({
1261
1475
  phase: "error",
1262
1476
  error: error && error.message ? String(error.message) : String(error),
1263
1477
  panelOpen: state.panelOpen,
1478
+ settingsOpen: state.settingsOpen,
1264
1479
  lastResult: state.lastResult
1265
1480
  }));
1266
1481
  });
@@ -1333,8 +1548,57 @@ window.__ModuleLoader__.load({
1333
1548
  });
1334
1549
  }
1335
1550
 
1551
+ /**
1552
+ * Open/close the workbench. Closing it also drops the settings popover:
1553
+ * the panel component keeps its state while collapsed (it renders
1554
+ * nothing), so a popover left open would spring back over the next open.
1555
+ */
1336
1556
  function setPanelOpen(open) {
1337
- emit(Object.assign({}, state, { panelOpen: open === true }));
1557
+ emit(Object.assign({}, state, {
1558
+ panelOpen: open === true,
1559
+ settingsOpen: open === true ? state.settingsOpen : false
1560
+ }));
1561
+ }
1562
+
1563
+ /** Open/close the header's settings popover. */
1564
+ function setSettingsOpen(open) {
1565
+ emit(Object.assign({}, state, { settingsOpen: open === true }));
1566
+ }
1567
+
1568
+ /**
1569
+ * Persist the commit-message language. The settings popover is the only
1570
+ * writer; both seats read it back through the shared state, so the select
1571
+ * always shows what the next draft will actually ask for.
1572
+ * @param id - a preset id, or `custom`.
1573
+ * @param text - the custom language name (read only for `custom`).
1574
+ */
1575
+ function setGenerateLanguage(id, text) {
1576
+ languageId = LANGUAGE_IDS.indexOf(id) >= 0 ? id : "auto";
1577
+ // Kept verbatim: this value is echoed back into a controlled text
1578
+ // field, and trimming on every keystroke eats the space a user just
1579
+ // typed. The directive is trimmed where it is actually read.
1580
+ languageText = typeof text === "string" ? text : "";
1581
+ writeStoredLanguage(languageId, languageText);
1582
+ emit(Object.assign({}, state, {
1583
+ generateLanguage: languageId,
1584
+ generateLanguageText: languageText
1585
+ }));
1586
+ }
1587
+
1588
+ /**
1589
+ * The language directive to send with a draft. `undefined` means "follow
1590
+ * the repository" — which is also what an EMPTY custom field means: an
1591
+ * empty string would be rejected by the host's validation instead of
1592
+ * drafting, and "I typed nothing yet" must not break the button.
1593
+ * @returns the language NAME, or undefined.
1594
+ */
1595
+ function languageDirective() {
1596
+ if (languageId === LANGUAGE_CUSTOM_ID) {
1597
+ var trimmed = languageText.trim();
1598
+ return trimmed === "" ? undefined : trimmed;
1599
+ }
1600
+ var preset = LANGUAGE_PRESETS.filter(function (entry) { return entry.id === languageId; })[0];
1601
+ return preset === undefined ? undefined : preset.directive;
1338
1602
  }
1339
1603
 
1340
1604
  return {
@@ -1344,6 +1608,9 @@ window.__ModuleLoader__.load({
1344
1608
  bindSession: bindSession,
1345
1609
  act: act,
1346
1610
  setPanelOpen: setPanelOpen,
1611
+ setSettingsOpen: setSettingsOpen,
1612
+ setGenerateLanguage: setGenerateLanguage,
1613
+ languageDirective: languageDirective,
1347
1614
  verbs: verbs
1348
1615
  };
1349
1616
  }
@@ -1359,6 +1626,73 @@ window.__ModuleLoader__.load({
1359
1626
  fontWeight: 600
1360
1627
  };
1361
1628
 
1629
+ /**
1630
+ * The settings popover: one row per setting, anchored under the header.
1631
+ *
1632
+ * Pure and exported, so the render test can drive it directly — SSR runs no
1633
+ * effects and the popover starts closed, so a static panel render never
1634
+ * contains it. Everything it needs arrives through `spec`; the store's
1635
+ * `generateLanguage` stays the single source of truth (the select is
1636
+ * controlled), so the popover cannot drift from what the next draft sends.
1637
+ *
1638
+ * @param spec - `{ t, language, customText, onLanguage, onCustomText, onClose }`.
1639
+ * @returns the popover element.
1640
+ */
1641
+ function settingsView(spec) {
1642
+ var t = spec.t;
1643
+ var children = [
1644
+ h("div", { key: "head", style: S.settingsHead }, [
1645
+ h("span", { key: "title", style: S.settingsTitle }, t("settings.title")),
1646
+ h("button", {
1647
+ key: "close",
1648
+ type: "button",
1649
+ style: S.iconButton,
1650
+ onClick: spec.onClose,
1651
+ title: t("settings.close"),
1652
+ "aria-label": t("settings.close"),
1653
+ "data-dsh-git": "settings-close"
1654
+ }, "×")
1655
+ ]),
1656
+ h("div", { key: "language", style: S.settingsRow }, [
1657
+ h("span", { key: "label", style: S.settingsLabel }, t("settings.language")),
1658
+ h("select", {
1659
+ key: "select",
1660
+ style: S.select,
1661
+ value: spec.language,
1662
+ title: t("settings.language"),
1663
+ "aria-label": t("settings.language"),
1664
+ "data-dsh-git": "settings-language",
1665
+ onChange: function (event) { spec.onLanguage(event.target.value); }
1666
+ }, LANGUAGE_IDS.map(function (id) {
1667
+ return h("option", { key: id, value: id, style: OPTION_STYLE }, t("settings.language." + id));
1668
+ }))
1669
+ ])
1670
+ ];
1671
+ // The free-form name only exists behind its own entry: a text field
1672
+ // shown beside the preset list would read as "type here too".
1673
+ if (spec.language === LANGUAGE_CUSTOM_ID) {
1674
+ children.push(h("input", {
1675
+ key: "custom",
1676
+ type: "text",
1677
+ style: S.input,
1678
+ value: spec.customText,
1679
+ placeholder: t("settings.language.customPlaceholder"),
1680
+ title: t("settings.language.customPlaceholder"),
1681
+ "aria-label": t("settings.language.customPlaceholder"),
1682
+ "data-dsh-git": "settings-language-text",
1683
+ onChange: function (event) { spec.onCustomText(event.target.value); }
1684
+ }));
1685
+ }
1686
+ children.push(h("div", { key: "hint", style: S.settingsHint }, t("settings.language.hint")));
1687
+ return h("div", {
1688
+ key: "settings",
1689
+ style: S.settingsPopover,
1690
+ role: "dialog",
1691
+ "aria-label": t("settings.title"),
1692
+ "data-dsh-git": "settings-popover"
1693
+ }, children);
1694
+ }
1695
+
1362
1696
  /** One branch option list; remote picks carry the DWIM-able short name. */
1363
1697
  function branchOptions(state, t) {
1364
1698
  var local = state.local.map(function (entry) {
@@ -1906,6 +2240,13 @@ window.__ModuleLoader__.load({
1906
2240
  setOutputOpen(false);
1907
2241
  }, [state.lastResult]);
1908
2242
 
2243
+ // Whether the header's ⚙ popover is open. It lives in the STORE, not in
2244
+ // component state: the store already owns `panelOpen`, and closing the
2245
+ // panel has to take the popover down with it (the component keeps its
2246
+ // state while collapsed, since it renders null) — one owner, one rule.
2247
+ var settingsOpen = state.settingsOpen === true;
2248
+ function setSettingsOpen(open) { store.setSettingsOpen(open); }
2249
+
1909
2250
  // Branch the user picked while the tree is dirty: the pre-check
1910
2251
  // shows a warning instead of switching immediately; the switch only
1911
2252
  // runs via the "switch anyway" button (or once the tree is clean).
@@ -2166,6 +2507,11 @@ window.__ModuleLoader__.load({
2166
2507
  transform: "none"
2167
2508
  });
2168
2509
  }
2510
+ // The settings popover lives inside the panel's own box, and the
2511
+ // single-column panel is content-sized: with a short body (the initial
2512
+ // loading line, an error, an empty repository) `overflow: hidden` would
2513
+ // clip it. 240px is the popover's own height plus the header band.
2514
+ if (settingsOpen && panelStyle.height === undefined) panelStyle.minHeight = 240;
2169
2515
 
2170
2516
  if (cwd === null) {
2171
2517
  // No Session bound (nothing to float over the input).
@@ -2192,6 +2538,19 @@ window.__ModuleLoader__.load({
2192
2538
  }, [
2193
2539
  h("span", { key: "title", style: S.title }, "⎇ " + t("panel.title")),
2194
2540
  h("span", { key: "cwd", style: S.cwdPath, title: cwd }, shortText(cwd, 80)),
2541
+ h("button", {
2542
+ key: "settings",
2543
+ type: "button",
2544
+ // The panel's own settings live behind this gear rather than in
2545
+ // the body: they configure how the panel writes (the commit
2546
+ // message's language), not what this repository contains.
2547
+ style: Object.assign({}, S.iconButton, settingsOpen ? S.iconButtonActive : null),
2548
+ onClick: function () { setSettingsOpen(!settingsOpen); },
2549
+ "aria-label": t("settings.aria"),
2550
+ "aria-expanded": settingsOpen === true ? "true" : "false",
2551
+ title: t("settings.aria"),
2552
+ "data-dsh-git": "settings"
2553
+ }, "⚙"),
2195
2554
  h("button", {
2196
2555
  key: "refresh",
2197
2556
  type: "button",
@@ -2519,7 +2878,11 @@ window.__ModuleLoader__.load({
2519
2878
  generateMode,
2520
2879
  route ? route.provider : undefined,
2521
2880
  route ? route.model : undefined,
2522
- state.sessionId === null ? undefined : state.sessionId
2881
+ state.sessionId === null ? undefined : state.sessionId,
2882
+ // The forced output language from the settings popover;
2883
+ // undefined ("auto", or an empty custom field) lets the
2884
+ // model follow the repository instead.
2885
+ store.languageDirective()
2523
2886
  );
2524
2887
  }, describeGenerate).then(function (res) {
2525
2888
  if (res && res.ok === true && res.value && res.value.message) {
@@ -2661,7 +3024,29 @@ window.__ModuleLoader__.load({
2661
3024
  onMouseLeave: function () { setResizeHover(false); }
2662
3025
  });
2663
3026
 
2664
- return h("div", { "data-dsh-git": "panel", style: panelStyle, ref: panelRef }, [header, body, resizeHandle]);
3027
+ // The settings popover and its click catcher sit ABOVE the body in the
3028
+ // panel's own box: the backdrop closes the popover on any click that is
3029
+ // not on the popover itself, which is what makes dismissal work without
3030
+ // a document-level listener (SSR runs no effects, so a listener would
3031
+ // also be untestable).
3032
+ var settingsLayer = settingsOpen ? [
3033
+ h("div", {
3034
+ key: "settings-backdrop",
3035
+ style: S.settingsBackdrop,
3036
+ onClick: function () { setSettingsOpen(false); },
3037
+ "data-dsh-git": "settings-backdrop"
3038
+ }),
3039
+ settingsView({
3040
+ t: t,
3041
+ language: state.generateLanguage,
3042
+ customText: state.generateLanguageText,
3043
+ onLanguage: function (value) { store.setGenerateLanguage(value, state.generateLanguageText); },
3044
+ onCustomText: function (value) { store.setGenerateLanguage(LANGUAGE_CUSTOM_ID, value); },
3045
+ onClose: function () { setSettingsOpen(false); }
3046
+ })
3047
+ ] : [];
3048
+
3049
+ return h("div", { "data-dsh-git": "panel", style: panelStyle, ref: panelRef }, [header].concat(settingsLayer, [body, resizeHandle]));
2665
3050
  }
2666
3051
 
2667
3052
  /**
@@ -2832,6 +3217,10 @@ window.__ModuleLoader__.load({
2832
3217
  DIFF_ROW: DIFF_ROW,
2833
3218
  stripAnsiEscapes: stripAnsiEscapes,
2834
3219
  outputView: outputView,
3220
+ settingsView: settingsView,
3221
+ LANGUAGE_PRESETS: LANGUAGE_PRESETS,
3222
+ LANGUAGE_CUSTOM_ID: LANGUAGE_CUSTOM_ID,
3223
+ LANGUAGE_STORAGE_KEY: LANGUAGE_STORAGE_KEY,
2835
3224
  sectionView: sectionView,
2836
3225
  changeTagStyle: changeTagStyle,
2837
3226
  panelBoxStyle: panelBoxStyle,
package/lib/index.js CHANGED
@@ -24,7 +24,8 @@
24
24
  * - `push` → git push (current branch's upstream).
25
25
  * - `log` → recent commit summary lines.
26
26
  * - `generateMessage` → draft a commit message from the working tree through
27
- * the shared LLM service (see the endpoint's own docs).
27
+ * the shared LLM service (see the endpoint's own docs); the
28
+ * caller may pin the message's language with `language`.
28
29
  *
29
30
  * Every git run goes through execFile with a fixed argument array (no shell),
30
31
  * a timeout, and strict input validation.
@@ -102,6 +103,20 @@ const GENERATE_MODES = ["staged", "unstaged", "all"];
102
103
  const SESSION_ID_MAX_CHARS = 200;
103
104
  /** Default mode: the only one whose content is what `commit` will actually record. */
104
105
  const GENERATE_MODE_DEFAULT = "staged";
106
+ /**
107
+ * Longest commit-message language directive the panel may send. The setting is
108
+ * a language NAME ("Simplified Chinese (简体中文)"), never a sentence, so a
109
+ * tight cap plus {@link LANGUAGE_DIRECTIVE} keep a caller from smuggling extra
110
+ * prompt rules into the system message.
111
+ */
112
+ const LANGUAGE_MAX_CHARS = 60;
113
+ /**
114
+ * Shape of an acceptable language directive: a letter or digit, then letters,
115
+ * digits, spaces and the punctuation real language names use. Deliberately no
116
+ * line feed, no control character and no `:` / `;` / quote — the characters
117
+ * that would let the value open a rule of its own inside the system prompt.
118
+ */
119
+ const LANGUAGE_DIRECTIVE = /^[\p{L}\p{N}][\p{L}\p{N} .,()()·'\-/+&]*$/u;
105
120
 
106
121
  /** A successful RPC result. */
107
122
  function ok(value) {
@@ -207,6 +222,27 @@ function validCwd(value) {
207
222
  return typeof value === "string" && value.length > 0 && isAbsolute(value);
208
223
  }
209
224
 
225
+ /**
226
+ * Read the caller's commit-message language setting.
227
+ *
228
+ * `undefined`/absent/`"auto"` all mean "follow the repository" (the historical
229
+ * behaviour), so the panel can send whatever its settings field holds without
230
+ * the host needing a language table of its own: the value is the language NAME
231
+ * the model should answer in, validated only for shape and length.
232
+ *
233
+ * @param raw - the caller's `language` argument.
234
+ * @returns `{ ok: true, value }` with the directive (or undefined for auto), or
235
+ * `{ ok: false }` for anything that is not a plain language name.
236
+ */
237
+ function readLanguage(raw) {
238
+ if (raw === undefined || raw === null) return { ok: true, value: undefined };
239
+ if (typeof raw !== "string") return { ok: false };
240
+ const trimmed = raw.trim();
241
+ if (trimmed === "" || trimmed.toLowerCase() === "auto") return { ok: true, value: undefined };
242
+ if (trimmed.length > LANGUAGE_MAX_CHARS || !LANGUAGE_DIRECTIVE.test(trimmed)) return { ok: false };
243
+ return { ok: true, value: trimmed };
244
+ }
245
+
210
246
  /**
211
247
  * Validate a caller-supplied remote name (origin, upstream, …).
212
248
  * Plain segment: letters/digits/._- only.
@@ -980,19 +1016,29 @@ async function readChangesForMode(rawCwd, mode, signal) {
980
1016
  }
981
1017
 
982
1018
  /**
983
- * Frame one commit-message request. Kept language-neutral: the model is told to
984
- * match the codebase rather than to prefer either of this panel's locales.
1019
+ * Frame one commit-message request.
1020
+ *
1021
+ * Without `language` the model is told to match the codebase rather than to
1022
+ * prefer either of this panel's locales. With one, that rule is REPLACED by an
1023
+ * explicit override: the user asked for a fixed language, and "follow the
1024
+ * repository" is exactly the instruction a repository whose comments and past
1025
+ * commits are in another language would otherwise win against.
1026
+ *
985
1027
  * @param stat - diffstat text (may be empty).
986
1028
  * @param diff - unified diff text.
1029
+ * @param language - language NAME the message must be written in, from
1030
+ * {@link readLanguage}; undefined follows the repository.
987
1031
  */
988
- function generationPrompt(stat, diff) {
1032
+ function generationPrompt(stat, diff, language = undefined) {
989
1033
  const system = [
990
1034
  "You write one git commit message for the change set below.",
991
1035
  "Rules:",
992
1036
  "- Reply with the commit message only: no preamble, no quotes, no Markdown fences.",
993
1037
  "- One imperative subject line under 72 characters; add a short body only when the change needs it.",
994
1038
  "- Describe what changed and why, not how.",
995
- "- Write in the language already used by the codebase's comments and existing commit subjects."
1039
+ language === undefined
1040
+ ? "- Write in the language already used by the codebase's comments and existing commit subjects."
1041
+ : `- Write the entire commit message — subject and body — in ${language}, and in no other language. The user chose this language explicitly, so it overrides the diff, the code comments and the existing commit subjects even when those are written in a different one. Do not translate the message back.`
996
1042
  ].join("\n");
997
1043
  const body = [
998
1044
  "## Diffstat",
@@ -1130,9 +1176,11 @@ async function requestCommitMessage(ctx, prompt, route, signal, sessionId = unde
1130
1176
  * @param rawModel - caller's model id (optional).
1131
1177
  * @param rawSessionId - caller's session id (optional; see
1132
1178
  * {@link requestCommitMessage} for why a session-scoped caller must send it).
1179
+ * @param rawLanguage - language NAME the message must be written in (optional;
1180
+ * absent/`"auto"` follows the repository).
1133
1181
  * @param signal - transport cancellation.
1134
1182
  */
1135
- async function gitGenerateMessage(ctx, rawCwd, rawMode, rawProvider, rawModel, rawSessionId, signal) {
1183
+ async function gitGenerateMessage(ctx, rawCwd, rawMode, rawProvider, rawModel, rawSessionId, rawLanguage, signal) {
1136
1184
  if (!validCwd(rawCwd)) return fail("invalid-cwd", "a valid absolute working directory is required");
1137
1185
  if (rawMode !== undefined && rawMode !== null && !GENERATE_MODES.includes(rawMode)) {
1138
1186
  return fail("invalid-mode", `mode must be one of ${GENERATE_MODES.join(", ")}`);
@@ -1141,6 +1189,10 @@ async function gitGenerateMessage(ctx, rawCwd, rawMode, rawProvider, rawModel, r
1141
1189
  && (typeof rawSessionId !== "string" || rawSessionId === "" || rawSessionId.length > SESSION_ID_MAX_CHARS)) {
1142
1190
  return fail("invalid-session", "sessionId must be a non-empty string when present");
1143
1191
  }
1192
+ const language = readLanguage(rawLanguage);
1193
+ if (language.ok !== true) {
1194
+ return fail("invalid-language", `language must be a plain language name of at most ${LANGUAGE_MAX_CHARS} characters`);
1195
+ }
1144
1196
  const sessionId = typeof rawSessionId === "string" ? rawSessionId : undefined;
1145
1197
  const mode = rawMode ?? GENERATE_MODE_DEFAULT;
1146
1198
  const changes = await readChangesForMode(rawCwd, mode, signal);
@@ -1157,7 +1209,8 @@ async function gitGenerateMessage(ctx, rawCwd, rawMode, rawProvider, rawModel, r
1157
1209
  }
1158
1210
  const prompt = generationPrompt(
1159
1211
  clampForModel(changes.value.stat, LLM_STAT_MAX_CHARS),
1160
- clampForModel(changes.value.diff, LLM_DIFF_MAX_CHARS)
1212
+ clampForModel(changes.value.diff, LLM_DIFF_MAX_CHARS),
1213
+ language.value
1161
1214
  );
1162
1215
  // Bound the call even if the browser goes away without hanging up; the
1163
1216
  // turn's own signal still cancels immediately.
@@ -1220,7 +1273,7 @@ async function dispatch(ctx, endpoint, payload, signal) {
1220
1273
  case "log":
1221
1274
  return gitLog(args.cwd, args.count, signal);
1222
1275
  case "generateMessage":
1223
- return gitGenerateMessage(ctx, args.cwd, args.mode, args.provider, args.model, args.sessionId, signal);
1276
+ return gitGenerateMessage(ctx, args.cwd, args.mode, args.provider, args.model, args.sessionId, args.language, signal);
1224
1277
  default:
1225
1278
  return fail("unknown-endpoint", `unknown git endpoint ${JSON.stringify(endpoint)}`);
1226
1279
  }
@@ -1332,4 +1385,4 @@ function apply(ctx) {
1332
1385
  // diff through git first, so `generateMessage` needs a real repository to reach
1333
1386
  // them) and the commit-message normalizer (pure, and the exact place the
1334
1387
  // subject+body regression lived).
1335
- export { apply, clampForModel, generationPrompt, inject, name, normalizeCommitMessage, requestCommitMessage, resolveLlmRoute };
1388
+ export { apply, clampForModel, generationPrompt, inject, name, normalizeCommitMessage, readLanguage, requestCommitMessage, resolveLlmRoute };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xia-sc/dsh-git",
3
- "version": "0.6.1",
3
+ "version": "0.7.0",
4
4
  "description": "Complete Git management for the DeepSeek Harness Web GUI: branch switch, new-branch-from-base, fetch, pull, stage-all, commit (with AI-drafted message), push, status, and a click-to-open diff viewer (staged/unstaged, resizable) — as a collapsible floating panel that follows the current session's workspace.",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -42,6 +42,7 @@
42
42
  "test": "node test/smoke.mjs && node test/host-mount.mjs && node test/slot-mount.mjs && node test/generate.mjs && node test/render.mjs",
43
43
  "test:commit": "node test/commit.mjs",
44
44
  "test:diff": "node test/diff.mjs",
45
+ "test:ui:settings": "node test/ui/verify-settings.mjs",
45
46
  "prepublishOnly": "npm test"
46
47
  },
47
48
  "dsh": {