@xia-sc/dsh-git 0.5.2 → 0.5.3

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 xia-sc
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.en.md CHANGED
@@ -21,7 +21,13 @@ new-branch-from-base.
21
21
  the affected files instead of switching, with a "switch anyway" escape
22
22
  hatch, fetch/pull actions, a **commit area** — stage-all button, draft-basis
23
23
  picker, "✨ AI draft" button, message input and commit button — collapsible
24
- changes and recent-commit lists, last-operation output). The panel is
24
+ changes and recent-commit lists). **Operation feedback is pinned to the very
25
+ top of the panel and stays one line tall** — the busy label and the last
26
+ operation's result are the first rows of the body. The result is a localized
27
+ phrase ("Pushed", "Staged everything", "Switched to x"); git's own output (a
28
+ push sideband banner, the `LF will be replaced by CRLF` advice) sits behind a
29
+ "Details ▾" toggle on the right, so the change list and the log can never push
30
+ the notification out of sight. The panel is
25
31
  **draggable by its header bar**
26
32
  (the top row with the Git title — press, drag, release; it stays where
27
33
  dropped and is clamped inside the viewport; header buttons/inputs never
@@ -40,6 +46,11 @@ new-branch-from-base.
40
46
  - Both seats share one store, so they always agree, and both re-bind when the
41
47
  current session (and its cwd) changes. Rebinding clears the selected file —
42
48
  the panel never shows another repository's content.
49
+ - **ANSI colour codes are stripped from git's text output**: remotes colour
50
+ their own banner (gitee's `Powered by GITEE.COM` does), and the panel is a DOM,
51
+ not a terminal — the ESC byte has no glyph, so only the parameters survived
52
+ and read as `[0[01;33m`. Only `message` text is stripped; **diff bodies and
53
+ paths stay byte-faithful**.
43
54
 
44
55
  ### The diff viewer
45
56
 
@@ -202,11 +213,16 @@ shell metacharacters, and leading dashes are all recorded verbatim.
202
213
  | `commit` | `{ cwd, message }` | `{ message }`; `missing-author` error when `user.name/email` unset |
203
214
  | `push` | `{ cwd }` | `{ message }` (120s timeout) |
204
215
  | `log` | `{ cwd, count? }` | `{ repo, commits: [{sha, author, subject, refs}] }` (clamped 1..50) |
205
- | `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-empty`, `cancelled`, `llm-failed`. |
216
+ | `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`. |
206
217
 
207
218
  > A failed result carries `error.code === "internal"` on the wire (the Connection
208
219
  > envelope only requires a string), with the plugin's own diagnostic in
209
220
  > `error.details.code`; the client localizes from that code.
221
+ >
222
+ > The `message` of `fetch`/`pull`/`push`/`stage`/`commit` is **git's own output**
223
+ > (ANSI colour codes stripped): an empty string when git said nothing. The panel
224
+ > notifies with an `output.<action>` phrase and keeps that text behind its
225
+ > expandable "Details".
210
226
 
211
227
  ## Design decisions & boundaries
212
228
 
@@ -219,6 +235,10 @@ shell metacharacters, and leading dashes are all recorded verbatim.
219
235
  configured** — possibly a third-party gateway. It only happens when you click
220
236
  "✨ AI draft"; the plugin itself never calls the network. The diff is
221
237
  truncated to 12000 characters, and nothing outside the repository is sent.
238
+ The output cap is 8192 tokens: a reasoning model shares that completion budget
239
+ between its thinking and the message, so a cap sized for the message alone
240
+ produces "hit the cap before writing anything" — reported as `llm-truncated`
241
+ rather than a vague `llm-empty`.
222
242
  - **push/pull credentials** come from the system (Git Credential Manager /
223
243
  SSH agent); the plugin never touches credential storage. AI drafting never
224
244
  touches credentials either — the model adapter resolves its own API key.
@@ -282,3 +302,7 @@ shell metacharacters, and leading dashes are all recorded verbatim.
282
302
  - The last two need piped child-process stdio, so they are deliberately **not**
283
303
  part of `npm test` — run them from an ordinary terminal.
284
304
  - The git command set is verified end-to-end against the running server.
305
+
306
+ ## License
307
+
308
+ MIT — see [LICENSE](LICENSE).
package/README.md CHANGED
@@ -18,7 +18,10 @@ DeepSeek Harness Web GUI 的完整 Git 管理插件,形态为一个**可折叠
18
18
  "仍要切换"按钮;fetch/pull 操作;**提交区**——"暂存全部"按钮、AI 生成依据
19
19
  选择器、"✨ AI 生成"按钮、提交信息输入框(多行文本框,回车换行、
20
20
  Ctrl/Cmd+Enter 提交)与提交按钮;可折叠的变更列表与
21
- 最近提交列表;上次操作输出)。面板**可通过顶栏拖动**(按住带 Git 标题的
21
+ 最近提交列表)。**操作反馈固定钉在面板最上边,而且只有一行**——"推送中…"和上次操作的结果都在
22
+ body 的第一行;结果是一个中文短句("已推送"/"已暂存全部"/"已切换到 xxx"),git 自己的输出
23
+ (push 的 sideband banner、`LF will be replaced by CRLF` 这类提示)收在右侧的"详情 ▾"里,点开才展开,
24
+ 不会被变更列表和最近提交顶到看不见的地方。面板**可通过顶栏拖动**(按住带 Git 标题的
22
25
  那一行,拖到哪里就停在哪里,不会拖出视口;顶栏上的按钮/输入框不会触发
23
26
  拖动;双击顶栏回到居中位置)。
24
27
  分支切换器旁边的"+ 新建分支"按钮会展开一个内联表单:新分支名 + 基分支
@@ -31,6 +34,10 @@ DeepSeek Harness Web GUI 的完整 Git 管理插件,形态为一个**可折叠
31
34
  状态胶囊(分支摘要,或"当前工作区不是 Git 仓库");点击它展开/收起悬浮面板。
32
35
  - 两处界面共享同一个 store,状态永远一致,并都会随当前会话(及其 cwd)
33
36
  切换而重新绑定。切换工作区会清空已选中的文件——绝不让面板显示别的仓库的内容。
37
+ - **git 的文字输出会剥掉 ANSI 颜色码**:远端会给自己的 banner 上色(gitee 的
38
+ `Powered by GITEE.COM` 就是),面板是 DOM 不是终端,ESC 没有字形可渲染,
39
+ 不剥就只剩 `[0[01;33m` 这类参数当正文显示。只剥"消息"文本(`message`),
40
+ **diff 内容与文件名一律逐字节保真**。
34
41
 
35
42
  ### 差异查看
36
43
 
@@ -163,10 +170,13 @@ dsh plugin --profile web remove @xia-sc/dsh-git
163
170
  | `commit` | `{ cwd, message }` | `{ message }`;未配置 `user.name/email` 时报 `missing-author` 错误 |
164
171
  | `push` | `{ cwd }` | `{ message }`(120s 超时) |
165
172
  | `log` | `{ cwd, count? }` | `{ repo, commits: [{sha, author, subject, refs}] }`(钳制 1..50) |
166
- | `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-empty`、`cancelled`、`llm-failed`。 |
173
+ | `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`。 |
167
174
 
168
175
  > 失败结果的 `error.code` 在线路上固定为 `"internal"`(Connection 信封只要求它是字符串),
169
176
  > 插件自己的诊断码放在 `error.details.code`;客户端按该码做本地化文案。
177
+ >
178
+ > `fetch`/`pull`/`push`/`stage`/`commit` 的 `message` 就是 **git 自己的输出**(已剥掉 ANSI 颜色码):
179
+ > git 什么都没说时是空串,界面用 `output.<action>` 的中文短句做通知、把这段原文放进可展开的"详情"。
170
180
 
171
181
  ## 设计决策与边界
172
182
 
@@ -176,7 +186,9 @@ dsh plugin --profile web remove @xia-sc/dsh-git
176
186
  "暂存全部"按钮(`git add --all`),而不是让提交隐式暂存。
177
187
  - **AI 生成会把改动的 diff 发给你配置的模型提供方**——可能是第三方网关。
178
188
  这是显式点击"✨ AI 生成"才会发生的联网行为;插件本身不联网。diff 截断到
179
- 12000 字符后发送,且不发送任何仓库外的内容。
189
+ 12000 字符后发送,且不发送任何仓库外的内容。输出上限 8192 token:思考型
190
+ 模型的 reasoning 与正文共用同一份 completion 预算,上限太小会"一个字都没
191
+ 写就超限",此时报 `llm-truncated` 而不是含糊的 `llm-empty`。
180
192
  - **push/pull 凭据**来自系统(Git Credential Manager / SSH agent);插件
181
193
  绝不碰凭据存储。AI 生成同样不接触凭据——API key 由模型适配器自己解析。
182
194
  - **插件绝不修改 git config**;缺 author 时给出明确错误而不是悄悄补写。
@@ -223,3 +235,7 @@ dsh plugin --profile web remove @xia-sc/dsh-git
223
235
  - 后两个都必须 spawn git 的管道 stdio,故**不在 `npm test` 内**——请在没有
224
236
  该限制的环境(普通终端)单独运行。
225
237
  git 命令集对照运行中的服务端做端到端验证。
238
+
239
+ ## 许可
240
+
241
+ MIT,见 [LICENSE](LICENSE)。
package/lib/client.js CHANGED
@@ -72,6 +72,7 @@ window.__ModuleLoader__.load({
72
72
  "generate.noProvider": "尚未配置模型,请先在 设置 > 模型 中添加",
73
73
  "generate.noModel": "该提供方没有可用模型",
74
74
  "generate.empty": "模型没有返回提交信息",
75
+ "generate.truncated": "模型还没写出提交信息就用完了 token 上限(思考模型会先花掉这份预算),再点一次通常即可",
75
76
  "generate.cancelled": "生成已取消",
76
77
  "generate.failed": "生成失败",
77
78
  "commit.placeholder": "提交信息(可多行,Ctrl+Enter 提交)…",
@@ -109,6 +110,17 @@ window.__ModuleLoader__.load({
109
110
  "busy.checkout": "切换中…",
110
111
  "busy.createBranch": "创建分支中…",
111
112
  "output.ok": "操作成功",
113
+ "output.failed": "操作失败",
114
+ "output.detail": "详情",
115
+ "output.detailHide": "收起",
116
+ "output.fetch": "已拉取更新",
117
+ "output.pull": "已拉取合并",
118
+ "output.push": "已推送",
119
+ "output.stage": "已暂存全部",
120
+ "output.commit": "已提交",
121
+ "output.checkout": "已切换到 {branch}",
122
+ "output.createBranch": "已创建并切换到 {branch}",
123
+ "output.generate": "已生成提交信息",
112
124
  "dock.aria": "打开 Git 面板",
113
125
  "status.modified": "修改",
114
126
  "status.added": "新增",
@@ -159,6 +171,7 @@ window.__ModuleLoader__.load({
159
171
  "generate.noProvider": "No model is configured — add one in Settings > Models",
160
172
  "generate.noModel": "That provider advertises no model",
161
173
  "generate.empty": "The model returned no commit message",
174
+ "generate.truncated": "The model hit its token cap before writing anything (a reasoning model spends that budget thinking first) — trying again usually works",
162
175
  "generate.cancelled": "Draft cancelled",
163
176
  "generate.failed": "Draft failed",
164
177
  "commit.placeholder": "Commit message (multi-line allowed, Ctrl+Enter commits)…",
@@ -196,6 +209,17 @@ window.__ModuleLoader__.load({
196
209
  "busy.checkout": "Switching…",
197
210
  "busy.createBranch": "Creating branch…",
198
211
  "output.ok": "Operation succeeded",
212
+ "output.failed": "Operation failed",
213
+ "output.detail": "Details",
214
+ "output.detailHide": "Hide",
215
+ "output.fetch": "Fetched",
216
+ "output.pull": "Pulled",
217
+ "output.push": "Pushed",
218
+ "output.stage": "Staged everything",
219
+ "output.commit": "Committed",
220
+ "output.checkout": "Switched to {branch}",
221
+ "output.createBranch": "Created and switched to {branch}",
222
+ "output.generate": "Draft ready",
199
223
  "dock.aria": "Open Git panel",
200
224
  "status.modified": "modified",
201
225
  "status.added": "added",
@@ -606,11 +630,47 @@ window.__ModuleLoader__.load({
606
630
  fontFamily: "monospace"
607
631
  },
608
632
  output: {
609
- marginTop: 8,
633
+ // Sits at the top of the body, so the breathing room belongs below it.
634
+ // The card itself is one localized summary line tall; git's own words
635
+ // live in `outputDetail`, behind the toggle.
636
+ marginBottom: 8,
610
637
  padding: "6px 10px",
611
638
  borderRadius: 8,
612
639
  fontSize: 12,
640
+ lineHeight: "18px"
641
+ },
642
+ outputLine: {
643
+ display: "flex",
644
+ alignItems: "center",
645
+ gap: 8
646
+ },
647
+ outputSummary: {
648
+ flex: "1",
649
+ minWidth: 0,
650
+ overflow: "hidden",
651
+ textOverflow: "ellipsis",
652
+ whiteSpace: "nowrap"
653
+ },
654
+ outputToggle: {
655
+ flex: "none",
656
+ padding: "0 6px",
657
+ height: 20,
658
+ border: "1px solid currentColor",
659
+ borderRadius: 6,
660
+ background: "transparent",
661
+ color: "inherit",
662
+ font: "inherit",
663
+ fontSize: 11,
613
664
  lineHeight: "18px",
665
+ cursor: "pointer"
666
+ },
667
+ outputDetail: {
668
+ marginTop: 6,
669
+ paddingTop: 6,
670
+ borderTop: "1px solid var(--dsw-alias-border-secondary, rgba(127,127,127,0.35))",
671
+ fontFamily: "monospace",
672
+ fontSize: 11,
673
+ lineHeight: "16px",
614
674
  wordBreak: "break-word",
615
675
  maxHeight: 160,
616
676
  overflowY: "auto",
@@ -645,9 +705,128 @@ window.__ModuleLoader__.load({
645
705
  return text.length > max ? text.slice(0, max - 1) + "…" : text;
646
706
  }
647
707
 
708
+ /**
709
+ * Strip ANSI/VT escape sequences from git's human-readable output.
710
+ *
711
+ * `git push` forwards the remote's own sideband banner verbatim — Gitee,
712
+ * for one, decorates "Powered by GITEE.COM" with SGR codes, and its first
713
+ * sequence is even truncated (`ESC[0`) right before a complete one. A DOM
714
+ * is not a terminal: the ESC byte has no glyph, so only the parameters
715
+ * survived and the banner read `Powered by [0[01;33mGITEE.COM [0[m…`.
716
+ *
717
+ * Only `message` text passes through here. Diff bodies and paths must not:
718
+ * a file may legitimately contain ESC bytes and the pane shows it verbatim.
719
+ *
720
+ * @param text - a git message string (or anything else).
721
+ * @returns the text with escape sequences removed.
722
+ */
723
+ function stripAnsiEscapes(text) {
724
+ return (text === undefined || text === null ? "" : String(text))
725
+ // OSC (window title / hyperlink): ESC ] … BEL or ESC \
726
+ .replace(/\u001B\][^\u0007\u001B]*(?:\u0007|\u001B\\)/g, "")
727
+ // A complete CSI sequence: ESC [ params/intermediates final byte
728
+ .replace(/\u001B\[[0-9;?]*[ -/]*[@-~]/g, "")
729
+ // A truncated CSI (the remote's malformed banner): ESC [ params only
730
+ .replace(/\u001B\[[0-9;?]*/g, "")
731
+ // Any other two-character escape, then any surviving ESC.
732
+ .replace(/\u001B[@-Z\\-_]/g, "")
733
+ .replace(/\u001B/g, "");
734
+ }
735
+
736
+ /** Sanitize one RPC result's human-readable text, leaving data untouched. */
737
+ function sanitizeResult(result) {
738
+ if (result === null || typeof result !== "object") return result;
739
+ var message = function (holder) {
740
+ return holder !== null && typeof holder === "object" && typeof holder.message === "string"
741
+ ? Object.assign({}, holder, { message: stripAnsiEscapes(holder.message) })
742
+ : holder;
743
+ };
744
+ var value = message(result.value);
745
+ var error = message(result.error);
746
+ if (value === result.value && error === result.error) return result;
747
+ return Object.assign({}, result, { value: value, error: error });
748
+ }
749
+
750
+ /** Longest one-line summary an operation card shows before folding. */
751
+ var OUTPUT_SUMMARY_MAX = 160;
752
+
753
+ /**
754
+ * Word one operation result the default way: a summary the panel can
755
+ * localize from `action` (the store holds no translator), plus git's own
756
+ * output as collapsible detail.
757
+ *
758
+ * git's output is often several lines of advice — the LF/CRLF warning on
759
+ * every staged file, a remote's push banner — and showing it as the
760
+ * notification buries the answer to "did it work?". So success keeps git's
761
+ * words behind the toggle, and a failure shows the head of the diagnostic
762
+ * with the whole thing one click away.
763
+ *
764
+ * @param action - the operation name (`push`, `stage`, …).
765
+ * @param res - the raw RPC result.
766
+ * @param params - interpolation for the localized summary (e.g. `{ branch }`).
767
+ * @returns `{ kind, action, params, text, detail }`.
768
+ */
769
+ function summarizeResult(action, res, params) {
770
+ if (res && res.ok === true) {
771
+ var raw = res.value && typeof res.value.message === "string" ? res.value.message.trim() : "";
772
+ return { kind: "ok", action: action, params: params || null, text: null, detail: raw };
773
+ }
774
+ var message = res && res.error && res.error.message ? String(res.error.message).trim() : "";
775
+ return {
776
+ kind: "error",
777
+ action: action,
778
+ params: params || null,
779
+ text: message === "" ? null : shortText(message, OUTPUT_SUMMARY_MAX),
780
+ detail: message.length > OUTPUT_SUMMARY_MAX ? message : ""
781
+ };
782
+ }
783
+
784
+ /**
785
+ * One operation-feedback card: the localized summary line and, when git
786
+ * said anything, git's own output behind a toggle. Pure and exported, so
787
+ * the render test can drive both states (SSR cannot click).
788
+ *
789
+ * @param result - `state.lastResult` (`{ kind, action, params, text, detail }`).
790
+ * @param t - the locale translator.
791
+ * @param open - whether the detail block is expanded.
792
+ * @param onToggle - called when the detail toggle is clicked.
793
+ * @returns the card element, or null without a result.
794
+ */
795
+ function outputView(result, t, open, onToggle) {
796
+ if (!result) return null;
797
+ var summary = result.text !== undefined && result.text !== null && String(result.text) !== ""
798
+ ? String(result.text)
799
+ : result.action
800
+ ? t("output." + result.action, result.params || undefined)
801
+ : t(result.kind === "error" ? "output.failed" : "output.ok");
802
+ var detail = typeof result.detail === "string" ? result.detail.trim() : "";
803
+ var line = [
804
+ h("span", { key: "text", style: S.outputSummary, title: summary }, summary)
805
+ ];
806
+ if (detail !== "") {
807
+ line.push(h("button", {
808
+ key: "toggle",
809
+ type: "button",
810
+ "data-dsh-git": "output-toggle",
811
+ style: S.outputToggle,
812
+ "aria-expanded": open ? "true" : "false",
813
+ onClick: onToggle
814
+ }, (open ? "▴ " : "▾ ") + t(open ? "output.detailHide" : "output.detail")));
815
+ }
816
+ var children = [h("div", { key: "line", style: S.outputLine }, line)];
817
+ if (open && detail !== "") {
818
+ children.push(h("div", { key: "detail", "data-dsh-git": "output-detail", style: S.outputDetail }, detail));
819
+ }
820
+ return h("div", {
821
+ key: "output",
822
+ "data-dsh-git": "output",
823
+ style: Object.assign({}, S.output, result.kind === "ok" ? S.outputOk : S.outputError)
824
+ }, children);
825
+ }
826
+
648
827
  /** Call one /dsh-git-rpc endpoint. */
649
828
  function rpc(ctx, endpoint, args) {
650
- return ctx.connection.rpc.call("/dsh-git-rpc", endpoint, { args: args || {} });
829
+ return ctx.connection.rpc.call("/dsh-git-rpc", endpoint, { args: args || {} }).then(sanitizeResult);
651
830
  }
652
831
 
653
832
  /**
@@ -823,17 +1002,20 @@ window.__ModuleLoader__.load({
823
1002
  * Run one action; on success force a fresh load; surfaces lastResult.
824
1003
  * `describe` lets a caller own the wording of both outcomes (used by
825
1004
  * generation, where the success value is the message itself and a
826
- * failure code maps to a localized string). Resolves with the raw
827
- * RPC result so the caller can also act on it.
1005
+ * failure code maps to a localized string). Without it the result is
1006
+ * summarized by {@link summarizeResult}: a localized one-liner plus
1007
+ * git's own output as detail. `params` interpolates that summary (e.g.
1008
+ * `{ branch }`); the panel localizes it, since the store holds no `t`.
1009
+ * Resolves with the raw RPC result so the caller can also act on it.
1010
+ * @param name - the operation name (`push`, `commit`, …).
1011
+ * @param run - performs the call.
1012
+ * @param describe - optional wording override for both outcomes.
1013
+ * @param params - optional interpolation for the default summary.
828
1014
  */
829
- function act(name, run, describe) {
1015
+ function act(name, run, describe, params) {
830
1016
  emit(Object.assign({}, state, { busy: name, lastResult: null }));
831
1017
  return run().then(function (res) {
832
- var result = describe
833
- ? describe(res)
834
- : res && res.ok === true
835
- ? { kind: "ok", text: res.value && res.value.message ? String(res.value.message) : null }
836
- : { kind: "error", text: res && res.error && res.error.message ? String(res.error.message) : "operation failed" };
1018
+ var result = describe ? describe(res) : summarizeResult(name, res, params);
837
1019
  emit(Object.assign({}, state, { busy: null, lastResult: result }));
838
1020
  if (res && res.ok === true) {
839
1021
  var cwd = state.cwd;
@@ -841,10 +1023,10 @@ window.__ModuleLoader__.load({
841
1023
  }
842
1024
  return res;
843
1025
  }).catch(function (error) {
844
- emit(Object.assign({}, state, {
845
- busy: null,
846
- lastResult: { kind: "error", text: error && error.message ? String(error.message) : String(error) }
847
- }));
1026
+ // A transport failure is worded exactly like a git failure: the
1027
+ // same summary/detail split, so the card never grows a second shape.
1028
+ var failure = { ok: false, error: { message: error && error.message ? String(error.message) : String(error) } };
1029
+ emit(Object.assign({}, state, { busy: null, lastResult: summarizeResult(name, failure, params) }));
848
1030
  });
849
1031
  }
850
1032
 
@@ -1377,6 +1559,15 @@ window.__ModuleLoader__.load({
1377
1559
  var logOpenRef = React.useState(false);
1378
1560
  var logOpen = logOpenRef[0];
1379
1561
  var setLogOpen = logOpenRef[1];
1562
+ // Whether the last operation's git output is expanded. A new result
1563
+ // always starts collapsed: the summary line is the notification, the
1564
+ // raw output is opt-in.
1565
+ var outputOpenRef = React.useState(false);
1566
+ var outputOpen = outputOpenRef[0];
1567
+ var setOutputOpen = outputOpenRef[1];
1568
+ React.useEffect(function () {
1569
+ setOutputOpen(false);
1570
+ }, [state.lastResult]);
1380
1571
 
1381
1572
  // Branch the user picked while the tree is dirty: the pre-check
1382
1573
  // shows a warning instead of switching immediately; the switch only
@@ -1688,6 +1879,16 @@ window.__ModuleLoader__.load({
1688
1879
  ]);
1689
1880
 
1690
1881
  var bodyChildren = [];
1882
+ // Operation feedback sits at the very TOP of the body. A push banner is
1883
+ // several lines long, and at the bottom of a 400px panel it landed under
1884
+ // the change list and the log — below the fold on any repo with a few
1885
+ // files, which is exactly where nobody looks after clicking a button.
1886
+ if (state.lastResult) {
1887
+ bodyChildren.push(outputView(state.lastResult, t, outputOpen, function () { setOutputOpen(!outputOpen); }));
1888
+ }
1889
+ if (busy) {
1890
+ bodyChildren.push(h("div", { key: "busy", style: S.meta }, busyLabel));
1891
+ }
1691
1892
  if (state.phase === "loading" || state.phase === "idle") {
1692
1893
  bodyChildren.push(h("div", { key: "loading", style: S.meta }, t("loading")));
1693
1894
  } else if (state.phase === "error") {
@@ -1728,7 +1929,7 @@ window.__ModuleLoader__.load({
1728
1929
  var value = event.target.value;
1729
1930
  if (!value || value === state.branch) { setPendingBranch(null); return; }
1730
1931
  if (state.dirty > 0) { setPendingBranch(value); return; }
1731
- store.act("checkout", function () { return store.verbs.checkout(state.cwd, value); });
1932
+ store.act("checkout", function () { return store.verbs.checkout(state.cwd, value); }, null, { branch: value });
1732
1933
  },
1733
1934
  "aria-label": t("switch.aria")
1734
1935
  }, selectChildren),
@@ -1799,7 +2000,7 @@ window.__ModuleLoader__.load({
1799
2000
  if (nameText === "" || baseText === "" || busy !== null) return;
1800
2001
  setNewBranchName("");
1801
2002
  setNewBranchOpen(false);
1802
- store.act("createBranch", function () { return store.verbs.createBranch(state.cwd, nameText, baseText); });
2003
+ store.act("createBranch", function () { return store.verbs.createBranch(state.cwd, nameText, baseText); }, null, { branch: nameText });
1803
2004
  }
1804
2005
  }
1805
2006
  if (pendingBranch !== null && state.dirty > 0) {
@@ -1817,7 +2018,7 @@ window.__ModuleLoader__.load({
1817
2018
  onClick: function () {
1818
2019
  var target = pendingBranch;
1819
2020
  setPendingBranch(null);
1820
- store.act("checkout", function () { return store.verbs.checkout(state.cwd, target); });
2021
+ store.act("checkout", function () { return store.verbs.checkout(state.cwd, target); }, null, { branch: target });
1821
2022
  }
1822
2023
  }, busy === "checkout" ? t("busy.checkout") : t("switch.force"))
1823
2024
  ]));
@@ -1935,6 +2136,7 @@ window.__ModuleLoader__.load({
1935
2136
  "no-provider": t("generate.noProvider"),
1936
2137
  "no-model": t("generate.noModel"),
1937
2138
  "llm-empty": t("generate.empty"),
2139
+ "llm-truncated": t("generate.truncated"),
1938
2140
  "cancelled": t("generate.cancelled")
1939
2141
  };
1940
2142
  var text = known[details.code];
@@ -2036,17 +2238,6 @@ window.__ModuleLoader__.load({
2036
2238
  bodyChildren.push(h("div", { key: "logBody" }, logRows));
2037
2239
  }
2038
2240
 
2039
- // last operation output
2040
- if (state.lastResult) {
2041
- bodyChildren.push(h("div", {
2042
- key: "output",
2043
- style: Object.assign({}, S.output, state.lastResult.kind === "ok" ? S.outputOk : S.outputError)
2044
- }, state.lastResult.text || t("output.ok")));
2045
- }
2046
- if (busy) {
2047
- bodyChildren.push(h("div", { key: "busy", style: S.meta }, busyLabel));
2048
- }
2049
-
2050
2241
  // Everything the open diff depends on, as one comparable string: a
2051
2242
  // commit moves the oid, a branch switch moves several of these, a
2052
2243
  // session switch changes the cwd, and STAGING moves the per-file index
@@ -2279,7 +2470,9 @@ window.__ModuleLoader__.load({
2279
2470
  parseDiffSide: parseDiffSide,
2280
2471
  diffRowView: diffRowView,
2281
2472
  GitDiffPane: GitDiffPane,
2282
- DIFF_ROW: DIFF_ROW
2473
+ DIFF_ROW: DIFF_ROW,
2474
+ stripAnsiEscapes: stripAnsiEscapes,
2475
+ outputView: outputView
2283
2476
  };
2284
2477
  return module.exports;
2285
2478
  }
package/lib/index.js CHANGED
@@ -84,8 +84,15 @@ const LLM_TIMEOUT_MS = 120000;
84
84
  const LLM_DIFF_MAX_CHARS = 12000;
85
85
  /** Diff-stat characters sent to the model. */
86
86
  const LLM_STAT_MAX_CHARS = 2000;
87
- /** Output cap for one generated commit message. */
88
- const LLM_MAX_TOKENS = 256;
87
+ /**
88
+ * Output cap for one generated commit message. A reasoning model spends this
89
+ * budget on its thinking BEFORE any text exists and reports the two together as
90
+ * `completion_tokens`, so a cap sized for the message alone truncates every
91
+ * call that thinks a little. 8192 leaves room for a long think plus a
92
+ * subject+body; the model still stops on its own well before it (an unset cap
93
+ * would fall back to the adapter's own default, which is larger still).
94
+ */
95
+ const LLM_MAX_TOKENS = 8192;
89
96
  /** Accepted `generateMessage` modes. */
90
97
  const GENERATE_MODES = ["staged", "unstaged", "all"];
91
98
  /** Default mode: the only one whose content is what `commit` will actually record. */
@@ -535,7 +542,7 @@ async function gitFetch(rawCwd, rawRemote, signal) {
535
542
  return fail("fetch-failed", cleanMessage(error), {});
536
543
  }
537
544
  const detail = [out.stdout, out.stderr].join("\n").trim();
538
- return ok({ message: detail !== "" ? detail.slice(0, 800) : "fetch complete" });
545
+ return ok({ message: detail.slice(0, 800) });
539
546
  }
540
547
 
541
548
  /**
@@ -555,7 +562,7 @@ async function gitPull(rawCwd, signal) {
555
562
  return fail("pull-failed", cleanMessage(error), {});
556
563
  }
557
564
  const detail = [out.stdout, out.stderr].join("\n").trim();
558
- return ok({ message: detail !== "" ? detail.slice(0, 800) : "pull complete" });
565
+ return ok({ message: detail.slice(0, 800) });
559
566
  }
560
567
 
561
568
  /** Read the repo's configured author (user.name / user.email) or null. */
@@ -624,7 +631,7 @@ async function gitPush(rawCwd, signal) {
624
631
  return fail("push-failed", cleanMessage(error), {});
625
632
  }
626
633
  const detail = [out.stdout, out.stderr].join("\n").trim();
627
- return ok({ message: detail !== "" ? detail.slice(0, 800) : "push complete" });
634
+ return ok({ message: detail.slice(0, 800) });
628
635
  }
629
636
 
630
637
  /**
@@ -759,7 +766,9 @@ async function gitStage(rawCwd, signal) {
759
766
  return fail("stage-failed", cleanMessage(error), {});
760
767
  }
761
768
  const detail = [out.stdout, out.stderr].join("\n").trim();
762
- return ok({ message: detail !== "" ? detail.slice(0, 800) : "all changes staged" });
769
+ // `message` is git's own output and nothing else: an operation git says
770
+ // nothing about reports an empty string, and the panel words its own summary.
771
+ return ok({ message: detail.slice(0, 800) });
763
772
  }
764
773
 
765
774
  /**
@@ -1049,7 +1058,8 @@ function generationMessage(text) {
1049
1058
  * @param route - `{ provider, model }` from {@link resolveLlmRoute}.
1050
1059
  * @param signal - cancellation (browser abort plus the server ceiling).
1051
1060
  * @returns the trimmed message.
1052
- * @throws on a terminal stream failure or empty output.
1061
+ * @throws on a terminal stream failure, an output cap hit before any text
1062
+ * existed (`llm-truncated`), or genuinely empty output (`llm-empty`).
1053
1063
  */
1054
1064
  async function requestCommitMessage(ctx, prompt, route, signal) {
1055
1065
  const options = {
@@ -1076,6 +1086,16 @@ async function requestCommitMessage(ctx, prompt, route, signal) {
1076
1086
  const ordered = (map) => [...map.entries()].sort((left, right) => left[0] - right[0]).map((entry) => entry[1]);
1077
1087
  const message = (blocks.size > 0 ? ordered(blocks) : ordered(deltas)).join("").trim();
1078
1088
  if (message === "") {
1089
+ // "Nothing came back" and "the budget ran out before any text did" are
1090
+ // different failures with different fixes; a reasoning model hits the
1091
+ // second one routinely (its thinking shares `completion_tokens` with the
1092
+ // message), so report the cap instead of a misleading generic empty.
1093
+ if (finish !== undefined && finish.kind === "max-tokens") {
1094
+ throw Object.assign(
1095
+ new Error(`the model hit the ${LLM_MAX_TOKENS}-token output cap before writing any commit message (a reasoning model spends that budget on its thinking first)`),
1096
+ { pluginCode: "llm-truncated" }
1097
+ );
1098
+ }
1079
1099
  throw Object.assign(new Error("the model returned no commit message"), { pluginCode: "llm-empty" });
1080
1100
  }
1081
1101
  return message;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xia-sc/dsh-git",
3
- "version": "0.5.2",
3
+ "version": "0.5.3",
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",
@@ -12,7 +12,8 @@
12
12
  "files": [
13
13
  "lib",
14
14
  "cordis.patch.yml",
15
- "README.en.md"
15
+ "README.en.md",
16
+ "LICENSE"
16
17
  ],
17
18
  "license": "MIT",
18
19
  "author": "xia-sc",