@chatcode/cco-better-sidebar 0.18.0-alpha.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.
Files changed (329) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +554 -0
  3. package/README_EN.md +563 -0
  4. package/assets/logo.svg +1 -0
  5. package/cordis.patch.yml +79 -0
  6. package/lib/client-editor.js +55744 -0
  7. package/lib/client-mermaid.js +208256 -0
  8. package/lib/client-registry.js +30378 -0
  9. package/lib/client-terminal.js +16894 -0
  10. package/lib/client.js +30378 -0
  11. package/lib/index.js +6897 -0
  12. package/lib/invariant.js +22 -0
  13. package/lib/types/agent-opens.d.ts +65 -0
  14. package/lib/types/agent-pty.d.ts +226 -0
  15. package/lib/types/browser-probe.d.ts +15 -0
  16. package/lib/types/bundle-route.d.ts +12 -0
  17. package/lib/types/chatcode-update.d.ts +47 -0
  18. package/lib/types/client/AccountMenu.d.ts +1 -0
  19. package/lib/types/client/AiRatioSection.d.ts +3 -0
  20. package/lib/types/client/BrowserView.d.ts +39 -0
  21. package/lib/types/client/ChatcodeGate.d.ts +6 -0
  22. package/lib/types/client/CodingPlanModelsSettings.d.ts +9 -0
  23. package/lib/types/client/DiffTab.d.ts +6 -0
  24. package/lib/types/client/DiffView.d.ts +51 -0
  25. package/lib/types/client/EditorHost.d.ts +13 -0
  26. package/lib/types/client/FenceErrorNotice.d.ts +5 -0
  27. package/lib/types/client/FileTree.d.ts +41 -0
  28. package/lib/types/client/FreeWindow.d.ts +29 -0
  29. package/lib/types/client/GitView.d.ts +12 -0
  30. package/lib/types/client/MarkdownHtml.d.ts +32 -0
  31. package/lib/types/client/OrphanedTab.d.ts +11 -0
  32. package/lib/types/client/PdfView.d.ts +6 -0
  33. package/lib/types/client/ReleaseNotesDialog.d.ts +11 -0
  34. package/lib/types/client/RenderBoundary.d.ts +32 -0
  35. package/lib/types/client/SandboxStatusBar.d.ts +10 -0
  36. package/lib/types/client/SideCardSection.d.ts +74 -0
  37. package/lib/types/client/Sidebar.d.ts +8 -0
  38. package/lib/types/client/TabBar.d.ts +54 -0
  39. package/lib/types/client/TerminalView.d.ts +24 -0
  40. package/lib/types/client/TextEditor.d.ts +10 -0
  41. package/lib/types/client/TreePanel.d.ts +27 -0
  42. package/lib/types/client/UpdateDialog.d.ts +10 -0
  43. package/lib/types/client/UpdateSettingsPanel.d.ts +1 -0
  44. package/lib/types/client/UpdateSettingsSection.d.ts +10 -0
  45. package/lib/types/client/UploadOverlay.d.ts +20 -0
  46. package/lib/types/client/UsageHeatmapSection.d.ts +4 -0
  47. package/lib/types/client/add-plugin-modal.d.ts +15 -0
  48. package/lib/types/client/api.d.ts +345 -0
  49. package/lib/types/client/binary-download.d.ts +12 -0
  50. package/lib/types/client/breakpoints.d.ts +17 -0
  51. package/lib/types/client/browser.d.ts +60 -0
  52. package/lib/types/client/builtins/index.d.ts +18 -0
  53. package/lib/types/client/builtins/tabs.d.ts +11 -0
  54. package/lib/types/client/builtins/viewers.d.ts +3 -0
  55. package/lib/types/client/chat-presentation.d.ts +13 -0
  56. package/lib/types/client/chunk-loader.d.ts +124 -0
  57. package/lib/types/client/chunks/editor.d.ts +9 -0
  58. package/lib/types/client/chunks/mermaid.d.ts +10 -0
  59. package/lib/types/client/chunks/terminal.d.ts +9 -0
  60. package/lib/types/client/cm-themes.d.ts +24 -0
  61. package/lib/types/client/conversation-draft.d.ts +14 -0
  62. package/lib/types/client/desktop-env.d.ts +35 -0
  63. package/lib/types/client/editor-load.d.ts +66 -0
  64. package/lib/types/client/frame-batcher.d.ts +24 -0
  65. package/lib/types/client/icons.d.ts +66 -0
  66. package/lib/types/client/image-types.d.ts +3 -0
  67. package/lib/types/client/ime-guard.d.ts +36 -0
  68. package/lib/types/client/index.d.ts +19 -0
  69. package/lib/types/client/intercept.d.ts +44 -0
  70. package/lib/types/client/lang.d.ts +14 -0
  71. package/lib/types/client/layout-push.d.ts +15 -0
  72. package/lib/types/client/lazy-chunk.d.ts +25 -0
  73. package/lib/types/client/link-intercept.d.ts +42 -0
  74. package/lib/types/client/locales-ar.d.ts +18 -0
  75. package/lib/types/client/locales-de.d.ts +4 -0
  76. package/lib/types/client/locales-fr.d.ts +10 -0
  77. package/lib/types/client/locales-hi.d.ts +17 -0
  78. package/lib/types/client/locales-id.d.ts +15 -0
  79. package/lib/types/client/locales-it.d.ts +8 -0
  80. package/lib/types/client/locales-ja.d.ts +17 -0
  81. package/lib/types/client/locales-ko.d.ts +9 -0
  82. package/lib/types/client/locales-nl.d.ts +16 -0
  83. package/lib/types/client/locales-pl.d.ts +19 -0
  84. package/lib/types/client/locales-pt.d.ts +1 -0
  85. package/lib/types/client/locales-ru.d.ts +13 -0
  86. package/lib/types/client/locales-sv.d.ts +1 -0
  87. package/lib/types/client/locales-th.d.ts +17 -0
  88. package/lib/types/client/locales-tr.d.ts +17 -0
  89. package/lib/types/client/locales-vi.d.ts +17 -0
  90. package/lib/types/client/locales-zh-HK.d.ts +32 -0
  91. package/lib/types/client/locales-zh-MO.d.ts +32 -0
  92. package/lib/types/client/locales-zh-TW.d.ts +32 -0
  93. package/lib/types/client/locales.d.ts +571 -0
  94. package/lib/types/client/markdown-html.d.ts +96 -0
  95. package/lib/types/client/markdown-images.d.ts +45 -0
  96. package/lib/types/client/markdown-labels.d.ts +21 -0
  97. package/lib/types/client/md-toc.d.ts +4 -0
  98. package/lib/types/client/mermaid-blocks.d.ts +47 -0
  99. package/lib/types/client/mermaid-sanitize.d.ts +2 -0
  100. package/lib/types/client/mermaid.d.ts +12 -0
  101. package/lib/types/client/open-when-sized.d.ts +20 -0
  102. package/lib/types/client/open-with-settings.d.ts +4 -0
  103. package/lib/types/client/open-with.d.ts +87 -0
  104. package/lib/types/client/openpath-intercept.d.ts +52 -0
  105. package/lib/types/client/paths.d.ts +43 -0
  106. package/lib/types/client/pdf-types.d.ts +2 -0
  107. package/lib/types/client/pinned.d.ts +86 -0
  108. package/lib/types/client/plugin-settings.d.ts +8 -0
  109. package/lib/types/client/plugins-shared.d.ts +27 -0
  110. package/lib/types/client/plugins-tabs.d.ts +3 -0
  111. package/lib/types/client/plugins-viewers.d.ts +3 -0
  112. package/lib/types/client/prefs.d.ts +41 -0
  113. package/lib/types/client/produced-files.d.ts +29 -0
  114. package/lib/types/client/selection-payload.d.ts +27 -0
  115. package/lib/types/client/service.d.ts +445 -0
  116. package/lib/types/client/session-pins.d.ts +14 -0
  117. package/lib/types/client/settings-nav-icon.d.ts +27 -0
  118. package/lib/types/client/shell-presets.d.ts +48 -0
  119. package/lib/types/client/split-pane.d.ts +38 -0
  120. package/lib/types/client/state.d.ts +480 -0
  121. package/lib/types/client/tab-content-memo.d.ts +40 -0
  122. package/lib/types/client/terminal-font.d.ts +78 -0
  123. package/lib/types/client/terminal-links.d.ts +119 -0
  124. package/lib/types/client/theme.d.ts +45 -0
  125. package/lib/types/client/titlebar-strip.d.ts +23 -0
  126. package/lib/types/client/ui-tweaks/appicons.d.ts +16 -0
  127. package/lib/types/client/ui-tweaks/archive.d.ts +42 -0
  128. package/lib/types/client/ui-tweaks/branding.d.ts +45 -0
  129. package/lib/types/client/ui-tweaks/cachehit.d.ts +41 -0
  130. package/lib/types/client/ui-tweaks/gitbar.d.ts +103 -0
  131. package/lib/types/client/ui-tweaks/index.d.ts +279 -0
  132. package/lib/types/client/ui-tweaks/mcp.d.ts +28 -0
  133. package/lib/types/client/ui-tweaks/notifier.d.ts +125 -0
  134. package/lib/types/client/ui-tweaks/whale.d.ts +55 -0
  135. package/lib/types/client/upload.d.ts +69 -0
  136. package/lib/types/client/useChatcodeAccount.d.ts +10 -0
  137. package/lib/types/client/wco.d.ts +47 -0
  138. package/lib/types/codingplan.d.ts +37 -0
  139. package/lib/types/config.d.ts +95 -0
  140. package/lib/types/context-types.d.ts +540 -0
  141. package/lib/types/fs-operations.d.ts +30 -0
  142. package/lib/types/fs-search.d.ts +24 -0
  143. package/lib/types/fs-tree.d.ts +51 -0
  144. package/lib/types/git.d.ts +124 -0
  145. package/lib/types/html-route.d.ts +59 -0
  146. package/lib/types/index.d.ts +47 -0
  147. package/lib/types/invariant.d.ts +15 -0
  148. package/lib/types/login-route.d.ts +2 -0
  149. package/lib/types/open-external.d.ts +24 -0
  150. package/lib/types/path-security.d.ts +26 -0
  151. package/lib/types/prefs-shared.d.ts +257 -0
  152. package/lib/types/pty-deps.d.ts +79 -0
  153. package/lib/types/pty-manager.d.ts +168 -0
  154. package/lib/types/tools.d.ts +34 -0
  155. package/lib/types/trust-fence.d.ts +24 -0
  156. package/lib/types/ui-tweaks/archive.d.ts +71 -0
  157. package/lib/types/ui-tweaks/config.d.ts +186 -0
  158. package/lib/types/ui-tweaks/git-web.d.ts +43 -0
  159. package/lib/types/ui-tweaks/git.d.ts +311 -0
  160. package/lib/types/ui-tweaks/index.d.ts +17 -0
  161. package/lib/types/ui-tweaks/mcp.d.ts +103 -0
  162. package/lib/types/ui-tweaks/turn-outcome.d.ts +57 -0
  163. package/lib/types/ui-tweaks/web.d.ts +55 -0
  164. package/lib/types/wire.d.ts +38 -0
  165. package/package.json +213 -0
  166. package/scripts/install.ps1 +310 -0
  167. package/scripts/install.sh +287 -0
  168. package/src/agent-opens.ts +265 -0
  169. package/src/agent-pty.ts +527 -0
  170. package/src/browser-probe.ts +26 -0
  171. package/src/bundle-route.ts +129 -0
  172. package/src/chatcode-update.ts +193 -0
  173. package/src/client/AccountMenu.tsx +177 -0
  174. package/src/client/AiRatioSection.module.css +329 -0
  175. package/src/client/AiRatioSection.tsx +292 -0
  176. package/src/client/BrowserView.tsx +289 -0
  177. package/src/client/ChatcodeGate.tsx +35 -0
  178. package/src/client/CodingPlanModelsSettings.module.css +118 -0
  179. package/src/client/CodingPlanModelsSettings.tsx +101 -0
  180. package/src/client/DiffTab.tsx +114 -0
  181. package/src/client/DiffView.tsx +305 -0
  182. package/src/client/EditorHost.tsx +563 -0
  183. package/src/client/FenceErrorNotice.tsx +40 -0
  184. package/src/client/FileTree.tsx +701 -0
  185. package/src/client/FreeWindow.tsx +337 -0
  186. package/src/client/GitView.tsx +722 -0
  187. package/src/client/MarkdownHtml.tsx +296 -0
  188. package/src/client/OrphanedTab.tsx +27 -0
  189. package/src/client/PdfView.tsx +110 -0
  190. package/src/client/ReleaseNotesDialog.tsx +91 -0
  191. package/src/client/RenderBoundary.tsx +49 -0
  192. package/src/client/SandboxStatusBar.tsx +60 -0
  193. package/src/client/SideCardSection.module.css +932 -0
  194. package/src/client/SideCardSection.tsx +1169 -0
  195. package/src/client/Sidebar.tsx +1857 -0
  196. package/src/client/TabBar.tsx +365 -0
  197. package/src/client/TerminalView.tsx +424 -0
  198. package/src/client/TextEditor.tsx +468 -0
  199. package/src/client/TreePanel.tsx +279 -0
  200. package/src/client/UpdateDialog.tsx +186 -0
  201. package/src/client/UpdateSettingsPanel.tsx +29 -0
  202. package/src/client/UpdateSettingsSection.tsx +22 -0
  203. package/src/client/UploadOverlay.tsx +62 -0
  204. package/src/client/UsageHeatmapSection.module.css +440 -0
  205. package/src/client/UsageHeatmapSection.tsx +301 -0
  206. package/src/client/add-plugin-modal.tsx +199 -0
  207. package/src/client/api.ts +433 -0
  208. package/src/client/binary-download.tsx +23 -0
  209. package/src/client/breakpoints.ts +61 -0
  210. package/src/client/browser.ts +167 -0
  211. package/src/client/builtins/index.ts +37 -0
  212. package/src/client/builtins/tabs.tsx +275 -0
  213. package/src/client/builtins/viewers.tsx +125 -0
  214. package/src/client/chat-presentation.ts +95 -0
  215. package/src/client/chunk-loader.ts +350 -0
  216. package/src/client/chunks/editor.tsx +9 -0
  217. package/src/client/chunks/mermaid.tsx +10 -0
  218. package/src/client/chunks/terminal.tsx +9 -0
  219. package/src/client/cm-themes.ts +129 -0
  220. package/src/client/conversation-draft.ts +29 -0
  221. package/src/client/css-modules.d.ts +5 -0
  222. package/src/client/desktop-env.ts +72 -0
  223. package/src/client/editor-load.ts +92 -0
  224. package/src/client/frame-batcher.ts +56 -0
  225. package/src/client/icons.tsx +170 -0
  226. package/src/client/image-types.ts +8 -0
  227. package/src/client/ime-guard.ts +47 -0
  228. package/src/client/index.tsx +501 -0
  229. package/src/client/intercept.tsx +172 -0
  230. package/src/client/lang.ts +225 -0
  231. package/src/client/layout-push.ts +37 -0
  232. package/src/client/layout.css +156 -0
  233. package/src/client/lazy-chunk.tsx +89 -0
  234. package/src/client/link-intercept.ts +73 -0
  235. package/src/client/locales-ar.ts +387 -0
  236. package/src/client/locales-de.ts +372 -0
  237. package/src/client/locales-fr.ts +379 -0
  238. package/src/client/locales-hi.ts +386 -0
  239. package/src/client/locales-id.ts +384 -0
  240. package/src/client/locales-it.ts +377 -0
  241. package/src/client/locales-ja.ts +486 -0
  242. package/src/client/locales-ko.ts +378 -0
  243. package/src/client/locales-nl.ts +384 -0
  244. package/src/client/locales-pl.ts +388 -0
  245. package/src/client/locales-pt.ts +369 -0
  246. package/src/client/locales-ru.ts +384 -0
  247. package/src/client/locales-sv.ts +369 -0
  248. package/src/client/locales-th.ts +386 -0
  249. package/src/client/locales-tr.ts +386 -0
  250. package/src/client/locales-vi.ts +386 -0
  251. package/src/client/locales-zh-HK.ts +401 -0
  252. package/src/client/locales-zh-MO.ts +401 -0
  253. package/src/client/locales-zh-TW.ts +401 -0
  254. package/src/client/locales.ts +1229 -0
  255. package/src/client/markdown-html.ts +331 -0
  256. package/src/client/markdown-images.ts +137 -0
  257. package/src/client/markdown-labels.tsx +31 -0
  258. package/src/client/md-toc.tsx +127 -0
  259. package/src/client/mermaid-blocks.ts +110 -0
  260. package/src/client/mermaid-sanitize.ts +93 -0
  261. package/src/client/mermaid.tsx +406 -0
  262. package/src/client/open-when-sized.ts +43 -0
  263. package/src/client/open-with-settings.tsx +135 -0
  264. package/src/client/open-with.ts +221 -0
  265. package/src/client/openpath-intercept.ts +79 -0
  266. package/src/client/paths.ts +63 -0
  267. package/src/client/pdf-types.ts +4 -0
  268. package/src/client/pinned.ts +184 -0
  269. package/src/client/plugin-settings.ts +43 -0
  270. package/src/client/plugins-shared.ts +29 -0
  271. package/src/client/plugins-tabs.ts +153 -0
  272. package/src/client/plugins-viewers.ts +44 -0
  273. package/src/client/prefs.ts +216 -0
  274. package/src/client/produced-files.ts +120 -0
  275. package/src/client/selection-payload.ts +86 -0
  276. package/src/client/service.ts +873 -0
  277. package/src/client/session-pins.tsx +334 -0
  278. package/src/client/settings-nav-icon.ts +55 -0
  279. package/src/client/shell-presets.ts +83 -0
  280. package/src/client/sidebar.module.css +3607 -0
  281. package/src/client/split-pane.tsx +333 -0
  282. package/src/client/state.ts +1646 -0
  283. package/src/client/tab-content-memo.ts +55 -0
  284. package/src/client/terminal-font.ts +224 -0
  285. package/src/client/terminal-links.ts +194 -0
  286. package/src/client/theme.ts +110 -0
  287. package/src/client/titlebar-strip.ts +37 -0
  288. package/src/client/ui-tweaks/appicons.ts +24 -0
  289. package/src/client/ui-tweaks/archive.tsx +281 -0
  290. package/src/client/ui-tweaks/branding.tsx +232 -0
  291. package/src/client/ui-tweaks/cachehit.tsx +122 -0
  292. package/src/client/ui-tweaks/gitbar.tsx +2559 -0
  293. package/src/client/ui-tweaks/index.tsx +1696 -0
  294. package/src/client/ui-tweaks/mcp.tsx +582 -0
  295. package/src/client/ui-tweaks/notifier.ts +514 -0
  296. package/src/client/ui-tweaks/whale.tsx +171 -0
  297. package/src/client/upload.ts +187 -0
  298. package/src/client/useChatcodeAccount.ts +103 -0
  299. package/src/client/wco.ts +120 -0
  300. package/src/codingplan.ts +146 -0
  301. package/src/config.ts +207 -0
  302. package/src/context-types.ts +559 -0
  303. package/src/fs-operations.ts +97 -0
  304. package/src/fs-search.ts +113 -0
  305. package/src/fs-tree.ts +158 -0
  306. package/src/git.ts +446 -0
  307. package/src/html-route.ts +106 -0
  308. package/src/index.ts +1524 -0
  309. package/src/invariant.ts +32 -0
  310. package/src/login-route.ts +26 -0
  311. package/src/open-external.ts +90 -0
  312. package/src/path-security.ts +81 -0
  313. package/src/prefs-shared.ts +303 -0
  314. package/src/pty-deps.ts +241 -0
  315. package/src/pty-manager.ts +376 -0
  316. package/src/tools.ts +482 -0
  317. package/src/trust-fence.ts +84 -0
  318. package/src/ui-tweaks/archive.ts +293 -0
  319. package/src/ui-tweaks/config.ts +257 -0
  320. package/src/ui-tweaks/git-web.ts +421 -0
  321. package/src/ui-tweaks/git.ts +1524 -0
  322. package/src/ui-tweaks/index.ts +61 -0
  323. package/src/ui-tweaks/mcp.ts +535 -0
  324. package/src/ui-tweaks/turn-outcome.ts +105 -0
  325. package/src/ui-tweaks/web.ts +225 -0
  326. package/src/wire.ts +110 -0
  327. package/vendor/addon-fit.js +2 -0
  328. package/vendor/xterm.css +218 -0
  329. package/vendor/xterm.js +5 -0
