@xia-sc/dsh-git 0.6.0 → 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,11 +122,30 @@ 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
125
- registered route. Failures are reported in the last-operation output with a
126
- localized sentence (no changes / no model configured / draft failed, …).
142
+ registered route. The request carries the **current session id** (`sessionId`):
143
+ some gateways (the opencode-style routes on this machine, for one) require a
144
+ session-affinity header, and the host forwards a session id to its adapter only
145
+ when the request has one — without it the gateway answers `MissingSessionID`.
146
+ Failures are reported in the last-operation output with a localized sentence
147
+ (no changes / no model configured / draft failed, …); an unrecognized failure
148
+ code leads with the localized sentence and appends the host's own diagnosis.
127
149
 
128
150
  ## Architecture
129
151
 
@@ -232,7 +254,7 @@ shell metacharacters, and leading dashes are all recorded verbatim.
232
254
  | `commit` | `{ cwd, message }` | `{ message }`; `missing-author` error when `user.name/email` unset |
233
255
  | `push` | `{ cwd }` | `{ message }` (120s timeout) |
234
256
  | `log` | `{ cwd, count? }` | `{ repo, commits: [{sha, author, subject, refs}] }` (clamped 1..50) |
235
- | `generateMessage` | `{ cwd, mode?, provider?, model? }` | `{ message, mode, provider, model }`. `mode` is `staged` (default) / `unstaged` / `all`; anything else is `invalid-mode`. 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`. |
236
258
 
237
259
  > A failed result carries `error.code === "internal"` on the wire (the Connection
238
260
  > envelope only requires a string), with the plugin's own diagnostic in
@@ -293,7 +315,7 @@ shell metacharacters, and leading dashes are all recorded verbatim.
293
315
  - The browser bundle is hand-written (no build step); edits to `lib/client.js`
294
316
  are picked up on refresh (no-cache), host-side edits need a `dsh web`
295
317
  restart.
296
- - Tests (`npm test` runs all four):
318
+ - Tests (`npm test` runs all five):
297
319
  - `node test/smoke.mjs` — route, envelope, endpoint dispatch and input
298
320
  validation (no git spawn: the session sandbox blocks child-process piped
299
321
  stdio);
@@ -308,6 +330,14 @@ shell metacharacters, and leading dashes are all recorded verbatim.
308
330
  removed line starting with `--`), the row renderer, the diff pane's header,
309
331
  and `act()`'s result plumbing and localization (needs a react/react-dom copy,
310
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`.
311
341
  - `npm run test:commit` — **end-to-end**: really spawns git in a throwaway
312
342
  repository, commits through the plugin's own `/dsh-git-rpc/commit` route, and
313
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,10 +84,22 @@ 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
- 其次是上次实际使用),取不到时回落到宿主注册的第一条路由。失败会以
89
- 本地化文案显示在"上次操作输出"里(无可用改动 / 未配置模型 / 生成失败等)。
98
+ 其次是上次实际使用),取不到时回落到宿主注册的第一条路由。请求会带上**当前会话 id**
99
+ (`sessionId`),因为部分网关(例如本机配置的 opencode 系路由)要求会话亲和头,
100
+ 而宿主只在请求带 `sessionId` 时才把它转给适配器——不带就会被网关以
101
+ `MissingSessionID` 拒掉。失败会以本地化文案显示在"上次操作输出"里
102
+ (无可用改动 / 未配置模型 / 生成失败等;未知失败码先给本地化短句,再附宿主的原始诊断)。
90
103
 
91
104
  ## 架构
92
105
 
@@ -180,7 +193,7 @@ dsh plugin --profile web remove @xia-sc/dsh-git
180
193
  | `commit` | `{ cwd, message }` | `{ message }`;未配置 `user.name/email` 时报 `missing-author` 错误 |
181
194
  | `push` | `{ cwd }` | `{ message }`(120s 超时) |
182
195
  | `log` | `{ cwd, count? }` | `{ repo, commits: [{sha, author, subject, refs}] }`(钳制 1..50) |
183
- | `generateMessage` | `{ cwd, mode?, provider?, model? }` | `{ message, mode, provider, model }`。`mode` 为 `staged`(默认)/`unstaged`/`all`,非法值报 `invalid-mode`;失败码见 `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`。 |
184
197
 
185
198
  > 失败结果的 `error.code` 在线路上固定为 `"internal"`(Connection 信封只要求它是字符串),
186
199
  > 插件自己的诊断码放在 `error.details.code`;客户端按该码做本地化文案。
@@ -234,7 +247,14 @@ dsh plugin --profile web remove @xia-sc/dsh-git
234
247
  diff 解析器(行号 / 分类 / `--` 开头的删除行)、行渲染、差异面板标题栏与
