@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.
- package/LICENSE +21 -0
- package/README.md +554 -0
- package/README_EN.md +563 -0
- package/assets/logo.svg +1 -0
- package/cordis.patch.yml +79 -0
- package/lib/client-editor.js +55744 -0
- package/lib/client-mermaid.js +208256 -0
- package/lib/client-registry.js +30378 -0
- package/lib/client-terminal.js +16894 -0
- package/lib/client.js +30378 -0
- package/lib/index.js +6897 -0
- package/lib/invariant.js +22 -0
- package/lib/types/agent-opens.d.ts +65 -0
- package/lib/types/agent-pty.d.ts +226 -0
- package/lib/types/browser-probe.d.ts +15 -0
- package/lib/types/bundle-route.d.ts +12 -0
- package/lib/types/chatcode-update.d.ts +47 -0
- package/lib/types/client/AccountMenu.d.ts +1 -0
- package/lib/types/client/AiRatioSection.d.ts +3 -0
- package/lib/types/client/BrowserView.d.ts +39 -0
- package/lib/types/client/ChatcodeGate.d.ts +6 -0
- package/lib/types/client/CodingPlanModelsSettings.d.ts +9 -0
- package/lib/types/client/DiffTab.d.ts +6 -0
- package/lib/types/client/DiffView.d.ts +51 -0
- package/lib/types/client/EditorHost.d.ts +13 -0
- package/lib/types/client/FenceErrorNotice.d.ts +5 -0
- package/lib/types/client/FileTree.d.ts +41 -0
- package/lib/types/client/FreeWindow.d.ts +29 -0
- package/lib/types/client/GitView.d.ts +12 -0
- package/lib/types/client/MarkdownHtml.d.ts +32 -0
- package/lib/types/client/OrphanedTab.d.ts +11 -0
- package/lib/types/client/PdfView.d.ts +6 -0
- package/lib/types/client/ReleaseNotesDialog.d.ts +11 -0
- package/lib/types/client/RenderBoundary.d.ts +32 -0
- package/lib/types/client/SandboxStatusBar.d.ts +10 -0
- package/lib/types/client/SideCardSection.d.ts +74 -0
- package/lib/types/client/Sidebar.d.ts +8 -0
- package/lib/types/client/TabBar.d.ts +54 -0
- package/lib/types/client/TerminalView.d.ts +24 -0
- package/lib/types/client/TextEditor.d.ts +10 -0
- package/lib/types/client/TreePanel.d.ts +27 -0
- package/lib/types/client/UpdateDialog.d.ts +10 -0
- package/lib/types/client/UpdateSettingsPanel.d.ts +1 -0
- package/lib/types/client/UpdateSettingsSection.d.ts +10 -0
- package/lib/types/client/UploadOverlay.d.ts +20 -0
- package/lib/types/client/UsageHeatmapSection.d.ts +4 -0
- package/lib/types/client/add-plugin-modal.d.ts +15 -0
- package/lib/types/client/api.d.ts +345 -0
- package/lib/types/client/binary-download.d.ts +12 -0
- package/lib/types/client/breakpoints.d.ts +17 -0
- package/lib/types/client/browser.d.ts +60 -0
- package/lib/types/client/builtins/index.d.ts +18 -0
- package/lib/types/client/builtins/tabs.d.ts +11 -0
- package/lib/types/client/builtins/viewers.d.ts +3 -0
- package/lib/types/client/chat-presentation.d.ts +13 -0
- package/lib/types/client/chunk-loader.d.ts +124 -0
- package/lib/types/client/chunks/editor.d.ts +9 -0
- package/lib/types/client/chunks/mermaid.d.ts +10 -0
- package/lib/types/client/chunks/terminal.d.ts +9 -0
- package/lib/types/client/cm-themes.d.ts +24 -0
- package/lib/types/client/conversation-draft.d.ts +14 -0
- package/lib/types/client/desktop-env.d.ts +35 -0
- package/lib/types/client/editor-load.d.ts +66 -0
- package/lib/types/client/frame-batcher.d.ts +24 -0
- package/lib/types/client/icons.d.ts +66 -0
- package/lib/types/client/image-types.d.ts +3 -0
- package/lib/types/client/ime-guard.d.ts +36 -0
- package/lib/types/client/index.d.ts +19 -0
- package/lib/types/client/intercept.d.ts +44 -0
- package/lib/types/client/lang.d.ts +14 -0
- package/lib/types/client/layout-push.d.ts +15 -0
- package/lib/types/client/lazy-chunk.d.ts +25 -0
- package/lib/types/client/link-intercept.d.ts +42 -0
- package/lib/types/client/locales-ar.d.ts +18 -0
- package/lib/types/client/locales-de.d.ts +4 -0
- package/lib/types/client/locales-fr.d.ts +10 -0
- package/lib/types/client/locales-hi.d.ts +17 -0
- package/lib/types/client/locales-id.d.ts +15 -0
- package/lib/types/client/locales-it.d.ts +8 -0
- package/lib/types/client/locales-ja.d.ts +17 -0
- package/lib/types/client/locales-ko.d.ts +9 -0
- package/lib/types/client/locales-nl.d.ts +16 -0
- package/lib/types/client/locales-pl.d.ts +19 -0
- package/lib/types/client/locales-pt.d.ts +1 -0
- package/lib/types/client/locales-ru.d.ts +13 -0
- package/lib/types/client/locales-sv.d.ts +1 -0
- package/lib/types/client/locales-th.d.ts +17 -0
- package/lib/types/client/locales-tr.d.ts +17 -0
- package/lib/types/client/locales-vi.d.ts +17 -0
- package/lib/types/client/locales-zh-HK.d.ts +32 -0
- package/lib/types/client/locales-zh-MO.d.ts +32 -0
- package/lib/types/client/locales-zh-TW.d.ts +32 -0
- package/lib/types/client/locales.d.ts +571 -0
- package/lib/types/client/markdown-html.d.ts +96 -0
- package/lib/types/client/markdown-images.d.ts +45 -0
- package/lib/types/client/markdown-labels.d.ts +21 -0
- package/lib/types/client/md-toc.d.ts +4 -0
- package/lib/types/client/mermaid-blocks.d.ts +47 -0
- package/lib/types/client/mermaid-sanitize.d.ts +2 -0
- package/lib/types/client/mermaid.d.ts +12 -0
- package/lib/types/client/open-when-sized.d.ts +20 -0
- package/lib/types/client/open-with-settings.d.ts +4 -0
- package/lib/types/client/open-with.d.ts +87 -0
- package/lib/types/client/openpath-intercept.d.ts +52 -0
- package/lib/types/client/paths.d.ts +43 -0
- package/lib/types/client/pdf-types.d.ts +2 -0
- package/lib/types/client/pinned.d.ts +86 -0
- package/lib/types/client/plugin-settings.d.ts +8 -0
- package/lib/types/client/plugins-shared.d.ts +27 -0
- package/lib/types/client/plugins-tabs.d.ts +3 -0
- package/lib/types/client/plugins-viewers.d.ts +3 -0
- package/lib/types/client/prefs.d.ts +41 -0
- package/lib/types/client/produced-files.d.ts +29 -0
- package/lib/types/client/selection-payload.d.ts +27 -0
- package/lib/types/client/service.d.ts +445 -0
- package/lib/types/client/session-pins.d.ts +14 -0
- package/lib/types/client/settings-nav-icon.d.ts +27 -0
- package/lib/types/client/shell-presets.d.ts +48 -0
- package/lib/types/client/split-pane.d.ts +38 -0
- package/lib/types/client/state.d.ts +480 -0
- package/lib/types/client/tab-content-memo.d.ts +40 -0
- package/lib/types/client/terminal-font.d.ts +78 -0
- package/lib/types/client/terminal-links.d.ts +119 -0
- package/lib/types/client/theme.d.ts +45 -0
- package/lib/types/client/titlebar-strip.d.ts +23 -0
- package/lib/types/client/ui-tweaks/appicons.d.ts +16 -0
- package/lib/types/client/ui-tweaks/archive.d.ts +42 -0
- package/lib/types/client/ui-tweaks/branding.d.ts +45 -0
- package/lib/types/client/ui-tweaks/cachehit.d.ts +41 -0
- package/lib/types/client/ui-tweaks/gitbar.d.ts +103 -0
- package/lib/types/client/ui-tweaks/index.d.ts +279 -0
- package/lib/types/client/ui-tweaks/mcp.d.ts +28 -0
- package/lib/types/client/ui-tweaks/notifier.d.ts +125 -0
- package/lib/types/client/ui-tweaks/whale.d.ts +55 -0
- package/lib/types/client/upload.d.ts +69 -0
- package/lib/types/client/useChatcodeAccount.d.ts +10 -0
- package/lib/types/client/wco.d.ts +47 -0
- package/lib/types/codingplan.d.ts +37 -0
- package/lib/types/config.d.ts +95 -0
- package/lib/types/context-types.d.ts +540 -0
- package/lib/types/fs-operations.d.ts +30 -0
- package/lib/types/fs-search.d.ts +24 -0
- package/lib/types/fs-tree.d.ts +51 -0
- package/lib/types/git.d.ts +124 -0
- package/lib/types/html-route.d.ts +59 -0
- package/lib/types/index.d.ts +47 -0
- package/lib/types/invariant.d.ts +15 -0
- package/lib/types/login-route.d.ts +2 -0
- package/lib/types/open-external.d.ts +24 -0
- package/lib/types/path-security.d.ts +26 -0
- package/lib/types/prefs-shared.d.ts +257 -0
- package/lib/types/pty-deps.d.ts +79 -0
- package/lib/types/pty-manager.d.ts +168 -0
- package/lib/types/tools.d.ts +34 -0
- package/lib/types/trust-fence.d.ts +24 -0
- package/lib/types/ui-tweaks/archive.d.ts +71 -0
- package/lib/types/ui-tweaks/config.d.ts +186 -0
- package/lib/types/ui-tweaks/git-web.d.ts +43 -0
- package/lib/types/ui-tweaks/git.d.ts +311 -0
- package/lib/types/ui-tweaks/index.d.ts +17 -0
- package/lib/types/ui-tweaks/mcp.d.ts +103 -0
- package/lib/types/ui-tweaks/turn-outcome.d.ts +57 -0
- package/lib/types/ui-tweaks/web.d.ts +55 -0
- package/lib/types/wire.d.ts +38 -0
- package/package.json +213 -0
- package/scripts/install.ps1 +310 -0
- package/scripts/install.sh +287 -0
- package/src/agent-opens.ts +265 -0
- package/src/agent-pty.ts +527 -0
- package/src/browser-probe.ts +26 -0
- package/src/bundle-route.ts +129 -0
- package/src/chatcode-update.ts +193 -0
- package/src/client/AccountMenu.tsx +177 -0
- package/src/client/AiRatioSection.module.css +329 -0
- package/src/client/AiRatioSection.tsx +292 -0
- package/src/client/BrowserView.tsx +289 -0
- package/src/client/ChatcodeGate.tsx +35 -0
- package/src/client/CodingPlanModelsSettings.module.css +118 -0
- package/src/client/CodingPlanModelsSettings.tsx +101 -0
- package/src/client/DiffTab.tsx +114 -0
- package/src/client/DiffView.tsx +305 -0
- package/src/client/EditorHost.tsx +563 -0
- package/src/client/FenceErrorNotice.tsx +40 -0
- package/src/client/FileTree.tsx +701 -0
- package/src/client/FreeWindow.tsx +337 -0
- package/src/client/GitView.tsx +722 -0
- package/src/client/MarkdownHtml.tsx +296 -0
- package/src/client/OrphanedTab.tsx +27 -0
- package/src/client/PdfView.tsx +110 -0
- package/src/client/ReleaseNotesDialog.tsx +91 -0
- package/src/client/RenderBoundary.tsx +49 -0
- package/src/client/SandboxStatusBar.tsx +60 -0
- package/src/client/SideCardSection.module.css +932 -0
- package/src/client/SideCardSection.tsx +1169 -0
- package/src/client/Sidebar.tsx +1857 -0
- package/src/client/TabBar.tsx +365 -0
- package/src/client/TerminalView.tsx +424 -0
- package/src/client/TextEditor.tsx +468 -0
- package/src/client/TreePanel.tsx +279 -0
- package/src/client/UpdateDialog.tsx +186 -0
- package/src/client/UpdateSettingsPanel.tsx +29 -0
- package/src/client/UpdateSettingsSection.tsx +22 -0
- package/src/client/UploadOverlay.tsx +62 -0
- package/src/client/UsageHeatmapSection.module.css +440 -0
- package/src/client/UsageHeatmapSection.tsx +301 -0
- package/src/client/add-plugin-modal.tsx +199 -0
- package/src/client/api.ts +433 -0
- package/src/client/binary-download.tsx +23 -0
- package/src/client/breakpoints.ts +61 -0
- package/src/client/browser.ts +167 -0
- package/src/client/builtins/index.ts +37 -0
- package/src/client/builtins/tabs.tsx +275 -0
- package/src/client/builtins/viewers.tsx +125 -0
- package/src/client/chat-presentation.ts +95 -0
- package/src/client/chunk-loader.ts +350 -0
- package/src/client/chunks/editor.tsx +9 -0
- package/src/client/chunks/mermaid.tsx +10 -0
- package/src/client/chunks/terminal.tsx +9 -0
- package/src/client/cm-themes.ts +129 -0
- package/src/client/conversation-draft.ts +29 -0
- package/src/client/css-modules.d.ts +5 -0
- package/src/client/desktop-env.ts +72 -0
- package/src/client/editor-load.ts +92 -0
- package/src/client/frame-batcher.ts +56 -0
- package/src/client/icons.tsx +170 -0
- package/src/client/image-types.ts +8 -0
- package/src/client/ime-guard.ts +47 -0
- package/src/client/index.tsx +501 -0
- package/src/client/intercept.tsx +172 -0
- package/src/client/lang.ts +225 -0
- package/src/client/layout-push.ts +37 -0
- package/src/client/layout.css +156 -0
- package/src/client/lazy-chunk.tsx +89 -0
- package/src/client/link-intercept.ts +73 -0
- package/src/client/locales-ar.ts +387 -0
- package/src/client/locales-de.ts +372 -0
- package/src/client/locales-fr.ts +379 -0
- package/src/client/locales-hi.ts +386 -0
- package/src/client/locales-id.ts +384 -0
- package/src/client/locales-it.ts +377 -0
- package/src/client/locales-ja.ts +486 -0
- package/src/client/locales-ko.ts +378 -0
- package/src/client/locales-nl.ts +384 -0
- package/src/client/locales-pl.ts +388 -0
- package/src/client/locales-pt.ts +369 -0
- package/src/client/locales-ru.ts +384 -0
- package/src/client/locales-sv.ts +369 -0
- package/src/client/locales-th.ts +386 -0
- package/src/client/locales-tr.ts +386 -0
- package/src/client/locales-vi.ts +386 -0
- package/src/client/locales-zh-HK.ts +401 -0
- package/src/client/locales-zh-MO.ts +401 -0
- package/src/client/locales-zh-TW.ts +401 -0
- package/src/client/locales.ts +1229 -0
- package/src/client/markdown-html.ts +331 -0
- package/src/client/markdown-images.ts +137 -0
- package/src/client/markdown-labels.tsx +31 -0
- package/src/client/md-toc.tsx +127 -0
- package/src/client/mermaid-blocks.ts +110 -0
- package/src/client/mermaid-sanitize.ts +93 -0
- package/src/client/mermaid.tsx +406 -0
- package/src/client/open-when-sized.ts +43 -0
- package/src/client/open-with-settings.tsx +135 -0
- package/src/client/open-with.ts +221 -0
- package/src/client/openpath-intercept.ts +79 -0
- package/src/client/paths.ts +63 -0
- package/src/client/pdf-types.ts +4 -0
- package/src/client/pinned.ts +184 -0
- package/src/client/plugin-settings.ts +43 -0
- package/src/client/plugins-shared.ts +29 -0
- package/src/client/plugins-tabs.ts +153 -0
- package/src/client/plugins-viewers.ts +44 -0
- package/src/client/prefs.ts +216 -0
- package/src/client/produced-files.ts +120 -0
- package/src/client/selection-payload.ts +86 -0
- package/src/client/service.ts +873 -0
- package/src/client/session-pins.tsx +334 -0
- package/src/client/settings-nav-icon.ts +55 -0
- package/src/client/shell-presets.ts +83 -0
- package/src/client/sidebar.module.css +3607 -0
- package/src/client/split-pane.tsx +333 -0
- package/src/client/state.ts +1646 -0
- package/src/client/tab-content-memo.ts +55 -0
- package/src/client/terminal-font.ts +224 -0
- package/src/client/terminal-links.ts +194 -0
- package/src/client/theme.ts +110 -0
- package/src/client/titlebar-strip.ts +37 -0
- package/src/client/ui-tweaks/appicons.ts +24 -0
- package/src/client/ui-tweaks/archive.tsx +281 -0
- package/src/client/ui-tweaks/branding.tsx +232 -0
- package/src/client/ui-tweaks/cachehit.tsx +122 -0
- package/src/client/ui-tweaks/gitbar.tsx +2559 -0
- package/src/client/ui-tweaks/index.tsx +1696 -0
- package/src/client/ui-tweaks/mcp.tsx +582 -0
- package/src/client/ui-tweaks/notifier.ts +514 -0
- package/src/client/ui-tweaks/whale.tsx +171 -0
- package/src/client/upload.ts +187 -0
- package/src/client/useChatcodeAccount.ts +103 -0
- package/src/client/wco.ts +120 -0
- package/src/codingplan.ts +146 -0
- package/src/config.ts +207 -0
- package/src/context-types.ts +559 -0
- package/src/fs-operations.ts +97 -0
- package/src/fs-search.ts +113 -0
- package/src/fs-tree.ts +158 -0
- package/src/git.ts +446 -0
- package/src/html-route.ts +106 -0
- package/src/index.ts +1524 -0
- package/src/invariant.ts +32 -0
- package/src/login-route.ts +26 -0
- package/src/open-external.ts +90 -0
- package/src/path-security.ts +81 -0
- package/src/prefs-shared.ts +303 -0
- package/src/pty-deps.ts +241 -0
- package/src/pty-manager.ts +376 -0
- package/src/tools.ts +482 -0
- package/src/trust-fence.ts +84 -0
- package/src/ui-tweaks/archive.ts +293 -0
- package/src/ui-tweaks/config.ts +257 -0
- package/src/ui-tweaks/git-web.ts +421 -0
- package/src/ui-tweaks/git.ts +1524 -0
- package/src/ui-tweaks/index.ts +61 -0
- package/src/ui-tweaks/mcp.ts +535 -0
- package/src/ui-tweaks/turn-outcome.ts +105 -0
- package/src/ui-tweaks/web.ts +225 -0
- package/src/wire.ts +110 -0
- package/vendor/addon-fit.js +2 -0
- package/vendor/xterm.css +218 -0
- 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
|
+
}
|