@@ -0,0 +1,1524 @@
1
+ /**
2
+ * dsh-ui-tweaks — Git backend (host half).
3
+ *
4
+ * Executes read-only and mutating git commands in a session's working
5
+ * directory (the "current project", resolved from the session header's cwd)
6
+ * for the GitBar: status snapshot (branch, ahead/behind, changed files with
7
+ * ±line counts), branch enumeration, per-file diff views, commit-message
8
+ * suggestion (LLM first, heuristic fallback), and the commit / push /
9
+ * switch / create actions.
10
+ *
11
+ * Every command runs through `child_process.execFile` — never a shell, args
12
+ * as an argv array, cwd pinned, bounded timeout, abort propagation — so
13
+ * repo-controlled strings cannot escape into a shell and a hung remote cannot
14
+ * wedge the plugin.
15
+ *
16
+ * The terminal panel additionally runs a PERSISTENT REAL PTY per target
17
+ * (node-pty — conpty on Windows), streamed to the browser over a WebSocket
18
+ * and rendered with xterm.js: shell state, colors, Ctrl+C and interactive
19
+ * apps work exactly like the DSH-better-sidebar terminal, whose pty-manager
20
+ * design (transcript ring replay + reconnect grace) this follows.
21
+ * @module dsh-ui-tweaks/git
22
+ */
23
+
24
+ import { execFile, spawn } from 'node:child_process'
25
+ import { existsSync, readFileSync, statSync } from 'node:fs'
26
+ import { open, readFile, stat } from 'node:fs/promises'
27
+ import { createRequire } from 'node:module'
28
+ import { resolve, sep } from 'node:path'
29
+ import type { Context } from '@deepseek-ai/cordis'
30
+ import type { SettingsNamespace } from '@deepseek-ai/dsh-settings'
31
+ import type { UITweaksConfig } from './config.ts'
32
+
33
+ /** One changed file with its ±line counts. */
34
+ export interface GitFileChange {
35
+ path: string
36
+ /** Display status: M / A / D / R / C / U (untracked). */
37
+ status: string
38
+ added: number
39
+ deleted: number
40
+ untracked: boolean
41
+ }
42
+
43
+ /** Full status snapshot for one session cwd. */
44
+ export interface GitSnapshot {
45
+ isRepo: boolean
46
+ /** Absolute working directory, when resolvable. */
47
+ cwd?: string
48
+ /** Current branch name, or null when detached. */
49
+ branch: string | null
50
+ /** Short HEAD sha while detached. */
51
+ detachedHead?: string
52
+ /** Upstream ref name (e.g. `origin/main`), when tracked. */
53
+ upstream?: string
54
+ /** Commits ahead of the upstream. */
55
+ ahead: number
56
+ /** Commits behind the upstream. */
57
+ behind: number
58
+ hasRemote: boolean
59
+ clean: boolean
60
+ files: GitFileChange[]
61
+ totalAdded: number
62
+ totalDeleted: number
63
+ }
64
+
65
+ /** Local + remote branch enumeration. */
66
+ export interface GitBranches {
67
+ current: string | null
68
+ local: string[]
69
+ remote: string[]
70
+ }
71
+
72
+ /** One commit row of the graph table. */
73
+ export interface GitGraphCommit {
74
+ /** ASCII graph edge prefix (monospace drawing column). */
75
+ graph: string
76
+ /** Full commit hash. */
77
+ fullHash: string
78
+ /** Short hash (7 chars). */
79
+ hash: string
80
+ /** Subject line. */
81
+ subject: string
82
+ /** Author name. */
83
+ author: string
84
+ /** ISO-8601 commit date. */
85
+ date: string
86
+ /** Relative date, e.g. "3 days ago". */
87
+ dateRelative: string
88
+ /** Ref decorations, e.g. `HEAD -> main, origin/main` (outer parens stripped). */
89
+ refs: string
90
+ }
91
+
92
+ /** Git commit graph table (`git log --graph`, structured rows). */
93
+ export interface GitGraph {
94
+ commits: GitGraphCommit[]
95
+ /** Whether the requested commit window was cut off by the limit. */
96
+ truncated: boolean
97
+ }
98
+
99
+ /** One rendered line of a per-file diff view. */
100
+ export interface DiffLine {
101
+ type: 'hunk' | 'add' | 'del' | 'ctx'
102
+ /** Old-file line number (null for added lines). */
103
+ old: number | null
104
+ /** New-file line number (null for deleted lines). */
105
+ new: number | null
106
+ /** Content after the +/-/space prefix; hunk lines carry the full `@@` header. */
107
+ text: string
108
+ }
109
+
110
+ /** Structured per-file diff for the GitBar side panel. */
111
+ export interface GitDiffResult {
112
+ path: string
113
+ /** `hunk` = changed ranges only; `full` = whole file with added lines marked. */
114
+ mode: 'hunk' | 'full'
115
+ lines: DiffLine[]
116
+ truncated: boolean
117
+ }
118
+
119
+ /** Empty snapshot for non-repo / unresolvable sessions. */
120
+ const NO_REPO: GitSnapshot = {
121
+ isRepo: false,
122
+ branch: null,
123
+ ahead: 0,
124
+ behind: 0,
125
+ hasRemote: false,
126
+ clean: true,
127
+ files: [],
128
+ totalAdded: 0,
129
+ totalDeleted: 0,
130
+ }
131
+
132
+ /** Reasonable ceilings: line-count reads and full-file views stay bounded. */
133
+ const MAX_COUNT_FILE_BYTES = 512 * 1024
134
+ const MAX_DIFF_FILE_BYTES = 1024 * 1024
135
+ const MAX_FULL_LINES = 20000
136
+ /** Diff panel: upper bound on rendered hunk lines (a huge pnpm-lock.yaml diff
137
+ * can exceed 3000 lines, which freezes the GUI). */
138
+ const MAX_DIFF_ROWS = 500
139
+
140
+ const GIT_TIMEOUT_MS = 10000
141
+ const LLM_TIMEOUT_MS = 20000
142
+
143
+ /**
144
+ * Settings namespace of dsh-agent-default-model — the model the user picked
145
+ * for this DSH's Agents. Read directly from settings (not only through the
146
+ * service) so the selection works even when the service is out of the
147
+ * plugin context's reach.
148
+ */
149
+ const AGENT_DEFAULT_MODEL_NAMESPACE = 'agent-default-model' as SettingsNamespace
150
+
151
+ /**
152
+ * Budgets for the diff excerpt handed to the model when suggesting a commit
153
+ * message. A file list alone cannot describe intent, so the real patch text
154
+ * goes into the prompt — bounded, because the model context and the request
155
+ * latency both pay for it.
156
+ */
157
+ const MAX_SUGGEST_DIFF_CHARS = 12000
158
+ /** Per-file share, so one huge file cannot crowd every other file out. */
159
+ const MAX_SUGGEST_FILE_CHARS = 2600
160
+ /** New (untracked) files are described by their first lines only. */
161
+ const MAX_SUGGEST_NEW_FILE_LINES = 40
162
+ /** How many files contribute content at all (the list still shows the rest). */
163
+ const MAX_SUGGEST_FILES = 24
164
+
165
+ interface RunResult {
166
+ stdout: string
167
+ stderr: string
168
+ }
169
+
170
+ interface RunOpts {
171
+ signal?: AbortSignal | undefined
172
+ timeoutMs?: number | undefined
173
+ }
174
+
175
+ /** Run one git command with cwd pinned, no shell, bounded timeout, abort propagation. */
176
+ function runGit(
177
+ cwd: string,
178
+ args: readonly string[],
179
+ opts: RunOpts = {},
180
+ ): Promise<RunResult> {
181
+ return new Promise((resolvePromise, reject) => {
182
+ const controller = new AbortController()
183
+ const timer = setTimeout(() => controller.abort(), opts.timeoutMs ?? GIT_TIMEOUT_MS)
184
+ const onOuterAbort = (): void => { controller.abort() }
185
+ opts.signal?.addEventListener('abort', onOuterAbort, { once: true })
186
+ execFile('git', [...args], {
187
+ cwd,
188
+ encoding: 'utf8',
189
+ windowsHide: true,
190
+ signal: controller.signal,
191
+ maxBuffer: 32 * 1024 * 1024,
192
+ }, (error, stdout, stderr) => {
193
+ clearTimeout(timer)
194
+ opts.signal?.removeEventListener('abort', onOuterAbort)
195
+ if (error !== null) {
196
+ reject(Object.assign(new Error(error.message), {
197
+ cause: error,
198
+ code: (error as NodeJS.ErrnoException).code,
199
+ stdout,
200
+ stderr,
201
+ aborted: controller.signal.aborted,
202
+ }))
203
+ return
204
+ }
205
+ resolvePromise({ stdout, stderr })
206
+ })
207
+ })
208
+ }
209
+
210
+ /** Parse `git status --porcelain=v1 -z --branch` output into XY entries with
211
+ * paths. The leading `## ` branch-header record (present because of
212
+ * `--branch`) is skipped here; `parseStatusHeader` decodes it. */
213
+ function parseStatusPorcelainZ(stdout: string): Array<{ index: string; worktree: string; path: string }> {
214
+ const out: Array<{ index: string; worktree: string; path: string }> = []
215
+ const parts = stdout.split('\0')
216
+ for (let i = 0; i < parts.length; i++) {
217
+ const part = parts[i]
218
+ if (part === undefined || part.length < 3) continue
219
+ if (part.startsWith('## ')) continue
220
+ const xy = part.slice(0, 2)
221
+ let path = part.slice(3)
222
+ const index = xy.charAt(0)
223
+ const worktree = xy.charAt(1)
224
+ // Rename/copy entries list the source path then the destination path.
225
+ if (index === 'R' || index === 'C') {
226
+ const next = parts[i + 1]
227
+ if (next !== undefined && next !== '') {
228
+ path = next
229
+ i++
230
+ }
231
+ }
232
+ if (path !== '') out.push({ index, worktree, path })
233
+ }
234
+ return out
235
+ }
236
+
237
+ /** Branch/upstream/tracking info carried by the `## ` record of
238
+ * `git status --porcelain -z --branch` — replaces three separate git calls
239
+ * (branch --show-current, rev-parse @{upstream}, rev-list --count). */
240
+ interface StatusHeader {
241
+ branch: string | null
242
+ detached: boolean
243
+ upstream?: string
244
+ ahead: number
245
+ behind: number
246
+ }
247
+
248
+ function parseStatusHeader(stdout: string): StatusHeader {
249
+ const first = stdout.split('\0', 1)[0] ?? ''
250
+ if (!first.startsWith('## ')) return { branch: null, detached: false, ahead: 0, behind: 0 }
251
+ const header = first.slice(3)
252
+ // Fresh repo before the first commit.
253
+ const noCommits = 'No commits yet on '
254
+ if (header.startsWith(noCommits)) {
255
+ return { branch: header.slice(noCommits.length), detached: false, ahead: 0, behind: 0 }
256
+ }
257
+ // Detached HEAD — the caller resolves the short sha separately.
258
+ if (header.startsWith('HEAD (no branch)')) return { branch: null, detached: true, ahead: 0, behind: 0 }
259
+ // `main...origin/main [ahead 1, behind 2]` — the bracket is optional and so
260
+ // is the `...upstream` half.
261
+ const bracketStart = header.indexOf(' [')
262
+ const refs = bracketStart >= 0 ? header.slice(0, bracketStart) : header
263
+ const tracking = bracketStart >= 0 ? header.slice(bracketStart + 2, Math.max(bracketStart + 2, header.length - 1)) : ''
264
+ const refParts = refs.split('...')
265
+ const local = refParts[0] ?? ''
266
+ const upstreamRef = refParts[1]
267
+ const out: StatusHeader = { branch: local !== '' ? local : null, detached: false, ahead: 0, behind: 0 }
268
+ if (upstreamRef !== undefined && upstreamRef !== '') out.upstream = upstreamRef
269
+ for (const piece of tracking.split(',')) {
270
+ const match = piece.trim().match(/^(ahead|behind)\s+(\d+)$/)
271
+ if (match === null) continue
272
+ if (match[1] === 'ahead') out.ahead = Number(match[2]) || 0
273
+ else out.behind = Number(match[2]) || 0
274
+ }
275
+ return out
276
+ }
277
+
278
+ /** Parse `git diff --numstat` (non-z) into path → { added, deleted }. */
279
+ function parseNumstat(stdout: string): Map<string, { added: number; deleted: number }> {
280
+ const out = new Map<string, { added: number; deleted: number }>()
281
+ for (const line of stdout.split('\n')) {
282
+ if (line === '') continue
283
+ const tab1 = line.indexOf('\t')
284
+ if (tab1 < 0) continue
285
+ const tab2 = line.indexOf('\t', tab1 + 1)
286
+ if (tab2 < 0) continue
287
+ const addedRaw = line.slice(0, tab1)
288
+ const deletedRaw = line.slice(tab1 + 1, tab2)
289
+ let path = line.slice(tab2 + 1)
290
+ // Rename records carry `old => new`.
291
+ if (path.includes(' => ')) path = path.split(' => ')[1]?.trim() ?? path
292
+ if (path === '') continue
293
+ // Binary files report `-` in both numeric fields.
294
+ const added = addedRaw === '-' ? 0 : Number(addedRaw) || 0
295
+ const deleted = deletedRaw === '-' ? 0 : Number(deletedRaw) || 0
296
+ out.set(path, { added, deleted })
297
+ }
298
+ return out
299
+ }
300
+
301
+ /** Merge numstat records into one map (later records win per path). */
302
+ function mergeNumstat(map: Map<string, { added: number; deleted: number }>, stdout: string): void {
303
+ for (const [path, value] of parseNumstat(stdout)) map.set(path, value)
304
+ }
305
+
306
+ /** Resolve a repo-relative path safely: must stay inside the cwd. */
307
+ function resolveWithin(cwd: string, path: string): string {
308
+ const full = resolve(cwd, path)
309
+ if (full !== cwd && !full.startsWith(cwd + sep)) {
310
+ throw new Error(`path escapes the working directory: ${path}`)
311
+ }
312
+ return full
313
+ }
314
+
315
+ /** Count newline-delimited lines of a small text file; 0 for binary/oversized.
316
+ * Async (fs/promises) so a batch of untracked files runs concurrently instead
317
+ * of blocking the plugin's event loop with one sync read per file. */
318
+ async function countFileLines(cwd: string, path: string): Promise<number> {
319
+ try {
320
+ const full = resolveWithin(cwd, path)
321
+ const info = await stat(full)
322
+ if (!info.isFile() || info.size > MAX_COUNT_FILE_BYTES) return 0
323
+ const buf = await readFile(full)
324
+ if (buf.includes(0)) return 0
325
+ let lines = 0
326
+ for (let i = 0; i < buf.length; i++) if (buf[i] === 10) lines++
327
+ if (buf.length > 0 && buf[buf.length - 1] !== 10) lines++
328
+ return lines
329
+ } catch {
330
+ return 0
331
+ }
332
+ }
333
+
334
+ /** Read a text file (or a deleted file's HEAD content) with a size cap. */
335
+ function readFileContent(cwd: string, path: string, signal?: AbortSignal): Promise<{ content: string; truncated: boolean }> {
336
+ return (async () => {
337
+ const full = resolveWithin(cwd, path)
338
+ if (existsSync(full)) {
339
+ const stat = statSync(full)
340
+ if (stat.size > MAX_DIFF_FILE_BYTES) {
341
+ // Read the head of the file only; mark truncated.
342
+ const fd = await open(full, 'r')
343
+ try {
344
+ const buf = Buffer.alloc(MAX_DIFF_FILE_BYTES)
345
+ const { bytesRead } = await fd.read(buf, 0, MAX_DIFF_FILE_BYTES, 0)
346
+ return { content: buf.subarray(0, bytesRead).toString('utf8'), truncated: stat.size > MAX_DIFF_FILE_BYTES }
347
+ } finally {
348
+ await fd.close()
349
+ }
350
+ }
351
+ return { content: readFileSync(full, 'utf8'), truncated: false }
352
+ }
353
+ // Deleted (or renamed-away) file: read from HEAD.
354
+ const { stdout } = await runGit(cwd, ['show', `HEAD:${path}`], { signal })
355
+ const truncated = Buffer.byteLength(stdout) > MAX_DIFF_FILE_BYTES
356
+ return { content: truncated ? stdout.slice(0, MAX_DIFF_FILE_BYTES) : stdout, truncated }
357
+ })()
358
+ }
359
+
360
+ /** Parse a unified diff stream into typed lines with line numbers. */
361
+ function parseUnifiedDiff(text: string): DiffLine[] {
362
+ const out: DiffLine[] = []
363
+ let oldN = 0
364
+ let newN = 0
365
+ for (const raw of text.split('\n')) {
366
+ if (raw === '') continue
367
+ if (raw.startsWith('@@')) {
368
+ out.push({ type: 'hunk', old: null, new: null, text: raw })
369
+ // Seed the counters from the hunk header: `@@ -oldStart,oldCount +newStart,newCount @@`.
370
+ const match = raw.match(/^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/)
371
+ oldN = match !== null ? Number(match[1]) - 1 : 0
372
+ newN = match !== null ? Number(match[2]) - 1 : 0
373
+ continue
374
+ }
375
+ if (raw.startsWith('diff --git') || raw.startsWith('index ') || raw.startsWith('--- ') || raw.startsWith('+++ ') || raw.startsWith('\\ No newline')) {
376
+ continue
377
+ }
378
+ if (raw.startsWith('+')) {
379
+ newN++
380
+ out.push({ type: 'add', old: null, new: newN, text: raw.slice(1) })
381
+ continue
382
+ }
383
+ if (raw.startsWith('-')) {
384
+ oldN++
385
+ out.push({ type: 'del', old: oldN, new: null, text: raw.slice(1) })
386
+ continue
387
+ }
388
+ oldN++
389
+ newN++
390
+ out.push({ type: 'ctx', old: oldN, new: newN, text: raw.slice(1) })
391
+ }
392
+ return out
393
+ }
394
+
395
+ /** Collect the new-file line numbers that are additions, from a unified diff. */
396
+ function addedNewLineNumbers(diff: DiffLine[]): Set<number> {
397
+ const out = new Set<number>()
398
+ for (const line of diff) {
399
+ if (line.type === 'add' && line.new !== null) out.add(line.new)
400
+ }
401
+ return out
402
+ }
403
+
404
+ /** Conventional-commit style type inference from the change profile. */
405
+ function inferType(files: GitFileChange[], added: number, deleted: number): string {
406
+ const count = files.length
407
+ if (count === 0) return 'chore'
408
+ const isDocs = (f: GitFileChange): boolean => /\.(md|mdx|rst|txt)$/i.test(f.path) || /(^|\/)docs?(\/|$)/.test(f.path)
409
+ const isTest = (f: GitFileChange): boolean => /\.(test|spec)\./i.test(f.path) || /(^|\/)(tests?|__tests__|__specs__)(\/|$)/.test(f.path)
410
+ const isConfig = (f: GitFileChange): boolean => /(^|\/)(package\.json|pnpm-lock\.ya?ml|pnpm-workspace\.ya?ml|tsconfig[^/]*\.json|.*\.config\.(js|ts|mjs|cjs)|\.github\/|\.gitignore|cordis\.patch\.ya?ml|.*\.patch\.ya?ml)$/i.test(f.path)
411
+ const docs = files.filter(isDocs)
412
+ const tests = files.filter(isTest)
413
+ const configs = files.filter(isConfig)
414
+ if (docs.length > 0 && docs.length / count >= 0.5) return 'docs'
415
+ if (tests.length > 0 && tests.length / count >= 0.5) return 'test'
416
+ if (configs.length > 0 && configs.length / count >= 0.5) return 'chore'
417
+ const newFiles = files.filter(f => f.status === 'A' || f.untracked)
418
+ if (newFiles.length > 0 && added >= deleted) return 'feat'
419
+ if (deleted > added && added > 0) return 'fix'
420
+ if (added > deleted) return 'feat'
421
+ if (added > 0) return 'refactor'
422
+ return 'chore'
423
+ }
424
+
425
+ /** Derive a scope from the shared top-level directory of the changed files. */
426
+ function inferScope(files: GitFileChange[]): string | undefined {
427
+ const counts = new Map<string, number>()
428
+ for (const f of files) {
429
+ const idx = f.path.indexOf('/')
430
+ if (idx > 0) {
431
+ const top = f.path.slice(0, idx)
432
+ if (!top.startsWith('.')) counts.set(top, (counts.get(top) ?? 0) + 1)
433
+ }
434
+ }
435
+ let best: string | undefined
436
+ let bestCount = 0
437
+ for (const [top, n] of counts) {
438
+ if (n > bestCount) {
439
+ best = top
440
+ bestCount = n
441
+ }
442
+ }
443
+ return best
444
+ }
445
+
446
+ /** Shorten a summary while keeping it human-readable. */
447
+ function shorten(value: string, max: number): string {
448
+ return value.length <= max ? value : `${value.slice(0, max - 1)}…`
449
+ }
450
+
451
+ /** Deterministic offline commit-message generation (LLM fallback). */
452
+ export function heuristicSuggest(snapshot: GitSnapshot): string {
453
+ const files = snapshot.files
454
+ if (files.length === 0) return 'chore: no changes'
455
+ const type = inferType(files, snapshot.totalAdded, snapshot.totalDeleted)
456
+ const scope = inferScope(files)
457
+ const top = [...files].sort((a, b) => (b.added + b.deleted) - (a.added + a.deleted))
458
+ const names = top.slice(0, 2).map(f => {
459
+ const base = f.path.split('/').pop() ?? f.path
460
+ return base.replace(/\.[^.]+$/, '')
461
+ }).filter(Boolean)
462
+ const summary = shorten(names.join(' & '), 58)
463
+ return scope !== undefined ? `${type}(${scope}): ${summary}` : `${type}: ${summary}`
464
+ }
465
+
466
+ /** Structural view of the LLM service — duck-typed so dsh-llm is optional. */
467
+ interface LlmLike {
468
+ listProviders(): Array<{ id: string; name: string }>
469
+ listModels(provider: string): Promise<Array<{ id: string; name: string }>>
470
+ stream(options: {
471
+ provider: string
472
+ model: string
473
+ messages: Array<{ role: string; content: Array<{ type: string; text: string }>; id?: string; source?: unknown }>
474
+ system?: string
475
+ temperature?: number
476
+ maxTokens?: number
477
+ signal?: AbortSignal
478
+ }): AsyncIterable<{ type: string; text?: string; reason?: unknown }>
479
+ }
480
+
481
+ /**
482
+ * Structural view of the default-model service (dsh-agent-default-model), so
483
+ * a suggestion uses the model the user already chose for this DSH instead of
484
+ * whichever provider happened to register first.
485
+ */
486
+ interface DefaultModelLike {
487
+ currentSelection(): { provider?: string; model?: string }
488
+ }
489
+
490
+ /** How a commit message was produced, and why the LLM path was skipped. */
491
+ export interface GitSuggestResult {
492
+ message: string
493
+ /** `llm` = a model wrote it; `heuristic` = offline fallback from file names. */
494
+ via: 'llm' | 'heuristic'
495
+ /** Provider/model actually used (or attempted). */
496
+ provider?: string
497
+ model?: string
498
+ /** Why the LLM path did not produce a message (only when `via` is heuristic). */
499
+ reason?: string
500
+ /** Whether the diff excerpt handed to the model was cut short. */
501
+ truncated?: boolean
502
+ }
503
+
504
+ /** Error text for a diagnostic, without leaking a stack trace. */
505
+ function messageOf(error: unknown): string {
506
+ return error instanceof Error ? error.message : String(error)
507
+ }
508
+
509
+ /** Render a stream `finish` reason as a short diagnostic string. */
510
+ function finishReasonText(reason: unknown): string {
511
+ if (typeof reason === 'string') return reason
512
+ if (typeof reason !== 'object' || reason === null) return 'unknown'
513
+ const record = reason as Record<string, unknown>
514
+ const kind = typeof record.kind === 'string' ? record.kind : 'unknown'
515
+ const failure = record.failure
516
+ if (typeof failure === 'object' && failure !== null) {
517
+ const inner = failure as Record<string, unknown>
518
+ const code = typeof inner.code === 'string' ? inner.code : undefined
519
+ const message = typeof inner.message === 'string' ? inner.message : undefined
520
+ const detail = [code, message].filter(Boolean).join(': ')
521
+ if (detail !== '') return `${kind} (${detail})`
522
+ }
523
+ return kind
524
+ }
525
+
526
+ /** Keep the head of a per-file patch, marking the cut. */
527
+ function clampPatch(patch: string, max: number): { text: string; truncated: boolean } {
528
+ if (patch.length <= max) return { text: patch, truncated: false }
529
+ return { text: `${patch.slice(0, max)}\n… [patch truncated]\n`, truncated: true }
530
+ }
531
+
532
+ /**
533
+ * Collect the actual patch text for the pending changes, bounded by
534
+ * {@link MAX_SUGGEST_DIFF_CHARS}. Tracked changes come from one
535
+ * `git diff HEAD` (staged and unstaged together, renames detected); untracked
536
+ * files have no HEAD side, so their first lines are quoted instead.
537
+ */
538
+ async function collectSuggestDiff(
539
+ cwd: string,
540
+ snapshot: GitSnapshot,
541
+ signal?: AbortSignal,
542
+ ): Promise<{ text: string; truncated: boolean }> {
543
+ const args = ['diff', '--no-ext-diff', '--no-color', '--unified=3', '-M']
544
+ let raw = ''
545
+ try {
546
+ // `HEAD` covers staged + unstaged in one pass; it fails before the first
547
+ // commit, where `--cached` is the only side that exists.
548
+ raw = (await runGit(cwd, [...args, 'HEAD'], { signal })).stdout
549
+ } catch {
550
+ try {
551
+ raw = (await runGit(cwd, [...args, '--cached'], { signal })).stdout
552
+ } catch {
553
+ raw = ''
554
+ }
555
+ }
556
+
557
+ let budget = MAX_SUGGEST_DIFF_CHARS
558
+ let truncated = false
559
+ const chunks: string[] = []
560
+ if (raw !== '') {
561
+ // Split on file boundaries so the budget is spent across files, not on the
562
+ // first enormous one.
563
+ const parts = raw.split(/^(?=diff --git )/m).filter(part => part.trim() !== '')
564
+ let files = 0
565
+ for (const part of parts) {
566
+ if (budget <= 0 || files >= MAX_SUGGEST_FILES) {
567
+ truncated = true
568
+ break
569
+ }
570
+ const clamped = clampPatch(part, Math.min(MAX_SUGGEST_FILE_CHARS, budget))
571
+ if (clamped.truncated) truncated = true
572
+ chunks.push(clamped.text)
573
+ budget -= clamped.text.length
574
+ files++
575
+ }
576
+ }
577
+
578
+ for (const file of snapshot.files) {
579
+ if (!file.untracked) continue
580
+ if (budget <= 0) {
581
+ truncated = true
582
+ break
583
+ }
584
+ let head = ''
585
+ try {
586
+ const { content } = await readFileContent(cwd, file.path, signal)
587
+ const lines = content.split('\n')
588
+ const kept = lines.slice(0, MAX_SUGGEST_NEW_FILE_LINES)
589
+ if (lines.length > kept.length) truncated = true
590
+ head = kept.map(line => `+${line}`).join('\n')
591
+ } catch {
592
+ // Binary or unreadable new file: the entry alone still informs the model.
593
+ head = '+[unreadable or binary content]'
594
+ }
595
+ const clamped = clampPatch(`new file: ${file.path}\n${head}\n`, Math.min(MAX_SUGGEST_FILE_CHARS, budget))
596
+ if (clamped.truncated) truncated = true
597
+ chunks.push(clamped.text)
598
+ budget -= clamped.text.length
599
+ }
600
+
601
+ return { text: chunks.join('\n'), truncated }
602
+ }
603
+
604
+ /**
605
+ * Model-facing description of the pending changes: the file list for shape,
606
+ * the patch text for intent. Without the patch a model can only paraphrase
607
+ * file names, which is what made earlier suggestions inaccurate.
608
+ */
609
+ function buildSuggestPrompt(snapshot: GitSnapshot, diff: { text: string; truncated: boolean }): string {
610
+ const lines: string[] = []
611
+ lines.push(`Branch: ${snapshot.branch ?? 'detached'}`)
612
+ lines.push(`Changed files (${snapshot.files.length}, +${snapshot.totalAdded} -${snapshot.totalDeleted}):`)
613
+ for (const f of snapshot.files.slice(0, 40)) {
614
+ lines.push(`- [${f.status}] ${f.path} (+${f.added} -${f.deleted})`)
615
+ }
616
+ if (snapshot.files.length > 40) lines.push(`- … and ${snapshot.files.length - 40} more`)
617
+ if (diff.text !== '') {
618
+ lines.push('')
619
+ lines.push(diff.truncated ? 'Patch (truncated):' : 'Patch:')
620
+ lines.push(diff.text)
621
+ }
622
+ lines.push('')
623
+ lines.push('Write one conventional commit message for these changes: type(scope): summary.')
624
+ lines.push('Describe what the change does, judged from the patch — never just restate file names.')
625
+ lines.push('Rules: one line, under 72 characters, imperative mood, lowercase summary, no trailing period.')
626
+ lines.push('Reply with the message only.')
627
+ return lines.join('\n')
628
+ }
629
+
630
+ /**
631
+ * The Git service: resolves a session's cwd and runs git on its behalf.
632
+ * @param ctx - plugin context (sessions and llm are read structurally, never required).
633
+ * @param readConfig - reads the plugin config for the `suggestModel` override.
634
+ */
635
+ export class GitBackend {
636
+ /** Persistent PTY shell sessions for the terminal panel (WebSocket-streamed). */
637
+ readonly terminals: TerminalSessionManager
638
+
639
+ constructor(
640
+ private readonly ctx: Context,
641
+ private readonly readConfig: () => UITweaksConfig,
642
+ ) {
643
+ this.terminals = new TerminalSessionManager()
644
+ }
645
+
646
+ /** Resolve the session's working directory, or undefined when absent. */
647
+ resolveCwd(sessionId: string | undefined): string | undefined {
648
+ if (sessionId !== undefined) {
649
+ // Optional-service read: sessions is absent in headless assemblies.
650
+ const sessions = this.ctx.get('sessions') as { get(id: string): { header?: { cwd?: string } } | undefined } | undefined
651
+ const session = sessions?.get(sessionId)
652
+ const cwd = session?.header?.cwd
653
+ if (typeof cwd === 'string' && cwd !== '' && existsSync(cwd)) return cwd
654
+ }
655
+ return undefined
656
+ }
657
+
658
+ /**
659
+ * Resolve a workspace title (the hero/new-session screen has no real session
660
+ * yet, only the picked workspace) to its directory. Matches the display
661
+ * title exactly first, then a basename suffix — titles may duplicate, so an
662
+ * exact hit always wins. Optional-service read like resolveCwd.
663
+ */
664
+ resolveWorkspaceCwd(name: string | undefined): string | undefined {
665
+ if (name === undefined || name === '') return undefined
666
+ const workspaces = this.ctx.get('workspaces') as { list?: () => Array<{ path: string; title?: string }> } | undefined
667
+ const list = workspaces?.list?.() ?? []
668
+ const byTitle = list.find(w => w.title === name)
669
+ const byBase = list.find(w => w.path.endsWith(`${sep}${name}`))
670
+ const cwd = byTitle?.path ?? byBase?.path
671
+ if (typeof cwd === 'string' && cwd !== '' && existsSync(cwd)) return cwd
672
+ return undefined
673
+ }
674
+
675
+ /** Resolve either target form to a cwd (session id first, then workspace title). */
676
+ resolveTargetCwd(target: { session?: string | undefined; ws?: string | undefined }): string | undefined {
677
+ return this.resolveCwd(target.session) ?? this.resolveWorkspaceCwd(target.ws)
678
+ }
679
+
680
+ /**
681
+ * Open the project folder in an external app (explorer / editors). The
682
+ * editor targets launch through their CLI shims (code / idea / goland /
683
+ * webstorm / pycharm — Toolbox puts the JetBrains ones on PATH).
684
+ * Fire-and-forget GUI launches: explorer exits non-zero even on success, so
685
+ * only a spawn FAILURE rejects; app shims missing from PATH reject with a
686
+ * user-visible hint instead of crashing the route.
687
+ */
688
+ async openFolder(cwd: string, target: 'explorer' | 'vscode' | 'idea' | 'goland' | 'webstorm' | 'pycharm'): Promise<void> {
689
+ const launch = (file: string, args: readonly string[]): Promise<void> =>
690
+ new Promise((resolvePromise, rejectPromise) => {
691
+ const child = spawn(file, args, { cwd, stdio: 'ignore', detached: true })
692
+ child.once('error', rejectPromise)
693
+ child.once('spawn', () => { child.unref(); resolvePromise() })
694
+ })
695
+ const shims: Record<string, string> = { vscode: 'code', idea: 'idea', goland: 'goland', webstorm: 'webstorm', pycharm: 'pycharm' }
696
+ const shim = shims[target]
697
+ if (process.platform === 'win32') {
698
+ if (shim === undefined) return launch('explorer.exe', [cwd])
699
+ return new Promise((resolvePromise, rejectPromise) => {
700
+ const child = spawn('cmd.exe', ['/c', shim, '.'], { cwd, stdio: 'ignore' })
701
+ child.once('error', rejectPromise)
702
+ child.once('exit', (code) => {
703
+ if (code === 0) resolvePromise()
704
+ else rejectPromise(new Error(`${shim} 未能启动(退出码 ${code})——可能未安装或未加入 PATH`))
705
+ })
706
+ })
707
+ }
708
+ if (shim === undefined) {
709
+ const opener = process.platform === 'darwin' ? 'open' : 'xdg-open'
710
+ return launch(opener, [cwd])
711
+ }
712
+ return launch(shim, [cwd])
713
+ }
714
+
715
+ /** Full status snapshot for a session's cwd. One merged `status -z --branch
716
+ * --untracked-files=all` call carries branch/upstream/tracking and the
717
+ * per-file untracked list (no ls-files expansion pass), and every read —
718
+ * repo probe included — runs in one parallel batch. The previous nine
719
+ * sequential process spawns cost ~700ms on Windows, this costs one spawn. */
720
+ async snapshot(cwd: string, signal?: AbortSignal): Promise<GitSnapshot> {
721
+ const [inside, status, worktreeNumstat, stagedNumstat, remotes] = await Promise.all([
722
+ runGit(cwd, ['rev-parse', '--is-inside-work-tree'], { signal }).catch(() => undefined),
723
+ runGit(cwd, ['status', '--porcelain=v1', '-z', '--branch', '--untracked-files=all'], { signal }).catch(() => undefined),
724
+ runGit(cwd, ['diff', '--numstat'], { signal }).catch(() => undefined),
725
+ runGit(cwd, ['diff', '--cached', '--numstat'], { signal }).catch(() => undefined),
726
+ runGit(cwd, ['remote'], { signal }).catch(() => undefined),
727
+ ])
728
+ if (inside === undefined || status === undefined) {
729
+ return { ...NO_REPO, cwd }
730
+ }
731
+
732
+ const header = parseStatusHeader(status.stdout)
733
+ let detachedHead: string | undefined
734
+ if (header.detached) {
735
+ // Detached HEAD has no branch name; resolve the short sha for display.
736
+ try {
737
+ const { stdout } = await runGit(cwd, ['rev-parse', '--short', 'HEAD'], { signal })
738
+ detachedHead = stdout.trim() || undefined
739
+ } catch {
740
+ // Keep it undefined.
741
+ }
742
+ }
743
+
744
+ const numstat = new Map<string, { added: number; deleted: number }>()
745
+ if (worktreeNumstat !== undefined) mergeNumstat(numstat, worktreeNumstat.stdout)
746
+ if (stagedNumstat !== undefined) mergeNumstat(numstat, stagedNumstat.stdout)
747
+
748
+ const files: GitFileChange[] = []
749
+ for (const entry of parseStatusPorcelainZ(status.stdout)) {
750
+ const untrackedFile = entry.index === '?' || entry.worktree === '?'
751
+ const nums = numstat.get(entry.path)
752
+ files.push({
753
+ path: entry.path,
754
+ status: untrackedFile ? 'U' : entry.index === ' ' ? entry.worktree : entry.index,
755
+ added: nums?.added ?? 0,
756
+ deleted: nums?.deleted ?? 0,
757
+ untracked: untrackedFile,
758
+ })
759
+ }
760
+ // Untracked files never appear in numstat; describe each by its line
761
+ // count. Concurrent async reads — a batch of new files must not block the
762
+ // plugin's event loop the way one sync read per file used to.
763
+ await Promise.all(files.filter(file => file.untracked).map(async file => {
764
+ file.added = await countFileLines(cwd, file.path)
765
+ }))
766
+ files.sort((a, b) => a.path.localeCompare(b.path))
767
+
768
+ let totalAdded = 0
769
+ let totalDeleted = 0
770
+ for (const file of files) {
771
+ totalAdded += file.added
772
+ totalDeleted += file.deleted
773
+ }
774
+
775
+ const hasRemote = (remotes?.stdout.trim().length ?? 0) > 0
776
+
777
+ return {
778
+ isRepo: true,
779
+ cwd,
780
+ branch: header.branch,
781
+ ...(detachedHead !== undefined ? { detachedHead } : {}),
782
+ ...(header.upstream !== undefined ? { upstream: header.upstream } : {}),
783
+ ahead: header.ahead,
784
+ behind: header.behind,
785
+ hasRemote,
786
+ clean: files.length === 0,
787
+ files,
788
+ totalAdded,
789
+ totalDeleted,
790
+ }
791
+ }
792
+
793
+ /** Local + remote branch enumeration. */
794
+ async branches(cwd: string, signal?: AbortSignal): Promise<GitBranches> {
795
+ let current: string | null = null
796
+ try {
797
+ const { stdout } = await runGit(cwd, ['branch', '--show-current'], { signal })
798
+ current = stdout.trim() || null
799
+ } catch {
800
+ // Ignore.
801
+ }
802
+ const local: string[] = []
803
+ const remote: string[] = []
804
+ try {
805
+ const { stdout } = await runGit(cwd, ['branch', '--format=%(refname:short)'], { signal })
806
+ for (const line of stdout.split('\n')) {
807
+ const name = line.trim()
808
+ if (name !== '') local.push(name)
809
+ }
810
+ } catch {
811
+ // Ignore.
812
+ }
813
+ try {
814
+ // Full refnames, then strip `refs/remotes/` ourselves: with
815
+ // `%(refname:short)` git collapses `refs/remotes/origin/HEAD` to a bare
816
+ // `origin`, which would show up as a phantom remote branch.
817
+ const { stdout } = await runGit(cwd, ['branch', '-r', '--format=%(refname)'], { signal })
818
+ for (const line of stdout.split('\n')) {
819
+ const ref = line.trim()
820
+ if (!ref.startsWith('refs/remotes/')) continue
821
+ const name = ref.slice('refs/remotes/'.length)
822
+ if (name === '' || name === 'HEAD' || name.endsWith('/HEAD')) continue
823
+ remote.push(name)
824
+ }
825
+ } catch {
826
+ // Ignore.
827
+ }
828
+ return { current, local, remote }
829
+ }
830
+
831
+ /**
832
+ * Per-file diff for the side panel. `mode: 'hunk'` returns only the changed
833
+ * ranges (untracked files render as whole-file additions); `mode: 'full'`
834
+ * returns the whole file with added lines marked.
835
+ */
836
+ async diff(cwd: string, path: string, mode: 'hunk' | 'full', signal?: AbortSignal): Promise<GitDiffResult> {
837
+ if (path.includes('\0')) throw new Error('invalid path')
838
+ if (mode !== 'hunk' && mode !== 'full') throw new Error('invalid mode')
839
+
840
+ // New / untracked files have no HEAD side; the whole file is an addition.
841
+ let isUntracked = false
842
+ let hasHeadVersion = true
843
+ try {
844
+ await runGit(cwd, ['ls-files', '--error-unmatch', '--', path], { signal })
845
+ } catch {
846
+ hasHeadVersion = false
847
+ }
848
+ try {
849
+ const { stdout } = await runGit(cwd, ['ls-files', '--others', '--exclude-standard', '-z'], { signal })
850
+ isUntracked = stdout.split('\0').includes(path)
851
+ } catch {
852
+ isUntracked = false
853
+ }
854
+
855
+ if (!hasHeadVersion && !isUntracked) {
856
+ throw new Error(`not a changed file in this repository: ${path}`)
857
+ }
858
+
859
+ const fromHead = !isUntracked && hasHeadVersion
860
+ let hunkText = ''
861
+ if (fromHead) {
862
+ const [staged, unstaged] = await Promise.all([
863
+ runGit(cwd, ['diff', '--cached', '--no-ext-diff', '--unified=3', '--', path], { signal }).catch(() => ({ stdout: '', stderr: '' })),
864
+ runGit(cwd, ['diff', '--no-ext-diff', '--unified=3', '--', path], { signal }).catch(() => ({ stdout: '', stderr: '' })),
865
+ ])
866
+ hunkText = `${staged.stdout}${unstaged.stdout}`
867
+ }
868
+ const hunkLines = fromHead ? parseUnifiedDiff(hunkText) : []
869
+
870
+ if (mode === 'hunk') {
871
+ if (!fromHead) {
872
+ const { content, truncated } = await readFileContent(cwd, path, signal)
873
+ const lines = content.split('\n').map((text, i) => ({
874
+ type: 'add' as const,
875
+ old: null,
876
+ new: i + 1,
877
+ text,
878
+ }))
879
+ if (lines.length > 0 && lines[lines.length - 1]?.text === '') lines.pop()
880
+ return { path, mode, lines, truncated }
881
+ }
882
+ if (hunkLines.length > MAX_DIFF_ROWS) {
883
+ // Cap the DOM the panel renders — a >3000-line lockfile diff freezes
884
+ // the GUI. Keep the head hunks (what the eye reads first) and a
885
+ // trailing marker, like git's own pager truncation.
886
+ return {
887
+ path,
888
+ mode,
889
+ lines: [...hunkLines.slice(0, MAX_DIFF_ROWS), { type: 'hunk', old: null, new: null, text: `… diff truncated (${hunkLines.length} lines)` }],
890
+ truncated: true,
891
+ }
892
+ }
893
+ return { path, mode, lines: hunkLines, truncated: false }
894
+ }
895
+
896
+ // Full-file view: read the working-tree content, mark added lines.
897
+ const { content, truncated } = await readFileContent(cwd, path, signal)
898
+ const addedSet = addedNewLineNumbers(hunkLines)
899
+ const allAdded = !fromHead
900
+ const rawLines = content.split('\n')
901
+ if (rawLines.length > 0 && rawLines[rawLines.length - 1] === '') rawLines.pop()
902
+ const lines: DiffLine[] = rawLines.slice(0, MAX_FULL_LINES).map((text, i) => ({
903
+ type: allAdded || addedSet.has(i + 1) ? 'add' : 'ctx',
904
+ old: null,
905
+ new: i + 1,
906
+ text,
907
+ }))
908
+ return { path, mode, lines, truncated: truncated || rawLines.length > MAX_FULL_LINES }
909
+ }
910
+
911
+ /**
912
+ * Suggest a commit message: LLM first (when the service and a model are
913
+ * available), heuristic fallback otherwise. Kept for callers that only want
914
+ * the text; {@link suggestDetailed} also reports which path produced it.
915
+ */
916
+ async suggest(cwd: string, signal?: AbortSignal): Promise<string> {
917
+ return (await this.suggestDetailed(cwd, signal)).message
918
+ }
919
+
920
+ /**
921
+ * Suggest a commit message and report how it was produced. A failing LLM
922
+ * path is no longer silent: the caller receives `via: 'heuristic'` plus the
923
+ * reason, and the failure is logged, because an offline fallback message
924
+ * looks exactly like a model-written one in the UI.
925
+ */
926
+ async suggestDetailed(cwd: string, signal?: AbortSignal): Promise<GitSuggestResult> {
927
+ const snapshot = await this.snapshot(cwd, signal)
928
+ if (snapshot.files.length === 0) return { message: 'chore: no changes', via: 'heuristic', reason: 'no changes' }
929
+ const attempt = await this.llmSuggest(snapshot, cwd, signal)
930
+ if (attempt.message !== null) {
931
+ return {
932
+ message: attempt.message,
933
+ via: 'llm',
934
+ ...(attempt.provider !== undefined ? { provider: attempt.provider } : {}),
935
+ ...(attempt.model !== undefined ? { model: attempt.model } : {}),
936
+ ...(attempt.truncated === true ? { truncated: true } : {}),
937
+ }
938
+ }
939
+ const reason = attempt.reason ?? 'llm unavailable'
940
+ this.ctx.logger.warn(
941
+ 'ChatCode CLI UI Tweaks: commit-message model unavailable (%s%s); used the offline heuristic instead',
942
+ reason,
943
+ attempt.provider === undefined ? '' : ` — ${attempt.provider}:${attempt.model ?? '?'}`,
944
+ )
945
+ return {
946
+ message: heuristicSuggest(snapshot),
947
+ via: 'heuristic',
948
+ reason,
949
+ ...(attempt.provider !== undefined ? { provider: attempt.provider } : {}),
950
+ ...(attempt.model !== undefined ? { model: attempt.model } : {}),
951
+ }
952
+ }
953
+
954
+ /**
955
+ * Resolve which provider/model writes the commit message: the `suggestModel`
956
+ * override, else this host's default Agent model. No further fallback — if
957
+ * the default model is unavailable or fails, the suggestion fails (the
958
+ * caller reports the reason) rather than silently using another provider.
959
+ */
960
+ private async resolveSuggestModel(llm: LlmLike): Promise<{ provider?: string; model?: string; reason?: string }> {
961
+ // 1) Explicit per-plugin override wins.
962
+ const configured = this.readConfig().suggestModel
963
+ if (typeof configured === 'string' && configured.includes(':')) {
964
+ const idx = configured.indexOf(':')
965
+ const provider = configured.slice(0, idx)
966
+ const model = configured.slice(idx + 1)
967
+ if (provider !== '' && model !== '') return { provider, model }
968
+ }
969
+
970
+ const registered = new Set(llm.listProviders().map(provider => provider.id))
971
+
972
+ // 2) The model the user picked for this DSH's Agents. Try the service
973
+ // first; fall back to the settings document directly in case the
974
+ // service is out of this plugin context's reach.
975
+ const defaults = this.ctx.get('agentDefaultModel') as DefaultModelLike | undefined
976
+ if (defaults !== undefined) {
977
+ try {
978
+ const selection = defaults.currentSelection()
979
+ const provider = selection.provider
980
+ const model = selection.model
981
+ if (typeof provider === 'string' && provider !== '' && typeof model === 'string' && model !== ''
982
+ && registered.has(provider)) {
983
+ return { provider, model }
984
+ }
985
+ } catch {
986
+ // Fall through to the settings read.
987
+ }
988
+ }
989
+ try {
990
+ const stored = this.ctx.settings?.get(AGENT_DEFAULT_MODEL_NAMESPACE) as { provider?: string; model?: string } | undefined
991
+ const provider = stored?.provider
992
+ const model = stored?.model
993
+ if (typeof provider === 'string' && provider !== '' && typeof model === 'string' && model !== ''
994
+ && registered.has(provider)) {
995
+ return { provider, model }
996
+ }
997
+ } catch {
998
+ // Ignore — reported as unresolvable below.
999
+ }
1000
+
1001
+ return { reason: 'no default model is configured (set ui-tweaks.suggestModel or the ChatCode CLI agent-default-model)' }
1002
+ }
1003
+
1004
+ private async llmSuggest(
1005
+ snapshot: GitSnapshot,
1006
+ cwd: string,
1007
+ signal?: AbortSignal,
1008
+ ): Promise<{ message: string | null; reason?: string; provider?: string; model?: string; truncated?: boolean }> {
1009
+ // Optional-service read: the LLM service may not be composed in.
1010
+ const llm = this.ctx.get('llm') as LlmLike | undefined
1011
+ if (llm === undefined) return { message: null, reason: 'the LLM service is not available in this ChatCode CLI instance' }
1012
+
1013
+ const target = await this.resolveSuggestModel(llm)
1014
+ const { provider, model } = target
1015
+ if (provider === undefined || model === undefined) {
1016
+ return {
1017
+ message: null,
1018
+ reason: target.reason ?? 'no provider/model could be resolved',
1019
+ ...(provider !== undefined ? { provider } : {}),
1020
+ }
1021
+ }
1022
+
1023
+ const diff = await collectSuggestDiff(cwd, snapshot, signal).catch(() => ({ text: '', truncated: false }))
1024
+
1025
+ const controller = new AbortController()
1026
+ const timer = setTimeout(() => controller.abort(), LLM_TIMEOUT_MS)
1027
+ const onOuterAbort = (): void => { controller.abort() }
1028
+ signal?.addEventListener('abort', onOuterAbort, { once: true })
1029
+ const parts: string[] = []
1030
+ let finish: string | undefined
1031
+ let failure: string | undefined
1032
+ try {
1033
+ const stream = llm.stream({
1034
+ provider,
1035
+ model,
1036
+ messages: [{
1037
+ role: 'user',
1038
+ content: [{ type: 'text', text: buildSuggestPrompt(snapshot, diff) }],
1039
+ }],
1040
+ system: 'You write concise git commit messages. Reply with ONLY the commit message — one line, conventional commits format (type(scope): summary), under 72 characters, no code fences, no extra text.',
1041
+ temperature: 0.2,
1042
+ // No reasoningEffort is passed: models that do not declare the chosen
1043
+ // effort would reject the call. The provider's default reasoning can
1044
+ // consume tokens, so leave room above the ~120-token message itself;
1045
+ // reasoning deltas are ignored when assembling the reply.
1046
+ maxTokens: 1024,
1047
+ signal: controller.signal,
1048
+ })
1049
+ for await (const chunk of stream) {
1050
+ if (chunk.type === 'text-delta' && typeof chunk.text === 'string') parts.push(chunk.text)
1051
+ if (chunk.type === 'finish') {
1052
+ finish = finishReasonText(chunk.reason)
1053
+ break
1054
+ }
1055
+ }
1056
+ } catch (error) {
1057
+ // A thrown stream (middleware, adapter construction) is a real failure —
1058
+ // report it instead of pretending the model had nothing to say.
1059
+ failure = messageOf(error)
1060
+ } finally {
1061
+ clearTimeout(timer)
1062
+ signal?.removeEventListener('abort', onOuterAbort)
1063
+ }
1064
+ // Strip fences and collapse to one line; a leading "type(scope):" survives.
1065
+ const text = parts.join('').trim().replace(/^```|```$/g, '').replace(/\s+/g, ' ').slice(0, 120)
1066
+ if (text !== '') {
1067
+ return { message: text, provider, model, ...(diff.truncated ? { truncated: true } : {}) }
1068
+ }
1069
+ const reason = failure !== undefined
1070
+ ? `the model call failed: ${failure}`
1071
+ : finish !== undefined
1072
+ ? `the model returned no text (finish: ${finish})`
1073
+ : 'the model returned no text'
1074
+ return { message: null, reason, provider, model }
1075
+ }
1076
+
1077
+ /**
1078
+ * Stage and commit; optionally push afterwards. Returns the short hash.
1079
+ * When `exclude` names changed files, they are unstaged and left out of the
1080
+ * commit (they stay in the working tree); otherwise everything is staged
1081
+ * with `git add -A`.
1082
+ */
1083
+ async commit(
1084
+ cwd: string,
1085
+ message: string,
1086
+ opts: { push?: boolean; exclude?: readonly string[]; signal?: AbortSignal } = {},
1087
+ ): Promise<{ hash?: string; pushed: boolean }> {
1088
+ const msg = message.trim()
1089
+ if (msg === '') throw new Error('commit message is empty')
1090
+ const exclude = (opts.exclude ?? []).filter(path => path !== '')
1091
+ if (exclude.length === 0) {
1092
+ await runGit(cwd, ['add', '-A'], { signal: opts.signal })
1093
+ } else {
1094
+ // Exclusions requested: unstage everything, then stage only the files
1095
+ // that were NOT excluded.
1096
+ await runGit(cwd, ['reset'], { signal: opts.signal })
1097
+ const snapshot = await this.snapshot(cwd, opts.signal)
1098
+ const included = snapshot.files
1099
+ .filter(file => !exclude.includes(file.path))
1100
+ .map(file => file.path)
1101
+ if (included.length === 0) throw new Error('no files to commit — everything is excluded')
1102
+ await runGit(cwd, ['add', '--', ...included], { signal: opts.signal })
1103
+ }
1104
+ await runGit(cwd, ['commit', '-m', msg], { signal: opts.signal })
1105
+ let hash: string | undefined
1106
+ try {
1107
+ const { stdout } = await runGit(cwd, ['rev-parse', '--short', 'HEAD'], { signal: opts.signal })
1108
+ hash = stdout.trim() || undefined
1109
+ } catch {
1110
+ // Hash is informational only.
1111
+ }
1112
+ let pushed = false
1113
+ if (opts.push === true) {
1114
+ await this.push(cwd, opts.signal)
1115
+ pushed = true
1116
+ }
1117
+ return hash === undefined ? { pushed } : { hash, pushed }
1118
+ }
1119
+
1120
+ /** Delete a local branch (force; the UI confirms first). Protected branches cannot be deleted. */
1121
+ async deleteBranch(cwd: string, name: string, signal?: AbortSignal): Promise<void> {
1122
+ if (!isValidBranchName(name)) throw new Error('invalid branch name')
1123
+ if (isProtectedBranchName(name)) throw new Error(`cannot delete protected branch: ${name}`)
1124
+ const { stdout } = await runGit(cwd, ['branch', '--show-current'], { signal })
1125
+ if (stdout.trim() === name) throw new Error(`cannot delete the current branch: ${name}`)
1126
+ await runGit(cwd, ['branch', '-D', name], { signal })
1127
+ }
1128
+
1129
+ /**
1130
+ * Delete a remote branch (`origin/foo` → `git push origin --delete foo`).
1131
+ * Protected branches cannot be deleted. The local remote-tracking ref is
1132
+ * pruned afterwards so the branch list refreshes without waiting for a fetch.
1133
+ */
1134
+ async deleteRemoteBranch(cwd: string, name: string, signal?: AbortSignal): Promise<void> {
1135
+ const idx = name.indexOf('/')
1136
+ if (idx <= 0 || idx === name.length - 1) throw new Error('invalid remote branch name')
1137
+ const remote = name.slice(0, idx)
1138
+ const branch = name.slice(idx + 1)
1139
+ if (remote.includes('/') || !isValidBranchName(remote) || !isValidBranchName(branch)) {
1140
+ throw new Error('invalid remote branch name')
1141
+ }
1142
+ if (isProtectedBranchName(branch)) throw new Error(`cannot delete protected branch: ${branch}`)
1143
+ await runGit(cwd, ['push', remote, '--delete', branch], { signal })
1144
+ // Best-effort prune of the stale remote-tracking ref.
1145
+ await runGit(cwd, ['branch', '-dr', name], { signal }).catch(() => {})
1146
+ }
1147
+
1148
+ /**
1149
+ * Recent commit graph as structured rows (`git log --graph --all --no-color`
1150
+ * with a machine-readable format), bounded by `limit` commits. The graph edge
1151
+ * prefix is kept verbatim for the monospace drawing column.
1152
+ */
1153
+ async graph(cwd: string, limit: number | undefined, signal?: AbortSignal): Promise<GitGraph> {
1154
+ const n = Math.max(1, Math.min(500, Number.isFinite(limit) ? Math.floor(limit as number) : 150))
1155
+ const sep = '\u001f'
1156
+ const { stdout } = await runGit(cwd, [
1157
+ 'log', '--graph', '--all', '--no-color',
1158
+ `--format=${sep}%H${sep}%h${sep}%an${sep}%aI${sep}%cr${sep}%d${sep}%s`,
1159
+ '-n', String(n),
1160
+ ], { signal })
1161
+ const commits: GitGraphCommit[] = []
1162
+ for (const line of stdout.split('\n')) {
1163
+ // Pure graph continuation lines (`|`, `/`, `\`) carry no commit data.
1164
+ const parts = line.split(sep)
1165
+ if (parts.length < 8) continue
1166
+ const [graph, fullHash, hash, author, date, dateRelative, refs, subject] = parts
1167
+ if (fullHash === undefined || fullHash === '' || subject === undefined) continue
1168
+ commits.push({
1169
+ graph: graph ?? '',
1170
+ fullHash,
1171
+ hash: hash ?? fullHash.slice(0, 7),
1172
+ author: (author ?? '').trim(),
1173
+ date: (date ?? '').trim(),
1174
+ dateRelative: (dateRelative ?? '').trim(),
1175
+ refs: (refs ?? '').trim().replace(/^\(|\)$/g, ''),
1176
+ subject: subject.trim(),
1177
+ })
1178
+ }
1179
+ return { commits, truncated: commits.length >= n }
1180
+ }
1181
+
1182
+ /** Push the current branch; a branch without upstream gets `-u origin <branch>`. */
1183
+ async push(cwd: string, signal?: AbortSignal): Promise<void> {
1184
+ let branch = ''
1185
+ try {
1186
+ const { stdout } = await runGit(cwd, ['branch', '--show-current'], { signal })
1187
+ branch = stdout.trim()
1188
+ } catch {
1189
+ // Ignore.
1190
+ }
1191
+ let hasUpstream = false
1192
+ try {
1193
+ await runGit(cwd, ['rev-parse', '--abbrev-ref', '@{upstream}'], { signal })
1194
+ hasUpstream = true
1195
+ } catch {
1196
+ hasUpstream = false
1197
+ }
1198
+ if (hasUpstream || branch === '') {
1199
+ await runGit(cwd, ['push'], { signal })
1200
+ } else {
1201
+ await runGit(cwd, ['push', '-u', 'origin', branch], { signal })
1202
+ }
1203
+ }
1204
+
1205
+ /** Switch to an existing branch (git switch, falling back to checkout). */
1206
+ async checkout(cwd: string, branch: string, signal?: AbortSignal): Promise<void> {
1207
+ if (!isValidBranchName(branch)) throw new Error('invalid branch name')
1208
+ try {
1209
+ await runGit(cwd, ['switch', branch], { signal })
1210
+ } catch (error) {
1211
+ if (isAbortError(error)) throw error
1212
+ await runGit(cwd, ['checkout', branch], { signal })
1213
+ }
1214
+ }
1215
+
1216
+ /**
1217
+ * Create a branch from `base` (default: current HEAD) and switch to it.
1218
+ * When `pushRemote` is true, the new branch is also pushed to `origin`
1219
+ * with `-u`, creating it on the remote.
1220
+ */
1221
+ async createBranch(
1222
+ cwd: string,
1223
+ name: string,
1224
+ base: string | undefined,
1225
+ pushRemote: boolean,
1226
+ signal?: AbortSignal,
1227
+ ): Promise<void> {
1228
+ if (!isValidBranchName(name)) throw new Error('invalid branch name')
1229
+ const args = ['switch', '-c', name]
1230
+ if (base !== undefined && base !== '') {
1231
+ if (!isValidBranchName(base)) throw new Error('invalid base branch name')
1232
+ args.push(base)
1233
+ }
1234
+ try {
1235
+ await runGit(cwd, args, { signal })
1236
+ } catch (error) {
1237
+ if (isAbortError(error)) throw error
1238
+ await runGit(cwd, ['checkout', '-b', name, ...(base !== undefined && base !== '' ? [base] : [])], { signal })
1239
+ }
1240
+ if (pushRemote) {
1241
+ await runGit(cwd, ['push', '-u', 'origin', name], { signal })
1242
+ }
1243
+ }
1244
+ }
1245
+
1246
+ // ---------------------------------------------------------------------------
1247
+ // Terminal sessions — one REAL PTY per target key, following the
1248
+ // dsh-better-sidebar design (src/pty-manager.ts): node-pty spawns the shell,
1249
+ // output is mirrored into a bounded transcript ring that is replayed to every
1250
+ // newly attached socket before live data, processes survive socket drops
1251
+ // (page refresh, panel toggle) behind a reconnect grace, and an explicit
1252
+ // close frame / plugin teardown kills the shell. The browser half renders the
1253
+ // stream with xterm.js over a WebSocket — full emulation, colors, Ctrl+C and
1254
+ // interactive apps work exactly like a local terminal.
1255
+ // ---------------------------------------------------------------------------
1256
+
1257
+ /**
1258
+ * Duck-typed face of the `node-pty` module — only what this plugin touches.
1259
+ * Kept structural so a broken native install degrades into a clear
1260
+ * "pty unavailable" error instead of crashing plugin activation.
1261
+ */
1262
+ interface NodePtyModule {
1263
+ spawn(file: string, args: readonly string[], options: {
1264
+ name: string
1265
+ cols: number
1266
+ rows: number
1267
+ cwd: string
1268
+ env: NodeJS.ProcessEnv
1269
+ }): IPtyLike
1270
+ }
1271
+
1272
+ /** Duck-typed face of one node-pty terminal handle. */
1273
+ interface IPtyLike {
1274
+ pid: number
1275
+ write(data: string): void
1276
+ resize(cols: number, rows: number): void
1277
+ kill(): void
1278
+ onData(listener: (data: string) => void): unknown
1279
+ onExit(listener: (event: { exitCode: number; signal?: number }) => void): unknown
1280
+ }
1281
+
1282
+ /** Load the native pty module; undefined when missing or unloadable. */
1283
+ function loadNodePty(): NodePtyModule | undefined {
1284
+ try {
1285
+ return createRequire(import.meta.url)('node-pty') as NodePtyModule
1286
+ } catch {
1287
+ return undefined
1288
+ }
1289
+ }
1290
+
1291
+ /**
1292
+ * Resolve the login shell. Windows prefers pwsh (PowerShell 7+) from PATH or
1293
+ * the well-known install dirs, falling back to Windows PowerShell; POSIX uses
1294
+ * $SHELL, then bash, then sh. Resolved per spawn so a mid-session install of
1295
+ * pwsh is picked up without a host restart. No -NoLogo: the version banner
1296
+ * gives the panel the familiar "real terminal" opening frame.
1297
+ */
1298
+ function resolveShell(): { file: string; args: readonly string[] } {
1299
+ if (process.platform === 'win32') {
1300
+ const dirs: string[] = []
1301
+ for (const entry of (process.env.PATH ?? '').split(';')) {
1302
+ const trimmed = entry.trim()
1303
+ if (trimmed !== '') dirs.push(trimmed)
1304
+ }
1305
+ for (const root of [process.env.ProgramW6432, process.env.ProgramFiles]) {
1306
+ if (root === undefined || root === '') continue
1307
+ dirs.push(resolve(root, 'PowerShell', '7'), resolve(root, 'PowerShell', '7-preview'))
1308
+ }
1309
+ for (const dir of dirs) {
1310
+ const candidate = resolve(dir, 'pwsh.exe')
1311
+ if (existsSync(candidate)) return { file: candidate, args: [] }
1312
+ }
1313
+ return { file: 'powershell.exe', args: [] }
1314
+ }
1315
+ const shell = process.env.SHELL
1316
+ if (shell !== undefined && shell !== '') return { file: shell, args: [] }
1317
+ for (const candidate of ['/bin/bash', '/bin/sh']) {
1318
+ if (existsSync(candidate)) return { file: candidate, args: [] }
1319
+ }
1320
+ return { file: 'sh', args: [] }
1321
+ }
1322
+
1323
+ /** Per-terminal transcript bound (bytes kept for replay), mirroring better-sidebar. */
1324
+ const TRANSCRIPT_LIMIT = 1 << 20
1325
+
1326
+ /** Upper bound on concurrent shells across all targets. */
1327
+ const MAX_SESSIONS = 16
1328
+
1329
+ /** A browser socket attached to one terminal (the WebSocket adapter). */
1330
+ export interface TerminalClient {
1331
+ /** Push one chunk to the browser: raw pty bytes or a JSON control frame. */
1332
+ send(text: string): void
1333
+ /** Whether the connection can still carry frames. */
1334
+ readonly alive: boolean
1335
+ }
1336
+
1337
+ interface TerminalSession {
1338
+ key: string
1339
+ cwd: string
1340
+ pty: IPtyLike
1341
+ /** Output accumulated since spawn (bounded; head dropped when over the limit). */
1342
+ transcript: string
1343
+ exited: boolean
1344
+ exitCode: number | null
1345
+ clients: Set<TerminalClient>
1346
+ closeTimer: ReturnType<typeof setTimeout> | null
1347
+ }
1348
+
1349
+ /**
1350
+ * Owns the terminal shells, keyed by target (`session:<id>` / `ws:<name>`).
1351
+ * One live process per key: re-attach reuses it (transcript replay makes the
1352
+ * panel reopen seamless), an exited or cwd-changed handle is replaced with a
1353
+ * fresh spawn. Socket drops schedule a grace close that a timely re-attach
1354
+ * cancels; only the explicit close frame and teardown kill immediately.
1355
+ */
1356
+ export class TerminalSessionManager {
1357
+ private readonly sessions = new Map<string, TerminalSession>()
1358
+ private readonly nodePty: NodePtyModule | undefined
1359
+
1360
+ constructor(loadPty: () => NodePtyModule | undefined = loadNodePty) {
1361
+ this.nodePty = loadPty()
1362
+ }
1363
+
1364
+ /** Whether the native pty module could not be loaded (degraded mode). */
1365
+ get unavailable(): boolean {
1366
+ return this.nodePty === undefined
1367
+ }
1368
+
1369
+ /**
1370
+ * Attach one browser client to the target's terminal, opening (or replacing)
1371
+ * the underlying shell as needed.
1372
+ * @returns the session whose `transcript` must be replayed to the client
1373
+ * before live data (empty on a fresh spawn).
1374
+ */
1375
+ attach(key: string, cwd: string, cols: number, rows: number, client: TerminalClient): TerminalSession {
1376
+ let session = this.sessions.get(key)
1377
+ // A dead shell must not become an input sink, and a shell sitting in a
1378
+ // stale directory (session cwd changed) is respawned — same rules as
1379
+ // better-sidebar's PtyManager.open.
1380
+ if (session !== undefined && (session.exited || session.cwd !== cwd)) {
1381
+ this.close(key)
1382
+ session = undefined
1383
+ }
1384
+ if (session === undefined) {
1385
+ this.evictForNewSession()
1386
+ session = this.spawn(key, cwd, cols, rows)
1387
+ this.sessions.set(key, session)
1388
+ }
1389
+ this.cancelClose(key)
1390
+ session.clients.add(client)
1391
+ return session
1392
+ }
1393
+
1394
+ /** Remove one client; when the last one leaves, arm the reconnect grace. */
1395
+ detach(key: string, client: TerminalClient): void {
1396
+ const session = this.sessions.get(key)
1397
+ if (session === undefined) return
1398
+ session.clients.delete(client)
1399
+ if (session.clients.size === 0 && !session.exited) this.scheduleClose(key, SOCKET_DROP_GRACE_MS)
1400
+ }
1401
+
1402
+ /** Forward raw stdin bytes from the browser to the shell. */
1403
+ input(key: string, data: string): void {
1404
+ this.sessions.get(key)?.pty.write(data)
1405
+ }
1406
+
1407
+ /** Propagate the xterm viewport size to the pty. */
1408
+ resize(key: string, cols: number, rows: number): void {
1409
+ const session = this.sessions.get(key)
1410
+ if (session === undefined || session.exited) return
1411
+ try {
1412
+ session.pty.resize(Math.max(2, Math.floor(cols)), Math.max(2, Math.floor(rows)))
1413
+ } catch {
1414
+ // A resize racing an exit throws inside node-pty; harmless.
1415
+ }
1416
+ }
1417
+
1418
+ /** Kill the target's shell now (explicit close frame / teardown path). */
1419
+ close(key: string): void {
1420
+ this.cancelClose(key)
1421
+ const session = this.sessions.get(key)
1422
+ if (session === undefined) return
1423
+ this.sessions.delete(key)
1424
+ try {
1425
+ session.pty.kill()
1426
+ } catch {
1427
+ // Already exited or gone; nothing left to kill.
1428
+ }
1429
+ }
1430
+
1431
+ /** Kill every shell (plugin teardown). */
1432
+ disposeAll(): void {
1433
+ for (const key of [...this.sessions.keys()]) this.close(key)
1434
+ }
1435
+
1436
+ private spawn(key: string, cwd: string, cols: number, rows: number): TerminalSession {
1437
+ if (this.nodePty === undefined) throw new Error('pty-unavailable')
1438
+ const shell = resolveShell()
1439
+ const pty = this.nodePty.spawn(shell.file, [...shell.args], {
1440
+ name: 'xterm-256color',
1441
+ cols: Math.max(2, Math.floor(cols)),
1442
+ rows: Math.max(2, Math.floor(rows)),
1443
+ cwd,
1444
+ env: { ...process.env },
1445
+ })
1446
+ const session: TerminalSession = { key, cwd, pty, transcript: '', exited: false, exitCode: null, clients: new Set(), closeTimer: null }
1447
+ pty.onData((data) => {
1448
+ session.transcript += data
1449
+ if (session.transcript.length > TRANSCRIPT_LIMIT) {
1450
+ session.transcript = session.transcript.slice(session.transcript.length - TRANSCRIPT_LIMIT)
1451
+ }
1452
+ this.broadcast(session, data)
1453
+ })
1454
+ pty.onExit(({ exitCode }) => {
1455
+ session.exited = true
1456
+ session.exitCode = exitCode
1457
+ this.broadcast(session, `${JSON.stringify({ type: 'exit', code: exitCode })}\n`)
1458
+ // Nobody is watching an exited shell — drop the record at once so the
1459
+ // next attach spawns a fresh prompt instead of replaying a corpse.
1460
+ if (session.clients.size === 0) this.close(key)
1461
+ })
1462
+ return session
1463
+ }
1464
+
1465
+ /** Fan one pty chunk out to every attached, still-open client. */
1466
+ private broadcast(session: TerminalSession, text: string): void {
1467
+ for (const client of session.clients) {
1468
+ if (!client.alive) continue
1469
+ try {
1470
+ client.send(text)
1471
+ } catch {
1472
+ // A throwing socket must not kill the pump loop.
1473
+ }
1474
+ }
1475
+ }
1476
+
1477
+ /** Make room for a new shell: drop exited records first, then detached ones. */
1478
+ private evictForNewSession(): void {
1479
+ if (this.sessions.size < MAX_SESSIONS) return
1480
+ const candidates = [...this.sessions.entries()].filter(([, s]) => s.clients.size === 0)
1481
+ candidates.sort((a, b) => a[0].localeCompare(b[0]))
1482
+ const exited = candidates.find(([, s]) => s.exited)
1483
+ const detached = exited ?? candidates[0]
1484
+ if (detached !== undefined) {
1485
+ this.close(detached[0])
1486
+ return
1487
+ }
1488
+ throw new Error(`terminal limit reached (${MAX_SESSIONS})`)
1489
+ }
1490
+
1491
+ /** Arm (or re-arm) the delayed destruction used for bare socket drops. */
1492
+ private scheduleClose(key: string, delayMs: number): void {
1493
+ this.cancelClose(key)
1494
+ const session = this.sessions.get(key)
1495
+ if (session === undefined) return
1496
+ session.closeTimer = setTimeout(() => { this.close(key) }, delayMs)
1497
+ }
1498
+
1499
+ /** Cancel a pending scheduled close (a client re-attached in time). */
1500
+ private cancelClose(key: string): void {
1501
+ const session = this.sessions.get(key)
1502
+ if (session?.closeTimer !== null && session?.closeTimer !== undefined) {
1503
+ clearTimeout(session.closeTimer)
1504
+ session.closeTimer = null
1505
+ }
1506
+ }
1507
+ }
1508
+
1509
+ /** How long a shell survives after its last browser socket drops. */
1510
+ const SOCKET_DROP_GRACE_MS = 120_000
1511
+
1512
+ function isAbortError(error: unknown): boolean {
1513
+ return error !== null && typeof error === 'object' && 'aborted' in error && (error as { aborted?: unknown }).aborted === true
1514
+ }
1515
+
1516
+ /** Git branch names: no leading `-`, no spaces, no control chars. */
1517
+ function isValidBranchName(name: string): boolean {
1518
+ return /^[A-Za-z0-9._\/-]+$/.test(name) && !name.startsWith('-') && !name.includes('..')
1519
+ }
1520
+
1521
+ /** Branch names that must never be deletable (main/master). */
1522
+ function isProtectedBranchName(name: string): boolean {
1523
+ return name === 'main' || name === 'master'
1524
+ }