@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 +34 -4
- package/README.md +25 -5
- package/lib/client.js +482 -29
- package/lib/index.js +123 -27
- package/package.json +7 -3
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.
|
|
126
|
-
|
|
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
|
|
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
|
|
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
|
-
-
|
|
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
|
|
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
|
-
|
|
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, {
|
|
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:
|
|
1804
|
-
//
|
|
1805
|
-
//
|
|
1806
|
-
//
|
|
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
|
|
1828
|
-
//
|
|
1829
|
-
//
|
|
1830
|
-
var sessionRoute = (
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
2681
|
-
// card's
|
|
2682
|
-
//
|
|
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
|
-
|
|
2690
|
-
margin: "2px
|
|
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
|
-
*
|
|
686
|
-
*
|
|
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.
|
|
977
|
-
*
|
|
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
|
-
|
|
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.
|
|
1042
|
-
*
|
|
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
|
|
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
|
-
|
|
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
|
|
1207
|
-
//
|
|
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
|
-
|
|
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.
|
|
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
|
},
|