235
248
  `act()` 的结果回传/本地化(需要一份 react/react-dom,可用
236
249
  `DSH_GIT_REACT_ROOT` 指定,找不到则 SKIP)。
237
- - 也提供 `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`(依次跑五个)。
238
258
  - `npm run test:commit` —— **端到端**:在临时仓库里真起 git,走插件的
239
259
  `/dsh-git-rpc/commit` 路由提交,再用 `git log --format=%B` 逐字节比对提交
240
260
  信息(多行、CRLF、中文、前导 `-`、shell 元字符等),并确认非法信息被拒且
package/lib/client.js CHANGED
@@ -76,6 +76,28 @@ window.__ModuleLoader__.load({
76
76
  "generate.truncated": "模型还没写出提交信息就用完了 token 上限(思考模型会先花掉这份预算),再点一次通常即可",
77
77
  "generate.cancelled": "生成已取消",
78
78
  "generate.failed": "生成失败",
79
+ "generate.failedDetail": "生成失败:{message}",
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 生成」只用所选语言写提交信息,不受代码注释与历史提交影响;「自动」跟随仓库。",
79
101
  "commit.placeholder": "提交信息(可多行,Ctrl+Enter 提交)…",
80
102
  "commit.empty": "请输入提交信息",
81
103
  "changes.title": "变更",
@@ -180,6 +202,28 @@ window.__ModuleLoader__.load({
180
202
  "generate.truncated": "The model hit its token cap before writing anything (a reasoning model spends that budget thinking first) — trying again usually works",
181
203
  "generate.cancelled": "Draft cancelled",
182
204
  "generate.failed": "Draft failed",
205
+ "generate.failedDetail": "Draft failed: {message}",
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.",
183
227
  "commit.placeholder": "Commit message (multi-line allowed, Ctrl+Enter commits)…",
184
228
  "commit.empty": "Enter a commit message",
185
229
  "changes.title": "Changes",
@@ -240,6 +284,90 @@ window.__ModuleLoader__.load({
240
284
  "status.changed": "changed"
241
285
  };
242
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
+
243
371
  /** React.createElement shorthand. */
244
372
  function h(type, props) {
245
373
  var children = Array.prototype.slice.call(arguments, 2);
@@ -251,7 +379,12 @@ window.__ModuleLoader__.load({
251
379
  panel: {
252
380
  position: "fixed",
253
381
  // Sit above the composer card so the expanded panel never covers
254
- // the input box (the input.left pill sits in the tool row).
382
+ // the input box. 168px is a measurement of the composer stack
383
+ // (card + tool row + the dock band), not a published contract:
384
+ // ui-conversation keeps that geometry in its own module CSS
385
+ // (`--dsh-composer-stack-gap` etc.). If the composer grows or
386
+ // shrinks, re-measure here and in DIFF_PANEL_HEIGHT below — the
387
+ // panel is anchored by `bottom`, so both move together.
255
388
  bottom: 168,
256
389
  left: "50%",
257
390
  transform: "translateX(-50%)",
@@ -326,12 +459,85 @@ window.__ModuleLoader__.load({
326
459
  boxSizing: "border-box",
327
460
  cursor: "pointer"
328
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
+ },
329
469
  body: {
330
470
  flex: "1",
331
471
  minHeight: 0,
332
472
  padding: "10px 12px 12px",
333
473
  overflowY: "auto"
334
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
+ },
335
541
  row: {
336
542
  display: "flex",
337
543
  alignItems: "center",
@@ -878,6 +1084,33 @@ window.__ModuleLoader__.load({
878
1084
  return text.length > max ? text.slice(0, max - 1) + "…" : text;
879
1085
  }
880
1086
 
1087
+ /**
1088
+ * The model route one session's AI draft should use, or null to let the
1089
+ * host fall back to its first registered provider route.
1090
+ *
1091
+ * Two host generations carry the same `modelSelection` projection: the
1092
+ * row's own `projectionValues` (present since 0.1.6) and, since 0.1.7,
1093
+ * the snapshot's per-session `projectionsBySession[id].values`. Read the
1094
+ * row first, then the shared record, so neither generation loses a route
1095
+ * the user already chose. `next` (a pending pick) beats `lastUsed`.
1096
+ *
1097
+ * @param summary - `byId[sessionId]` row, when the snapshot has one.
1098
+ * @param sessions - the whole `useSessions` snapshot (may be undefined).
1099
+ * @param sessionId - the bound session id, or null/undefined.
1100
+ * @returns `{ provider, model }`, or null.
1101
+ */
1102
+ function sessionModelRoute(summary, sessions, sessionId) {
1103
+ var selection = summary && summary.projectionValues ? summary.projectionValues.modelSelection : undefined;
1104
+ if (!selection && sessionId !== null && sessionId !== undefined && sessions && sessions.projectionsBySession) {
1105
+ var record = sessions.projectionsBySession[sessionId];
1106
+ selection = record && record.values ? record.values.modelSelection : undefined;
1107
+ }
1108
+ if (!selection) return null;
1109
+ var effective = selection.next || selection.lastUsed;
1110
+ if (!effective || !effective.provider || !effective.model) return null;
1111
+ return { provider: effective.provider, model: effective.model };
1112
+ }
1113
+
881
1114
  /**
882
1115
  * Strip ANSI/VT escape sequences from git's human-readable output.
883
1116
  *
@@ -1100,8 +1333,13 @@ window.__ModuleLoader__.load({
1100
1333
  commit: function (cwd, message) { return rpc(ctx, "commit", { cwd: cwd, message: message }); },
1101
1334
  push: function (cwd) { return rpc(ctx, "push", { cwd: cwd }); },
1102
1335
  log: function (cwd) { return rpc(ctx, "log", { cwd: cwd, count: 10 }); },
1103
- generateMessage: function (cwd, mode, provider, model) {
1104
- return rpc(ctx, "generateMessage", { cwd: cwd, mode: mode, provider: provider, model: model });
1336
+ generateMessage: function (cwd, mode, provider, model, sessionId, language) {
1337
+ // The session travels with the request: session-labelled
1338
+ // providers reject an unidentified call, and the host forwards
1339
+ // a session id to its adapter only when one is present.
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 });
1105
1343
  }
1106
1344
  };
1107
1345
 
@@ -1113,6 +1351,16 @@ window.__ModuleLoader__.load({
1113
1351
  */
1114
1352
  var sessionId = null;
1115
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
+
1116
1364
  function idleState() {
1117
1365
  return {
1118
1366
  sessionId: sessionId,
@@ -1134,7 +1382,10 @@ window.__ModuleLoader__.load({
1134
1382
  busy: null,
1135
1383
  refreshing: false,
1136
1384
  lastResult: null,
1137
- panelOpen: false
1385
+ panelOpen: false,
1386
+ settingsOpen: false,
1387
+ generateLanguage: languageId,
1388
+ generateLanguageText: languageText
1138
1389
  };
1139
1390
  }
1140
1391
 
@@ -1169,6 +1420,7 @@ window.__ModuleLoader__.load({
1169
1420
  phase: "error",
1170
1421
  error: status && status.error ? String(status.error.message || "git status failed") : "git status failed",
1171
1422
  panelOpen: state.panelOpen,
1423
+ settingsOpen: state.settingsOpen,
1172
1424
  lastResult: state.lastResult
1173
1425
  });
1174
1426
  }
@@ -1188,6 +1440,7 @@ window.__ModuleLoader__.load({
1188
1440
  remote: bv !== null && Array.isArray(bv.remote) ? bv.remote : [],
1189
1441
  commits: lv !== null && Array.isArray(lv.commits) ? lv.commits : [],
1190
1442
  panelOpen: state.panelOpen,
1443
+ settingsOpen: state.settingsOpen,
1191
1444
  lastResult: state.lastResult
1192
1445
  });
1193
1446
  }
@@ -1222,6 +1475,7 @@ window.__ModuleLoader__.load({
1222
1475
  phase: "error",
1223
1476
  error: error && error.message ? String(error.message) : String(error),
1224
1477
  panelOpen: state.panelOpen,
1478
+ settingsOpen: state.settingsOpen,
1225
1479
  lastResult: state.lastResult
1226
1480
  }));
1227
1481
  });
@@ -1294,8 +1548,57 @@ window.__ModuleLoader__.load({
1294
1548
  });
1295
1549
  }
1296
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
+ */
1297
1556
  function setPanelOpen(open) {
1298
- 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;
1299
1602
  }
1300
1603
 
1301
1604
  return {
@@ -1305,6 +1608,9 @@ window.__ModuleLoader__.load({
1305
1608
  bindSession: bindSession,
1306
1609
  act: act,
1307
1610
  setPanelOpen: setPanelOpen,
1611
+ setSettingsOpen: setSettingsOpen,
1612
+ setGenerateLanguage: setGenerateLanguage,
1613
+ languageDirective: languageDirective,
1308
1614
  verbs: verbs
1309
1615
  };
1310
1616
  }
@@ -1320,6 +1626,73 @@ window.__ModuleLoader__.load({
1320
1626
  fontWeight: 600
1321
1627
  };
1322
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
+
1323
1696
  /** One branch option list; remote picks carry the DWIM-able short name. */
1324
1697
  function branchOptions(state, t) {
1325
1698
  var local = state.local.map(function (entry) {
@@ -1800,10 +2173,14 @@ window.__ModuleLoader__.load({
1800
2173
  // with "selector is not a function".
1801
2174
  var sessions = useSessions(function (s) { return s; });
1802
2175
 
1803
- // NOTE: the shell's seed react-dom is an experimental 18.3.1-next
1804
- // build where React.useSyncExternalStore misbehaves (returns
1805
- // undefined / throws), so subscribe through the classic
1806
- // useState + useEffect pattern instead safe on any React.
2176
+ // NOTE: subscribe through the classic useState + useEffect pattern
2177
+ // instead of React.useSyncExternalStore, so this store's own
2178
+ // subscription is independent of whichever implementation the seed
2179
+ // ships. This is about keeping one dependency out of the path, not a
2180
+ // claim that the seeded build is broken: the framework's own
2181
+ // `useSessions` prop already goes through the renderer's
2182
+ // useSyncExternalStoreWithSelector, so an unusable seed would break
2183
+ // the session list before it broke this panel.
1807
2184
  var stateRef = React.useState(function () { return store.getSnapshot(); });
1808
2185
  var state = stateRef[0];
1809
2186
  var setState = stateRef[1];
@@ -1824,16 +2201,10 @@ window.__ModuleLoader__.load({
1824
2201
  var sessionId = state.sessionId;
1825
2202
  var currentSummary = sessionId !== null && sessions && sessions.byId ? sessions.byId[sessionId] : undefined;
1826
2203
 
1827
- // The session's own model route, when one is recorded: the durable
1828
- // `modelSelection` projection, pending pick first, last used second.
1829
- // Generation falls back to the host's first registered route.
1830
- var sessionRoute = (function () {
1831
- var selection = currentSummary && currentSummary.projectionValues ? currentSummary.projectionValues.modelSelection : undefined;
1832
- if (!selection) return null;
1833
- var effective = selection.next || selection.lastUsed;
1834
- if (!effective || !effective.provider || !effective.model) return null;
1835
- return { provider: effective.provider, model: effective.model };
1836
- })();
2204
+ // The session's own model route, when one is recorded. Both projection
2205
+ // surfaces it can come from are handled by sessionModelRoute; a
2206
+ // missing route leaves generation to the host's first registered one.
2207
+ var sessionRoute = sessionModelRoute(currentSummary, sessions, sessionId);
1837
2208
 
1838
2209
  var messageRef = React.useState("");
1839
2210
  var message = messageRef[0];
@@ -1869,6 +2240,13 @@ window.__ModuleLoader__.load({
1869
2240
  setOutputOpen(false);
1870
2241
  }, [state.lastResult]);
1871
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
+
1872
2250
  // Branch the user picked while the tree is dirty: the pre-check
1873
2251
  // shows a warning instead of switching immediately; the switch only
1874
2252
  // runs via the "switch anyway" button (or once the tree is clean).
@@ -2129,6 +2507,11 @@ window.__ModuleLoader__.load({
2129
2507
  transform: "none"
2130
2508
  });
2131
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;
2132
2515
 
2133
2516
  if (cwd === null) {
2134
2517
  // No Session bound (nothing to float over the input).
@@ -2155,6 +2538,19 @@ window.__ModuleLoader__.load({
2155
2538
  }, [
2156
2539
  h("span", { key: "title", style: S.title }, "⎇ " + t("panel.title")),
2157
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
+ }, "⚙"),
2158
2554
  h("button", {
2159
2555
  key: "refresh",
2160
2556
  type: "button",
@@ -2449,7 +2845,26 @@ window.__ModuleLoader__.load({
2449
2845
  };
2450
2846
  var text = known[details.code];
2451
2847
  if (text === undefined) {
2452
- text = res && res.error && res.error.message ? String(res.error.message) : t("generate.failed");
2848
+ // Unknown code (a transport failure, a provider rejection,
2849
+ // …): lead with the localized sentence, keep the host's
2850
+ // own words as the diagnosis, and name the route that
2851
+ // failed — "which provider is broken" is the actionable
2852
+ // part when a gateway refuses the call.
2853
+ var detail = res && res.error && res.error.message ? String(res.error.message) : "";
2854
+ var failedRoute = details.provider && details.model
2855
+ ? { provider: details.provider, model: details.model }
2856
+ : null;
2857
+ if (detail === "") {
2858
+ text = t("generate.failed");
2859
+ } else if (failedRoute !== null) {
2860
+ text = t("generate.failedRoute", {
2861
+ message: shortText(detail, 160),
2862
+ provider: failedRoute.provider,
2863
+ model: failedRoute.model
2864
+ });
2865
+ } else {
2866
+ text = t("generate.failedDetail", { message: shortText(detail, 160) });
2867
+ }
2453
2868
  }
2454
2869
  return { kind: "error", text: text };
2455
2870
  }
@@ -2462,7 +2877,12 @@ window.__ModuleLoader__.load({
2462
2877
  state.cwd,
2463
2878
  generateMode,
2464
2879
  route ? route.provider : undefined,
2465
- route ? route.model : undefined
2880
+ route ? route.model : undefined,
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()
2466
2886
  );
2467
2887
  }, describeGenerate).then(function (res) {
2468
2888
  if (res && res.ok === true && res.value && res.value.message) {
@@ -2604,7 +3024,29 @@ window.__ModuleLoader__.load({
2604
3024
  onMouseLeave: function () { setResizeHover(false); }
2605
3025
  });
2606
3026
 
2607
- 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]));
2608
3050
  }
2609
3051
 
2610
3052
  /**
@@ -2641,7 +3083,8 @@ window.__ModuleLoader__.load({
2641
3083
  ? true
2642
3084
  : (summary.retainedBy.mainView || 0) > 0;
2643
3085
 
2644
- // Classic subscription pattern (see GitFloatingPanel for why).
3086
+ // Classic subscription pattern (see GitFloatingPanel's note: keep this
3087
+ // store independent of the seed's useSyncExternalStore build).
2645
3088
  var stateRef = React.useState(function () { return store.getSnapshot(); });
2646
3089
  var state = stateRef[0];
2647
3090
  var setState = stateRef[1];
@@ -2677,17 +3120,22 @@ window.__ModuleLoader__.load({
2677
3120
  }
2678
3121
 
2679
3122
  // Left-aligned compact pill in the row band ABOVE the composer card
2680
- // (conversation.input.dock). The padding-left mirrors the centered
2681
- // card's left margin, so the pill hugs the textarea's top-left
2682
- // corner instead of floating at the conversation column's edge.
3123
+ // (conversation.input.dock). The row box is capped at the composer
3124
+ // card's own width and centered — the same way ui-conversation's own
3125
+ // dock occupant (QueueDock) aligns itself — so the pill hugs the
3126
+ // textarea's top-left corner instead of floating at the conversation
3127
+ // column's edge. `--dsh-composer-card-max-width` is ui-conversation's
3128
+ // internal variable (no published contract, and 0.1.7 added a second
3129
+ // value for the embedded body); the 778px fallback keeps this row sane
3130
+ // if it is ever renamed.
2683
3131
  return h("div", {
2684
3132
  style: {
2685
3133
  boxSizing: "border-box",
2686
3134
  display: "flex",
2687
3135
  justifyContent: "flex-start",
2688
3136
  width: "100%",
2689
- paddingLeft: "calc((100% - var(--dsh-composer-card-max-width, 778px)) / 2)",
2690
- margin: "2px 0"
3137
+ maxWidth: "var(--dsh-composer-card-max-width, 778px)",
3138
+ margin: "2px auto"
2691
3139
  },
2692
3140
  "data-dsh-git": "dock-row"
2693
3141
  }, h("button", {
@@ -2769,10 +3217,15 @@ window.__ModuleLoader__.load({
2769
3217
  DIFF_ROW: DIFF_ROW,
2770
3218
  stripAnsiEscapes: stripAnsiEscapes,
2771
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,
2772
3224
  sectionView: sectionView,
2773
3225
  changeTagStyle: changeTagStyle,
2774
3226
  panelBoxStyle: panelBoxStyle,
2775
- DIFF_PANEL_HEIGHT: DIFF_PANEL_HEIGHT
3227
+ DIFF_PANEL_HEIGHT: DIFF_PANEL_HEIGHT,
3228
+ sessionModelRoute: sessionModelRoute
2776
3229
  };
2777
3230
  return module.exports;
2778
3231
  }
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.
@@ -49,7 +50,6 @@
49
50
  * `/api` transport — loopback or a configured trusted authority, same-origin.
50
51
  */
51
52
  import { execFile } from "node:child_process";
52
- import { randomUUID } from "node:crypto";
53
53
  import { isAbsolute } from "node:path";
54
54
 
55
55
  /** Stable Cordis plugin name. */
@@ -95,8 +95,28 @@ const LLM_STAT_MAX_CHARS = 2000;
95
95
  const LLM_MAX_TOKENS = 8192;
96
96
  /** Accepted `generateMessage` modes. */
97
97
  const GENERATE_MODES = ["staged", "unstaged", "all"];
98
+ /**
99
+ * Longest session id this plugin will forward to the LLM service. Real ids are
100
+ * short branded strings; the cap only keeps a hostile caller from stuffing
101
+ * arbitrary text into a request header.
102
+ */
103
+ const SESSION_ID_MAX_CHARS = 200;
98
104
  /** Default mode: the only one whose content is what `commit` will actually record. */
99
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;
100
120
 
101
121
  /** A successful RPC result. */
102
122
  function ok(value) {
@@ -202,6 +222,27 @@ function validCwd(value) {
202
222
  return typeof value === "string" && value.length > 0 && isAbsolute(value);
203
223
  }
204
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
+
205
246
  /**
206
247
  * Validate a caller-supplied remote name (origin, upstream, …).
207
248
  * Plain segment: letters/digits/._- only.
@@ -682,8 +723,10 @@ function endpointFromPath(pathname) {
682
723
 
683
724
  /**
684
725
  * Read a request body with a hard byte cap.
685
- * Uses the classic data/end/error events rather than async iteration: the
686
- * IncomingMessage async iterator is not a stable path in this runtime.
726
+ * Deliberately uses the classic data/end/error events rather than async
727
+ * iteration: the host's own bridge does `for await (const chunk of req)`, and
728
+ * keeping this reader on the event API means one dependency fewer on an
729
+ * IncomingMessage behaviour this plugin cannot pin.
687
730
  * @param req - node:http request.
688
731
  * @param maxBytes - cap; exceeding it resolves to undefined.
689
732
  * @returns the utf8 body, or undefined on overrun/error/abort.
@@ -973,19 +1016,29 @@ async function readChangesForMode(rawCwd, mode, signal) {
973
1016
  }
974
1017
 
975
1018
  /**
976
- * Frame one commit-message request. Kept language-neutral: the model is told to
977
- * 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
+ *
978
1027
  * @param stat - diffstat text (may be empty).
979
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.
980
1031
  */
981
- function generationPrompt(stat, diff) {
1032
+ function generationPrompt(stat, diff, language = undefined) {
982
1033
  const system = [
983
1034
  "You write one git commit message for the change set below.",
984
1035
  "Rules:",
985
1036
  "- Reply with the commit message only: no preamble, no quotes, no Markdown fences.",
986
1037
  "- One imperative subject line under 72 characters; add a short body only when the change needs it.",
987
1038
  "- Describe what changed and why, not how.",
988
- "- 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.`
989
1042
  ].join("\n");
990
1043
  const body = [
991
1044
  "## Diffstat",
@@ -1038,17 +1091,21 @@ async function resolveLlmRoute(ctx, rawProvider, rawModel) {
1038
1091
  * Hand-built rather than imported from `@deepseek-ai/dsh-llm`: this package
1039
1092
  * deliberately declares no `@deepseek-ai/*` runtime imports, because a plugin
1040
1093
  * installed with pnpm `link:` resolves them from its own real source path,
1041
- * where no host tree exists. The shape must stay exactly this: a fresh id, the
1042
- * `user` role, one text block, and a plugin source.
1094
+ * where no host tree exists.
1095
+ *
1096
+ * The shape is the host's documented hand-built one-shot input
1097
+ * (`RequestUserInput`): the `user` role plus one text block, and deliberately
1098
+ * **no `id` and no `source`**. dsh >= 0.1.7 retired the catch-all `plugin`
1099
+ * source kind — `MessageSourceMap` has no such member ("each producer declares
1100
+ * its own `kind` in its own module; there is no shared catch-all `plugin`
1101
+ * kind", `dsh-llm/lib/types/message.d.ts`), and Session format v4 refuses
1102
+ * `{kind:"plugin"}` outright (`dsh-session-format-v3-to-v4`). A one-shot
1103
+ * request needs no durable identity: only `provider`/`model`/`messages`/… are
1104
+ * read off {@link GenerateOptions}.
1043
1105
  * @param text - model-facing prompt body.
1044
1106
  */
1045
1107
  function generationMessage(text) {
1046
- return Object.freeze({
1047
- id: randomUUID(),
1048
- role: "user",
1049
- content: Object.freeze([Object.freeze({ type: "text", text })]),
1050
- source: Object.freeze({ kind: "plugin", plugin: name })
1051
- });
1108
+ return { role: "user", content: [{ type: "text", text }] };
1052
1109
  }
1053
1110
 
1054
1111
  /**
@@ -1057,18 +1114,27 @@ function generationMessage(text) {
1057
1114
  * @param prompt - `{ system, body }` from {@link generationPrompt}.
1058
1115
  * @param route - `{ provider, model }` from {@link resolveLlmRoute}.
1059
1116
  * @param signal - cancellation (browser abort plus the server ceiling).
1117
+ * @param sessionId - the caller's session, when it has one. Forwarded as
1118
+ * `GenerateOptions.sessionId`; adapters that need session affinity (they
1119
+ * receive it only when it is present — see `dsh-llm-pi-ai`'s options mapping)
1120
+ * reject an unidentified request outright, so an AI draft from the panel must
1121
+ * carry one.
1060
1122
  * @returns the trimmed message.
1061
1123
  * @throws on a terminal stream failure, an output cap hit before any text
1062
1124
  * existed (`llm-truncated`), or genuinely empty output (`llm-empty`).
1063
1125
  */
1064
- async function requestCommitMessage(ctx, prompt, route, signal) {
1126
+ async function requestCommitMessage(ctx, prompt, route, signal, sessionId = undefined) {
1065
1127
  const options = {
1066
1128
  provider: route.provider,
1067
1129
  model: route.model,
1068
1130
  messages: [generationMessage(prompt.body)],
1069
1131
  system: prompt.system,
1070
1132
  maxTokens: LLM_MAX_TOKENS,
1071
- signal
1133
+ signal,
1134
+ // Session-labelled routes (an "opencode go"-style gateway is the local
1135
+ // example) answer 400 when the request carries no session identity, and
1136
+ // the host's pi-ai adapter forwards this field only when it is present.
1137
+ ...(sessionId === undefined ? {} : { sessionId })
1072
1138
  };
1073
1139
  // Final text comes from the assembled blocks; the delta map is a fallback for
1074
1140
  // an adapter that emits text without a closing `block-end`.
@@ -1108,13 +1174,26 @@ async function requestCommitMessage(ctx, prompt, route, signal) {
1108
1174
  * @param rawMode - `staged`, `unstaged`, or `all`.
1109
1175
  * @param rawProvider - caller's provider route (optional).
1110
1176
  * @param rawModel - caller's model id (optional).
1177
+ * @param rawSessionId - caller's session id (optional; see
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).
1111
1181
  * @param signal - transport cancellation.
1112
1182
  */
1113
- async function gitGenerateMessage(ctx, rawCwd, rawMode, rawProvider, rawModel, signal) {
1183
+ async function gitGenerateMessage(ctx, rawCwd, rawMode, rawProvider, rawModel, rawSessionId, rawLanguage, signal) {
1114
1184
  if (!validCwd(rawCwd)) return fail("invalid-cwd", "a valid absolute working directory is required");
1115
1185
  if (rawMode !== undefined && rawMode !== null && !GENERATE_MODES.includes(rawMode)) {
1116
1186
  return fail("invalid-mode", `mode must be one of ${GENERATE_MODES.join(", ")}`);
1117
1187
  }
1188
+ if (rawSessionId !== undefined && rawSessionId !== null
1189
+ && (typeof rawSessionId !== "string" || rawSessionId === "" || rawSessionId.length > SESSION_ID_MAX_CHARS)) {
1190
+ return fail("invalid-session", "sessionId must be a non-empty string when present");
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
+ }
1196
+ const sessionId = typeof rawSessionId === "string" ? rawSessionId : undefined;
1118
1197
  const mode = rawMode ?? GENERATE_MODE_DEFAULT;
1119
1198
  const changes = await readChangesForMode(rawCwd, mode, signal);
1120
1199
  if (changes.ok !== true) return changes;
@@ -1130,7 +1209,8 @@ async function gitGenerateMessage(ctx, rawCwd, rawMode, rawProvider, rawModel, s
1130
1209
  }
1131
1210
  const prompt = generationPrompt(
1132
1211
  clampForModel(changes.value.stat, LLM_STAT_MAX_CHARS),
1133
- clampForModel(changes.value.diff, LLM_DIFF_MAX_CHARS)
1212
+ clampForModel(changes.value.diff, LLM_DIFF_MAX_CHARS),
1213
+ language.value
1134
1214
  );
1135
1215
  // Bound the call even if the browser goes away without hanging up; the
1136
1216
  // turn's own signal still cancels immediately.
@@ -1138,10 +1218,16 @@ async function gitGenerateMessage(ctx, rawCwd, rawMode, rawProvider, rawModel, s
1138
1218
  const combined = signal === undefined ? timeout : AbortSignal.any([signal, timeout]);
1139
1219
  if (combined.aborted) return fail("cancelled", "generation was cancelled", { mode });
1140
1220
  try {
1141
- const message = await requestCommitMessage(ctx, prompt, route, combined);
1221
+ const message = await requestCommitMessage(ctx, prompt, route, combined, sessionId);
1142
1222
  return ok({ message, mode, provider: route.provider, model: route.model });
1143
1223
  } catch (error) {
1144
- return fail(error.pluginCode ?? "llm-failed", error instanceof Error ? error.message : String(error), { mode });
1224
+ // The route rides along so the panel can name the provider that failed:
1225
+ // a route-level gateway rejection otherwise reads as a plugin bug.
1226
+ return fail(error.pluginCode ?? "llm-failed", error instanceof Error ? error.message : String(error), {
1227
+ mode,
1228
+ provider: route.provider,
1229
+ model: route.model
1230
+ });
1145
1231
  }
1146
1232
  }
1147
1233
 
@@ -1187,7 +1273,7 @@ async function dispatch(ctx, endpoint, payload, signal) {
1187
1273
  case "log":
1188
1274
  return gitLog(args.cwd, args.count, signal);
1189
1275
  case "generateMessage":
1190
- return gitGenerateMessage(ctx, args.cwd, args.mode, args.provider, args.model, signal);
1276
+ return gitGenerateMessage(ctx, args.cwd, args.mode, args.provider, args.model, args.sessionId, args.language, signal);
1191
1277
  default:
1192
1278
  return fail("unknown-endpoint", `unknown git endpoint ${JSON.stringify(endpoint)}`);
1193
1279
  }
@@ -1203,8 +1289,10 @@ function apply(ctx) {
1203
1289
  // The channel runs git against caller-supplied absolute paths, so it must
1204
1290
  // never mount unfenced: the connection service's Host/Origin + browser-cookie
1205
1291
  // check is the only gate. `requestRejection` is the >= 0.1.5-rc.1 form of that
1206
- // check; an older host without it fails loudly instead of serving an open
1207
- // channel.
1292
+ // check. An older host without it still gets no channel (this throws), but
1293
+ // note the throw is *stderr-only* for the operator: dsh-app-boot treats
1294
+ // non-required rows as optional, so this row is skipped with a warning rather
1295
+ // than failing the boot. Never downgrade the guard to a pass-through.
1208
1296
  const connection = ctx.get("connection");
1209
1297
  if (connection === undefined || typeof connection.requestRejection !== "function") {
1210
1298
  throw new Error(`${name}: this plugin requires dsh >= 0.1.5-rc.1 (connection.requestRejection is unavailable, so /dsh-git-rpc could not be fenced)`);
@@ -1213,7 +1301,15 @@ function apply(ctx) {
1213
1301
  kind: "prefix",
1214
1302
  path: RPC_CHANNEL,
1215
1303
  handler: async (req, res) => {
1216
- const pathname = String(req.url ?? "/").split("?")[0];
1304
+ // The host's own router matches on `new URL(req.url, "http://x").pathname`,
1305
+ // so an absolute-form request target (a proxy in front of dsh web) must
1306
+ // resolve here too. Origin-form targets are kept verbatim: `endpointFromPath`
1307
+ // is the stricter reader (it rejects `.`/`..`/empty segments and anything
1308
+ // outside the client's own segment pattern), and routing the raw target
1309
+ // through the URL parser first would silently normalize a traversal
1310
+ // attempt into a different endpoint instead of refusing it.
1311
+ const rawTarget = String(req.url ?? "/");
1312
+ const pathname = rawTarget.startsWith("/") ? rawTarget : new URL(rawTarget, "http://x").pathname;
1217
1313
  const endpoint = endpointFromPath(pathname);
1218
1314
  if (endpoint === undefined) {
1219
1315
  sendEnvelope(res, 404, rpcError("invalid-request", "not-found", "not found", {}));
@@ -1289,4 +1385,4 @@ function apply(ctx) {
1289
1385
  // diff through git first, so `generateMessage` needs a real repository to reach
1290
1386
  // them) and the commit-message normalizer (pure, and the exact place the
1291
1387
  // subject+body regression lived).
1292
- 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.0",
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",
@@ -39,9 +39,10 @@
39
39
  "access": "public"
40
40
  },
41
41
  "scripts": {
42
- "test": "node test/smoke.mjs && node test/host-mount.mjs && node test/generate.mjs && node test/render.mjs",
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": {
@@ -51,9 +52,12 @@
51
52
  "client": {
52
53
  "platform": "web",
53
54
  "inject": [
55
+ "@deepseek-ai/dsh-client-connection",
54
56
  "@deepseek-ai/dsh-client-locale",
55
57
  "@deepseek-ai/dsh-client-ui-conversation",
56
- "@deepseek-ai/dsh-client-ui-layout"
58
+ "@deepseek-ai/dsh-client-ui-layout",
59
+ "@deepseek-ai/dsh-client-ui-renderer",
60
+ "@deepseek-ai/dsh-client-ui-session"
57
61
  ]
58
62
  }
59
63
  },