@keith9681/dsh-sidebar 0.15.2

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 (220) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +0 -0
  3. package/README_EN.md +0 -0
  4. package/cordis.patch.yml +49 -0
  5. package/lib/client-editor.js +34651 -0
  6. package/lib/client-mermaid.js +200629 -0
  7. package/lib/client-registry.js +13388 -0
  8. package/lib/client-terminal.js +8904 -0
  9. package/lib/client.js +13388 -0
  10. package/lib/index.js +3959 -0
  11. package/lib/invariant.js +22 -0
  12. package/lib/types/agent-pty.d.ts +226 -0
  13. package/lib/types/browser-probe.d.ts +15 -0
  14. package/lib/types/bundle-route.d.ts +12 -0
  15. package/lib/types/client/BrowserView.d.ts +22 -0
  16. package/lib/types/client/DiffTab.d.ts +6 -0
  17. package/lib/types/client/DiffView.d.ts +51 -0
  18. package/lib/types/client/EditorHost.d.ts +12 -0
  19. package/lib/types/client/FileTree.d.ts +36 -0
  20. package/lib/types/client/GitView.d.ts +8 -0
  21. package/lib/types/client/OrphanedTab.d.ts +11 -0
  22. package/lib/types/client/PdfView.d.ts +6 -0
  23. package/lib/types/client/RenderBoundary.d.ts +32 -0
  24. package/lib/types/client/SandboxStatusBar.d.ts +10 -0
  25. package/lib/types/client/SideCardSection.d.ts +74 -0
  26. package/lib/types/client/SideChatView.d.ts +16 -0
  27. package/lib/types/client/Sidebar.d.ts +6 -0
  28. package/lib/types/client/SubagentView.d.ts +16 -0
  29. package/lib/types/client/TabBar.d.ts +43 -0
  30. package/lib/types/client/TerminalView.d.ts +24 -0
  31. package/lib/types/client/TextEditor.d.ts +10 -0
  32. package/lib/types/client/TreePanel.d.ts +23 -0
  33. package/lib/types/client/UploadOverlay.d.ts +20 -0
  34. package/lib/types/client/add-plugin-modal.d.ts +15 -0
  35. package/lib/types/client/api.d.ts +268 -0
  36. package/lib/types/client/binary-download.d.ts +12 -0
  37. package/lib/types/client/breakpoints.d.ts +12 -0
  38. package/lib/types/client/browser.d.ts +50 -0
  39. package/lib/types/client/builtins/index.d.ts +18 -0
  40. package/lib/types/client/builtins/tabs.d.ts +11 -0
  41. package/lib/types/client/builtins/viewers.d.ts +3 -0
  42. package/lib/types/client/chunk-loader.d.ts +124 -0
  43. package/lib/types/client/chunks/editor.d.ts +9 -0
  44. package/lib/types/client/chunks/mermaid.d.ts +10 -0
  45. package/lib/types/client/chunks/terminal.d.ts +9 -0
  46. package/lib/types/client/cm-themes.d.ts +24 -0
  47. package/lib/types/client/conversation-draft.d.ts +14 -0
  48. package/lib/types/client/desktop-env.d.ts +35 -0
  49. package/lib/types/client/editable-state.d.ts +27 -0
  50. package/lib/types/client/editor-load.d.ts +66 -0
  51. package/lib/types/client/frame-batcher.d.ts +24 -0
  52. package/lib/types/client/icons.d.ts +57 -0
  53. package/lib/types/client/image-types.d.ts +3 -0
  54. package/lib/types/client/ime-guard.d.ts +36 -0
  55. package/lib/types/client/index.d.ts +19 -0
  56. package/lib/types/client/intercept.d.ts +33 -0
  57. package/lib/types/client/lang.d.ts +12 -0
  58. package/lib/types/client/lazy-chunk.d.ts +25 -0
  59. package/lib/types/client/link-intercept.d.ts +42 -0
  60. package/lib/types/client/locales.d.ts +366 -0
  61. package/lib/types/client/mermaid-blocks.d.ts +41 -0
  62. package/lib/types/client/mermaid-sanitize.d.ts +2 -0
  63. package/lib/types/client/mermaid.d.ts +12 -0
  64. package/lib/types/client/open-when-sized.d.ts +20 -0
  65. package/lib/types/client/open-with-settings.d.ts +4 -0
  66. package/lib/types/client/open-with.d.ts +87 -0
  67. package/lib/types/client/openpath-intercept.d.ts +41 -0
  68. package/lib/types/client/paths.d.ts +35 -0
  69. package/lib/types/client/pdf-types.d.ts +2 -0
  70. package/lib/types/client/plugin-settings.d.ts +8 -0
  71. package/lib/types/client/plugins-shared.d.ts +27 -0
  72. package/lib/types/client/plugins-tabs.d.ts +3 -0
  73. package/lib/types/client/plugins-viewers.d.ts +3 -0
  74. package/lib/types/client/prefs.d.ts +41 -0
  75. package/lib/types/client/produced-files.d.ts +23 -0
  76. package/lib/types/client/selection-payload.d.ts +27 -0
  77. package/lib/types/client/service.d.ts +440 -0
  78. package/lib/types/client/settings-nav-icon.d.ts +19 -0
  79. package/lib/types/client/shell-presets.d.ts +48 -0
  80. package/lib/types/client/sidechat-transcript.d.ts +103 -0
  81. package/lib/types/client/split-pane.d.ts +30 -0
  82. package/lib/types/client/state.d.ts +359 -0
  83. package/lib/types/client/subagent-detect.d.ts +54 -0
  84. package/lib/types/client/subagent-jobs.d.ts +63 -0
  85. package/lib/types/client/tab-content-memo.d.ts +33 -0
  86. package/lib/types/client/terminal-font.d.ts +20 -0
  87. package/lib/types/client/theme.d.ts +45 -0
  88. package/lib/types/client/titlebar-strip.d.ts +23 -0
  89. package/lib/types/client/upload.d.ts +69 -0
  90. package/lib/types/client/wco.d.ts +47 -0
  91. package/lib/types/config.d.ts +64 -0
  92. package/lib/types/context-types.d.ts +603 -0
  93. package/lib/types/fs-operations.d.ts +28 -0
  94. package/lib/types/fs-search.d.ts +24 -0
  95. package/lib/types/fs-tree.d.ts +51 -0
  96. package/lib/types/git.d.ts +82 -0
  97. package/lib/types/html-route.d.ts +59 -0
  98. package/lib/types/index.d.ts +47 -0
  99. package/lib/types/invariant.d.ts +15 -0
  100. package/lib/types/jobs-routes.d.ts +45 -0
  101. package/lib/types/open-external.d.ts +24 -0
  102. package/lib/types/prefs-shared.d.ts +225 -0
  103. package/lib/types/pty-deps.d.ts +78 -0
  104. package/lib/types/pty-manager.d.ts +168 -0
  105. package/lib/types/sidechat-core.d.ts +172 -0
  106. package/lib/types/sidechat-routes.d.ts +30 -0
  107. package/lib/types/subagent-activity.d.ts +44 -0
  108. package/lib/types/subagent-live-route.d.ts +44 -0
  109. package/lib/types/tools.d.ts +31 -0
  110. package/lib/types/trust-fence.d.ts +24 -0
  111. package/lib/types/wire.d.ts +38 -0
  112. package/package.json +173 -0
  113. package/scripts/install.ps1 +289 -0
  114. package/scripts/install.sh +287 -0
  115. package/src/agent-pty.ts +527 -0
  116. package/src/browser-probe.ts +26 -0
  117. package/src/bundle-route.ts +129 -0
  118. package/src/client/BrowserView.tsx +253 -0
  119. package/src/client/DiffTab.tsx +110 -0
  120. package/src/client/DiffView.tsx +305 -0
  121. package/src/client/EditorHost.tsx +525 -0
  122. package/src/client/FileTree.tsx +653 -0
  123. package/src/client/GitView.tsx +558 -0
  124. package/src/client/OrphanedTab.tsx +27 -0
  125. package/src/client/PdfView.tsx +110 -0
  126. package/src/client/RenderBoundary.tsx +49 -0
  127. package/src/client/SandboxStatusBar.tsx +60 -0
  128. package/src/client/SideCardSection.module.css +960 -0
  129. package/src/client/SideCardSection.tsx +1152 -0
  130. package/src/client/SideChatView.module.css +437 -0
  131. package/src/client/SideChatView.tsx +644 -0
  132. package/src/client/Sidebar.tsx +1148 -0
  133. package/src/client/SubagentView.module.css +547 -0
  134. package/src/client/SubagentView.tsx +883 -0
  135. package/src/client/TabBar.tsx +305 -0
  136. package/src/client/TerminalView.tsx +383 -0
  137. package/src/client/TextEditor.tsx +500 -0
  138. package/src/client/TreePanel.tsx +273 -0
  139. package/src/client/UploadOverlay.tsx +62 -0
  140. package/src/client/add-plugin-modal.tsx +199 -0
  141. package/src/client/api.ts +335 -0
  142. package/src/client/binary-download.tsx +23 -0
  143. package/src/client/breakpoints.ts +51 -0
  144. package/src/client/browser.ts +119 -0
  145. package/src/client/builtins/index.ts +37 -0
  146. package/src/client/builtins/tabs.tsx +331 -0
  147. package/src/client/builtins/viewers.tsx +125 -0
  148. package/src/client/chunk-loader.ts +351 -0
  149. package/src/client/chunks/editor.tsx +9 -0
  150. package/src/client/chunks/mermaid.tsx +10 -0
  151. package/src/client/chunks/terminal.tsx +9 -0
  152. package/src/client/cm-themes.ts +129 -0
  153. package/src/client/conversation-draft.ts +29 -0
  154. package/src/client/css-modules.d.ts +5 -0
  155. package/src/client/desktop-env.ts +72 -0
  156. package/src/client/editable-state.ts +70 -0
  157. package/src/client/editor-load.ts +92 -0
  158. package/src/client/frame-batcher.ts +56 -0
  159. package/src/client/icons.tsx +161 -0
  160. package/src/client/image-types.ts +8 -0
  161. package/src/client/ime-guard.ts +47 -0
  162. package/src/client/index.tsx +369 -0
  163. package/src/client/intercept.tsx +110 -0
  164. package/src/client/lang.ts +103 -0
  165. package/src/client/layout.css +94 -0
  166. package/src/client/lazy-chunk.tsx +89 -0
  167. package/src/client/link-intercept.ts +73 -0
  168. package/src/client/locales.ts +734 -0
  169. package/src/client/mermaid-blocks.ts +110 -0
  170. package/src/client/mermaid-sanitize.ts +93 -0
  171. package/src/client/mermaid.tsx +401 -0
  172. package/src/client/open-when-sized.ts +43 -0
  173. package/src/client/open-with-settings.tsx +135 -0
  174. package/src/client/open-with.ts +221 -0
  175. package/src/client/openpath-intercept.ts +62 -0
  176. package/src/client/paths.ts +47 -0
  177. package/src/client/pdf-types.ts +4 -0
  178. package/src/client/plugin-settings.ts +43 -0
  179. package/src/client/plugins-shared.ts +29 -0
  180. package/src/client/plugins-tabs.ts +87 -0
  181. package/src/client/plugins-viewers.ts +30 -0
  182. package/src/client/prefs.ts +207 -0
  183. package/src/client/produced-files.ts +91 -0
  184. package/src/client/selection-payload.ts +86 -0
  185. package/src/client/service.ts +838 -0
  186. package/src/client/settings-nav-icon.ts +45 -0
  187. package/src/client/shell-presets.ts +83 -0
  188. package/src/client/sidebar.module.css +2577 -0
  189. package/src/client/sidechat-transcript.ts +321 -0
  190. package/src/client/split-pane.tsx +322 -0
  191. package/src/client/state.ts +1175 -0
  192. package/src/client/subagent-detect.ts +137 -0
  193. package/src/client/subagent-jobs.ts +161 -0
  194. package/src/client/tab-content-memo.ts +46 -0
  195. package/src/client/terminal-font.ts +28 -0
  196. package/src/client/theme.ts +110 -0
  197. package/src/client/titlebar-strip.ts +37 -0
  198. package/src/client/upload.ts +187 -0
  199. package/src/client/wco.ts +120 -0
  200. package/src/config.ts +155 -0
  201. package/src/context-types.ts +610 -0
  202. package/src/fs-operations.ts +99 -0
  203. package/src/fs-search.ts +86 -0
  204. package/src/fs-tree.ts +158 -0
  205. package/src/git.ts +244 -0
  206. package/src/html-route.ts +106 -0
  207. package/src/index.ts +1132 -0
  208. package/src/invariant.ts +32 -0
  209. package/src/jobs-routes.ts +257 -0
  210. package/src/open-external.ts +90 -0
  211. package/src/prefs-shared.ts +268 -0
  212. package/src/pty-deps.ts +240 -0
  213. package/src/pty-manager.ts +376 -0
  214. package/src/sidechat-core.ts +468 -0
  215. package/src/sidechat-routes.ts +344 -0
  216. package/src/subagent-activity.ts +91 -0
  217. package/src/subagent-live-route.ts +93 -0
  218. package/src/tools.ts +479 -0
  219. package/src/trust-fence.ts +77 -0
  220. package/src/wire.ts +100 -0
@@ -0,0 +1,305 @@
1
+ /**
2
+ * The tab strip of one pane: tabs capped at TAB_MAX_WIDTH (ellipsized),
3
+ * overflow scrolls horizontally, a close button per tab, a four-way split
4
+ * button cluster, and the + menu that opens new tabs (explorer / git /
5
+ * terminal). Tabs are draggable; dropping onto another tab inserts before it,
6
+ * dropping on the strip background appends to this pane. Right-clicking a
7
+ * tab opens the tab context menu (close / close others / close to the left /
8
+ * close to the right, all scoped to this pane).
9
+ */
10
+ import { useEffect, useRef, useState, type ReactNode } from 'react'
11
+ import clsx from 'clsx'
12
+ import {
13
+ IconCloseFill14, IconPlusOutline16, Menu,
14
+ } from '@deepseek-ai/dsh-client-ui-primitives'
15
+ import type { SidebarTab } from './state.ts'
16
+ import { t } from './locales.ts'
17
+ import css from './sidebar.module.css'
18
+
19
+ /** One + menu option. */
20
+ export interface NewTabOption {
21
+ id: string
22
+ label: string
23
+ disabled?: boolean
24
+ /** Leading icon (Menu row). */
25
+ icon?: ReactNode
26
+ }
27
+
28
+ /** Drag payload for tab moves (HTML5 DnD dataTransfer). */
29
+ export const TAB_DRAG_TYPE = 'application/x-dsh-tab'
30
+
31
+ export interface TabDragPayload {
32
+ tabId: string
33
+ paneId: string
34
+ }
35
+
36
+ export function serializeDrag(payload: TabDragPayload): string {
37
+ return JSON.stringify(payload)
38
+ }
39
+
40
+ export function parseDrag(raw: string): TabDragPayload | null {
41
+ try {
42
+ const parsed = JSON.parse(raw) as TabDragPayload
43
+ if (typeof parsed.tabId === 'string' && typeof parsed.paneId === 'string') return parsed
44
+ return null
45
+ } catch {
46
+ return null
47
+ }
48
+ }
49
+
50
+ /** Global tab-drag flag: PDF iframes become non-interactive synchronously. */
51
+ function setTabDragging(active: boolean): void {
52
+ if (active) document.body.setAttribute('data-dsh-tab-dragging', '')
53
+ else document.body.removeAttribute('data-dsh-tab-dragging')
54
+ }
55
+
56
+ export function TabBar(props: {
57
+ paneId: string
58
+ tabs: SidebarTab[]
59
+ active: string | null
60
+ onActivate: (tabId: string) => void
61
+ onClose: (tabId: string) => void
62
+ onNewTab: (optionId: string) => void
63
+ newTabOptions: NewTabOption[]
64
+ /** Drop of a tab from any pane: (payload, insertBeforeTabId | null). */
65
+ onDropTab: (payload: TabDragPayload, before: string | null) => void
66
+ /** Icon resolver for tab labels (reads from the tab descriptor registry). */
67
+ getTabIcon?: (tab: SidebarTab) => ReactNode
68
+ /** Badge resolver for tab labels (reads the descriptor's `badge`; the
69
+ * resolver returns the rendered pill or null). */
70
+ getTabBadge?: (tab: SidebarTab) => ReactNode
71
+ }) {
72
+ const {
73
+ paneId, tabs, active, onActivate, onClose, onNewTab, newTabOptions, onDropTab, getTabIcon, getTabBadge,
74
+ } = props
75
+ const [menuOpen, setMenuOpen] = useState(false)
76
+ // The tab right-click context menu: the target tab plus the cursor
77
+ // position (the portaled Menu anchors there, following GitView/FileTree).
78
+ const [tabMenu, setTabMenu] = useState<{ tabId: string; x: number; y: number } | null>(null)
79
+ const [dragOver, setDragOver] = useState(false)
80
+ const listRef = useRef<HTMLDivElement>(null)
81
+ // The context target's index in the render-time tab snapshot; -1 when the
82
+ // tab disappeared since the menu opened (the menu hides then).
83
+ const tabMenuIndex = tabMenu === null ? -1 : tabs.findIndex(tab => tab.id === tabMenu.tabId)
84
+
85
+ // Middle-click close: the press target is recorded on middle mousedown
86
+ // (preventDefaulted to disarm Chrome's middle-click autoscroll — its
87
+ // indicator is inert here because the strip hides its scrollbar and only
88
+ // the wheel handler scrolls) and the close settles on the first middle
89
+ // mouseup OVER that same tab. Release-position semantics match VS Code
90
+ // (microsoft/vscode#101028) and what users expect from Chrome tabs
91
+ // (crbug/40679924): pressing on a tab and releasing elsewhere cancels the
92
+ // close. The browser dispatches auxclick to the nearest common ancestor of
93
+ // the press/release targets when they differ, so any drift, autoscroll
94
+ // scroll, or tab-list reflow between press and release would otherwise
95
+ // swallow the close; settling on the recorded press target at mouseup
96
+ // keeps release semantics without depending on auxclick delivery.
97
+ const onCloseRef = useRef(onClose)
98
+ const middlePressed = useRef<{ id: string; node: HTMLElement } | null>(null)
99
+ useEffect(() => {
100
+ onCloseRef.current = onClose
101
+ })
102
+ useEffect(() => {
103
+ const onMouseUp = (event: MouseEvent): void => {
104
+ if (event.button !== 1) return
105
+ const pressed = middlePressed.current
106
+ middlePressed.current = null
107
+ // Close only when the release lands on the pressed tab; a drag-away
108
+ // release cancels the press (one-shot per press).
109
+ if (pressed !== null && pressed.node.isConnected && pressed.node.contains(event.target as Node)) {
110
+ onCloseRef.current(pressed.id)
111
+ }
112
+ }
113
+ window.addEventListener('mouseup', onMouseUp)
114
+ return () => { window.removeEventListener('mouseup', onMouseUp) }
115
+ }, [])
116
+
117
+ // Wheel over the strip scrolls the tab row horizontally (a plain mouse
118
+ // wheel emits deltaY, which overflow-x alone never consumes). Bound as a
119
+ // native NON-passive listener: React registers onWheel passively at the
120
+ // root, where preventDefault() is a no-op. Modifier keys keep their native
121
+ // meaning (shift = horizontal scroll, ctrl/cmd = zoom), and a strip that
122
+ // does not overflow leaves the event alone so the page scrolls normally.
123
+ useEffect(() => {
124
+ const el = listRef.current
125
+ if (el === null) return
126
+ const onWheel = (event: WheelEvent): void => {
127
+ if (event.shiftKey || event.ctrlKey || event.metaKey || event.altKey) return
128
+ if (el.scrollWidth <= el.clientWidth) return
129
+ event.preventDefault()
130
+ const unit = event.deltaMode === 1 ? 16 : event.deltaMode === 2 ? el.clientWidth : 1
131
+ el.scrollLeft += (event.deltaX + event.deltaY) * unit
132
+ }
133
+ el.addEventListener('wheel', onWheel, { passive: false })
134
+ return () => { el.removeEventListener('wheel', onWheel) }
135
+ }, [])
136
+
137
+ useEffect(() => {
138
+ const clear = (): void => { setTabDragging(false); setDragOver(false) }
139
+ window.addEventListener('dragend', clear, true)
140
+ window.addEventListener('drop', clear, true)
141
+ window.addEventListener('blur', clear)
142
+ return () => {
143
+ window.removeEventListener('dragend', clear, true)
144
+ window.removeEventListener('drop', clear, true)
145
+ window.removeEventListener('blur', clear)
146
+ }
147
+ }, [])
148
+
149
+ return (
150
+ <div
151
+ className={clsx(css.tabBar, dragOver && css.tabBarDrop)}
152
+ onDragOver={(event) => {
153
+ // The strip owns drops on itself (merge into this pane); stopping
154
+ // propagation keeps the pane root from also running its edge-zone
155
+ // handler on the same drop.
156
+ event.preventDefault()
157
+ event.stopPropagation()
158
+ setDragOver(true)
159
+ }}
160
+ onDragLeave={() => { setDragOver(false) }}
161
+ onDrop={(event) => {
162
+ event.preventDefault()
163
+ event.stopPropagation()
164
+ setDragOver(false)
165
+ setTabDragging(false)
166
+ const raw = event.dataTransfer.getData(TAB_DRAG_TYPE)
167
+ const payload = parseDrag(raw)
168
+ if (payload !== null) onDropTab(payload, null)
169
+ }}
170
+ >
171
+ <div ref={listRef} className={css.tabList}>
172
+ {tabs.map(tab => (
173
+ <div
174
+ key={tab.id}
175
+ className={clsx(css.tab, active === tab.id && css.tabActive)}
176
+ title={tab.title}
177
+ draggable
178
+ onDragStart={(event) => {
179
+ setTabDragging(true)
180
+ event.dataTransfer.setData(TAB_DRAG_TYPE, serializeDrag({ tabId: tab.id, paneId }))
181
+ event.dataTransfer.effectAllowed = 'move'
182
+ }}
183
+ onDragEnd={() => { setTabDragging(false); setDragOver(false) }}
184
+ onDragOver={(event) => { event.preventDefault(); event.stopPropagation() }}
185
+ onDrop={(event) => {
186
+ event.preventDefault()
187
+ event.stopPropagation()
188
+ setTabDragging(false)
189
+ const raw = event.dataTransfer.getData(TAB_DRAG_TYPE)
190
+ const payload = parseDrag(raw)
191
+ if (payload !== null) onDropTab(payload, tab.id)
192
+ }}
193
+ onClick={() => { onActivate(tab.id) }}
194
+ onMouseDown={(event) => {
195
+ // Middle-click close: record the press target and disarm
196
+ // Chrome's middle-click autoscroll (its indicator is inert
197
+ // here — the strip scrolls via the wheel handler only). The
198
+ // close itself settles on the first middle mouseup over this
199
+ // same tab (window-level), keeping release semantics.
200
+ if (event.button === 1) {
201
+ event.preventDefault()
202
+ middlePressed.current = { id: tab.id, node: event.currentTarget }
203
+ }
204
+ }}
205
+ onContextMenu={(event) => {
206
+ // Take over the browser menu: the tab context menu offers the
207
+ // close operations for this pane. Opening it also dismisses
208
+ // the + menu (only one menu at a time).
209
+ event.preventDefault()
210
+ setMenuOpen(false)
211
+ setTabMenu({ tabId: tab.id, x: event.clientX, y: event.clientY })
212
+ }}
213
+ >
214
+ {getTabIcon?.(tab) ?? null}
215
+ {getTabBadge?.(tab) ?? null}
216
+ <span className={css.tabTitle}>{tab.title}</span>
217
+ <button
218
+ type="button"
219
+ className={css.tabClose}
220
+ aria-label={t('close')}
221
+ onClick={(event) => {
222
+ event.stopPropagation()
223
+ onClose(tab.id)
224
+ }}
225
+ >
226
+ <IconCloseFill14 />
227
+ </button>
228
+ </div>
229
+ ))}
230
+ {/*
231
+ The + sits immediately after the rightmost tab (sticky at the
232
+ right edge of the scrollport when the tabs overflow, so it stays
233
+ reachable no matter how many tabs are open).
234
+ */}
235
+ <Menu
236
+ open={menuOpen}
237
+ onClose={() => { setMenuOpen(false) }}
238
+ items={newTabOptions.map(option => ({
239
+ id: option.id,
240
+ label: option.label,
241
+ ...(option.disabled === true ? { disabled: true } : {}),
242
+ ...(option.icon !== undefined ? { icon: option.icon } : {}),
243
+ }))}
244
+ onSelect={(id) => {
245
+ onNewTab(id)
246
+ setMenuOpen(false)
247
+ }}
248
+ portal
249
+ align="end"
250
+ anchor={(
251
+ <button
252
+ type="button"
253
+ className={css.tabBarPlus}
254
+ aria-label={t('newTab')}
255
+ title={t('newTab')}
256
+ onClick={() => { setMenuOpen(v => !v); setTabMenu(null) }}
257
+ >
258
+ <IconPlusOutline16 />
259
+ </button>
260
+ )}
261
+ />
262
+ {/*
263
+ The tab context menu, positioned at the right-click cursor (portal
264
+ so the panel's overflow clip cannot crop it). Close operations are
265
+ scoped to THIS pane: "close others/left/right" walk the render-time
266
+ tab snapshot and reuse the per-tab onClose path (which routes
267
+ through the service and releases terminals), so the target tab is
268
+ never closed and the pane never empties mid-loop.
269
+ */}
270
+ <Menu
271
+ open={tabMenu !== null && tabMenuIndex >= 0}
272
+ onClose={() => { setTabMenu(null) }}
273
+ items={[
274
+ { id: 'close', label: t('close') },
275
+ { id: 'closeOthers', label: t('closeOtherTabs'), ...(tabs.length <= 1 ? { disabled: true } : {}) },
276
+ { id: 'closeLeft', label: t('closeLeftTabs'), ...(tabMenuIndex <= 0 ? { disabled: true } : {}) },
277
+ { id: 'closeRight', label: t('closeRightTabs'), ...(tabMenuIndex >= tabs.length - 1 ? { disabled: true } : {}) },
278
+ ]}
279
+ onSelect={(id) => {
280
+ const target = tabMenu
281
+ if (target === null) return
282
+ setTabMenu(null)
283
+ const index = tabs.findIndex(tab => tab.id === target.tabId)
284
+ if (index < 0) return
285
+ if (id === 'close') {
286
+ onClose(target.tabId)
287
+ } else if (id === 'closeOthers') {
288
+ for (const tab of tabs) {
289
+ if (tab.id !== target.tabId) onClose(tab.id)
290
+ }
291
+ } else if (id === 'closeLeft') {
292
+ for (const tab of tabs.slice(0, index)) onClose(tab.id)
293
+ } else if (id === 'closeRight') {
294
+ for (const tab of tabs.slice(index + 1)) onClose(tab.id)
295
+ }
296
+ }}
297
+ portal
298
+ align="start"
299
+ getAnchorRect={() => (tabMenu === null ? null : new DOMRect(tabMenu.x, tabMenu.y, 0, 0))}
300
+ anchor={<span />}
301
+ />
302
+ </div>
303
+ </div>
304
+ )
305
+ }
@@ -0,0 +1,383 @@
1
+ /**
2
+ * The interactive terminal: xterm.js over a WebSocket to the host pty.
3
+ * The host replays the session's transcript on connect, then streams live
4
+ * output; input frames are raw text, resize frames are JSON with
5
+ * type:"resize". Transient disconnects (page refresh, host restart) reconnect
6
+ * automatically; a server-side refusal (close code 1011 with a reason, e.g.
7
+ * a failed pty spawn) stops the loop and shows the reason with a manual
8
+ * retry, and repeated unreasoned failures surface the close code after three
9
+ * attempts, so the banner never spins forever.
10
+ *
11
+ * Three control frames shape the pty lifecycle on unmount:
12
+ * - `{type:'close'}` — the user closed the tab. The host kills the pty
13
+ * immediately (quota released).
14
+ * - `{type:'park'}` — the user switched to another conversation. The tab is
15
+ * still open in its session's persisted state but its view unmounted; the
16
+ * host keeps the pty alive indefinitely (no grace countdown), so switching
17
+ * back reattaches the same shell instead of respawning one.
18
+ * - bare socket drop (no frame) — page refresh, crash, plugin teardown, or a
19
+ * same-session re-render. The host's reconnect grace keeps the shell alive
20
+ * for a quick reconnect.
21
+ *
22
+ * Two attach modes share one upgrade endpoint:
23
+ * - `tabId` starting with `agent:` is an agent-owned terminal (created by
24
+ * the `terminal_create` tool). The uuid is the suffix after `agent:`; the
25
+ * view connects with `?uuid=...`. A close frame kills the pty (the agent's
26
+ * terminal closes when the user closes the tab); a bare socket drop
27
+ * leaves the pty alive (the agent owns the lifetime) — agent terminals
28
+ * never send park (their lifetime is already indefinite on bare drop).
29
+ * - Any other `tabId` is a UI-tab terminal (the user created it from the +
30
+ * menu). The view connects with `?tab=...&sessionId=...&cwd=...`. A close
31
+ * frame schedules a 0-ms close; a park frame marks the pty as parked; a
32
+ * bare socket drop gets the host's reconnect grace.
33
+ */
34
+ import { useEffect, useRef, useState } from 'react'
35
+ import { Terminal, type ITheme } from '@xterm/xterm'
36
+ import { FitAddon } from '@xterm/addon-fit'
37
+ import { writeClipboard } from '@deepseek-ai/dsh-client-ui-primitives'
38
+ import '@xterm/xterm/css/xterm.css'
39
+ import { t } from './locales.ts'
40
+ import { openWhenSized } from './open-when-sized.ts'
41
+ import { api, type SessionScope, type TerminalDepsStatus } from './api.ts'
42
+ import { agentUuidOf, isAgentTabId, type SidebarStore } from './state.ts'
43
+ import { isDarkScheme, subscribeColorScheme, effectiveTokenValue, tokenValue } from './theme.ts'
44
+ import { resolveTerminalFont } from './terminal-font.ts'
45
+ import css from './sidebar.module.css'
46
+
47
+ /** How many consecutive unreasoned failures before showing the error banner. */
48
+ const FAILURE_LIMIT = 3
49
+
50
+ /**
51
+ * The WS close-code-1011 reason the host sends when node-pty is unavailable
52
+ * (mirror of the host's PTY_DEPS_MISSING; the value is a wire contract, so
53
+ * the two sides keep the literal in lockstep). The view then fetches the
54
+ * full repair details from /sidebar/api/terminal.deps.
55
+ */
56
+ const PTY_DEPS_MISSING = 'pty-deps-missing'
57
+
58
+ /** The degraded-mode payload rendered by {@link TerminalDepsBanner}. */
59
+ type TerminalDepsInfo = Extract<TerminalDepsStatus, { ok: false }>
60
+
61
+ /**
62
+ * Curated ANSI palettes for the terminal. The surface colors (background,
63
+ * foreground, cursor, selection) ride the theme tokens so the terminal
64
+ * blends with the panel in both schemes; the 16 ANSI colors are the same
65
+ * designed palettes the app's code surfaces use (one-dark family for dark,
66
+ * one-light family for light), read live so a scheme flip re-themes in
67
+ * place.
68
+ */
69
+ const ANSI_DARK: Record<string, string> = {
70
+ black: '#282c34', red: '#e06c75', green: '#98c379', yellow: '#e5c07b',
71
+ blue: '#61afef', magenta: '#c678dd', cyan: '#56b6c2', white: '#abb2bf',
72
+ brightBlack: '#5c6370', brightRed: '#e06c75', brightGreen: '#98c379',
73
+ brightYellow: '#e5c07b', brightBlue: '#61afef', brightMagenta: '#c678dd',
74
+ brightCyan: '#56b6c2', brightWhite: '#ffffff',
75
+ }
76
+
77
+ const ANSI_LIGHT: Record<string, string> = {
78
+ black: '#383a42', red: '#e45649', green: '#50a14f', yellow: '#c18401',
79
+ blue: '#0184bc', magenta: '#a626a4', cyan: '#0997b3', white: '#a0a1a7',
80
+ brightBlack: '#4f525e', brightRed: '#e45649', brightGreen: '#50a14f',
81
+ brightYellow: '#c18401', brightBlue: '#0184bc', brightMagenta: '#a626a4',
82
+ brightCyan: '#0997b3', brightWhite: '#fafafa',
83
+ }
84
+
85
+ /** The xterm theme for the current scheme (surface from tokens, ANSI curated). */
86
+ function xtermTheme(): ITheme {
87
+ const dark = isDarkScheme()
88
+ // Skin systems set --dsw-alias-bg-base to `transparent` or translucent
89
+ // glass values (the dsh-web-ui skins use rgba 0.16–0.7); effectiveTokenValue
90
+ // treats those as unset below the opacity floor, so the opaque fallback
91
+ // engages and the terminal never renders see-through over the skin's
92
+ // backdrop (issue #90). Effectively opaque scoped surfaces (e.g. a skin's
93
+ // 0.96 porcelain) pass through — the skin still controls the terminal.
94
+ const background = effectiveTokenValue('--dsw-alias-bg-base') || (dark ? '#111114' : '#ffffff')
95
+ const foreground = effectiveTokenValue('--dsw-alias-label-primary') || (dark ? '#e6e6e6' : '#1a1a1a')
96
+ return {
97
+ background,
98
+ foreground,
99
+ cursor: foreground,
100
+ cursorAccent: background,
101
+ selectionBackground: dark ? 'rgba(255,255,255,0.22)' : 'rgba(0,0,0,0.12)',
102
+ ...(dark ? ANSI_DARK : ANSI_LIGHT),
103
+ }
104
+ }
105
+
106
+ export function TerminalView(props: { scope: SessionScope; tabId: string; store: SidebarStore }) {
107
+ const { scope, tabId, store } = props
108
+ const hostRef = useRef<HTMLDivElement>(null)
109
+ const [connected, setConnected] = useState(false)
110
+ const [fatal, setFatal] = useState<string | null>(null)
111
+ const [depsFatal, setDepsFatal] = useState<TerminalDepsInfo | null>(null)
112
+ const [lastUrl, setLastUrl] = useState<string | null>(null)
113
+ const connectRef = useRef<(() => void) | null>(null)
114
+
115
+ useEffect(() => {
116
+ const host = hostRef.current
117
+ if (host === null) return
118
+ // The custom font prefs (side card settings, terminal card) resolve at
119
+ // mount; store changes re-apply them live below.
120
+ const font = resolveTerminalFont(store.getPrefs(), tokenValue('--ds-font-family-code'))
121
+ const term = new Terminal({
122
+ cursorBlink: true,
123
+ fontSize: font.fontSize,
124
+ fontFamily: font.fontFamily,
125
+ allowTransparency: true,
126
+ convertEol: false,
127
+ scrollback: 4000,
128
+ theme: xtermTheme(),
129
+ })
130
+ const fit = new FitAddon()
131
+ term.loadAddon(fit)
132
+ // Re-theme in place when the app's scheme flips (tokens + palette).
133
+ const applyTheme = (): void => {
134
+ term.options.theme = xtermTheme()
135
+ term.refresh(0, term.rows - 1)
136
+ }
137
+ const schemeSub = subscribeColorScheme(applyTheme)
138
+
139
+ let socket: WebSocket | null = null
140
+ let closed = false
141
+ let retry: number | undefined
142
+ let failures = 0
143
+
144
+ const wsUrl = (): string => {
145
+ const url = new URL('/sidebar/ws/terminal', location.origin)
146
+ url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:'
147
+ // Agent terminals attach by uuid (the host looks them up in the agent
148
+ // pty registry); UI-tab terminals attach by sessionId+tab (the host
149
+ // uses the UI-tab pty manager). Same upgrade endpoint, different query.
150
+ if (isAgentTabId(tabId)) {
151
+ url.search = new URLSearchParams({ uuid: agentUuidOf(tabId) }).toString()
152
+ } else {
153
+ const params = new URLSearchParams({ sessionId: scope.sessionId, tab: tabId })
154
+ if (scope.cwd !== undefined && scope.cwd !== '') params.set('cwd', scope.cwd)
155
+ url.search = params.toString()
156
+ }
157
+ // Same construction the app's own downlink WebSockets use (new URL
158
+ // over location.origin + protocol swap): whatever the environment
159
+ // does to the app's websockets applies identically here.
160
+ return url.toString()
161
+ }
162
+
163
+ const sendResize = (): void => {
164
+ if (socket !== null && socket.readyState === WebSocket.OPEN) {
165
+ socket.send(JSON.stringify({ type: 'resize', cols: term.cols, rows: term.rows }))
166
+ }
167
+ }
168
+
169
+ const connect = (): void => {
170
+ if (closed) return
171
+ const url = wsUrl()
172
+ setLastUrl(url)
173
+ socket = new WebSocket(url)
174
+ socket.onopen = () => {
175
+ failures = 0
176
+ setConnected(true)
177
+ setFatal(null)
178
+ sendResize()
179
+ }
180
+ socket.onmessage = (event) => {
181
+ if (typeof event.data === 'string') term.write(event.data)
182
+ }
183
+ socket.onclose = (event) => {
184
+ setConnected(false)
185
+ // node-pty dependency missing/broken (issue #140): the host closed
186
+ // with the short marker. Fetch the full repair details over HTTP —
187
+ // a WS close reason is capped at 123 bytes, too small for the
188
+ // pasteable command. A failed fetch falls back to the plain banner.
189
+ if (event.code === 1011 && event.reason === PTY_DEPS_MISSING) {
190
+ void api.terminalDeps().then((status) => {
191
+ if (status.ok) {
192
+ // The host recovered between the close and the fetch — the
193
+ // plain banner with a retry is the honest state.
194
+ setFatal(t('terminalDepsFailed'))
195
+ return
196
+ }
197
+ setFatal(null)
198
+ setDepsFatal(status)
199
+ }).catch(() => {
200
+ setFatal(t('terminalDepsFailed'))
201
+ })
202
+ return
203
+ }
204
+ // A server-side refusal carries a close code + reason; retrying it
205
+ // forever would only spin the banner, so surface it with a retry.
206
+ if (event.code === 1011 && event.reason !== '') {
207
+ setFatal(event.reason)
208
+ return
209
+ }
210
+ // Unreasoned drops (upgrade rejected, host down, mid-handshake
211
+ // refusal) normally recover on the next attempt; after a few
212
+ // consecutive failures stop spinning and show the close code.
213
+ failures += 1
214
+ if (failures >= FAILURE_LIMIT) {
215
+ const detail = event.reason !== '' ? ` (${event.code}: ${event.reason})` : ` (${event.code})`
216
+ console.error('[dsh-better-sidebar] terminal connection failed:', event.code, event.reason, url)
217
+ setFatal(`${t('terminalConnectFailed')}${detail}`)
218
+ return
219
+ }
220
+ if (!closed) retry = window.setTimeout(connect, 2000)
221
+ }
222
+ socket.onerror = () => {
223
+ socket?.close()
224
+ }
225
+ }
226
+ connectRef.current = connect
227
+
228
+ const inputSub = term.onData((data) => {
229
+ if (socket !== null && socket.readyState === WebSocket.OPEN) socket.send(data)
230
+ })
231
+ const observer = new ResizeObserver(() => {
232
+ try {
233
+ fit.fit()
234
+ sendResize()
235
+ } catch {
236
+ // The terminal may be mid-dispose; ignore.
237
+ }
238
+ })
239
+ observer.observe(host)
240
+
241
+ // Custom font prefs (the terminal card's secondary settings) apply LIVE:
242
+ // on any store change re-resolve and diff the two options, re-fitting
243
+ // when they moved (the grid dimensions may change with the font). The
244
+ // subscribe fires on every store change (tabs, panels…), so the diff is
245
+ // what keeps this cheap.
246
+ const fontSub = store.subscribe(() => {
247
+ const next = resolveTerminalFont(store.getPrefs(), tokenValue('--ds-font-family-code'))
248
+ if (next.fontFamily !== term.options.fontFamily || next.fontSize !== term.options.fontSize) {
249
+ term.options.fontFamily = next.fontFamily
250
+ term.options.fontSize = next.fontSize
251
+ try {
252
+ fit.fit()
253
+ sendResize()
254
+ } catch {
255
+ // The terminal may be mid-dispose; ignore.
256
+ }
257
+ }
258
+ })
259
+
260
+ // The terminal must not be opened in a zero-size container: xterm's
261
+ // renderer creation fails there and the next Viewport refresh crashes
262
+ // reading `.dimensions` off the undefined renderer (blank terminal on
263
+ // WKWebView when the bottom panel's expand slide leaves the host at
264
+ // height 0; any display:none-hidden ancestor does the same). Defer
265
+ // open+fit until the host has a real size — writes arriving meanwhile
266
+ // are buffered by xterm's WriteBuffer and render once open, and
267
+ // FitAddon.fit() is a safe no-op before open. sendResize() here covers
268
+ // the deferred path where the socket may already be open with the
269
+ // default 80x24 dims.
270
+ const cancelOpen = openWhenSized(host, () => {
271
+ try {
272
+ term.open(host)
273
+ fit.fit()
274
+ sendResize()
275
+ } catch (error) {
276
+ console.error('[dsh-better-sidebar] xterm open failed:', error)
277
+ }
278
+ })
279
+
280
+ connect()
281
+ return () => {
282
+ closed = true
283
+ cancelOpen()
284
+ window.clearTimeout(retry)
285
+ observer.disconnect()
286
+ fontSub()
287
+ schemeSub()
288
+ inputSub.dispose()
289
+ // Three unmount cases, distinguished by the store's tab/open state and
290
+ // the active session id:
291
+ // 1. The tab was closed by the user (NOT in its session's state): send
292
+ // `{type:'close'}` — the host releases the pty immediately.
293
+ // 2. The user switched to another conversation (the tab IS still open
294
+ // in scope.sessionId's state, but the active session is now a
295
+ // different one): send `{type:'park'}` — the host keeps the pty
296
+ // alive indefinitely (no grace countdown), so switching back
297
+ // reattaches the SAME shell. Without this, the bare socket drop
298
+ // would start the 30s reconnect-grace countdown and kill the shell
299
+ // while the user is still actively working in the other session.
300
+ // 3. A same-session unmount (page refresh, crash, plugin teardown, a
301
+ // re-render that re-mounts the view): bare socket drop — the host's
302
+ // reconnect grace keeps the shell alive for a quick reconnect.
303
+ // Agent terminals follow the close-frame rule; their lifetime is owned
304
+ // by the agent, so a bare drop (case 3) already leaves them alive
305
+ // indefinitely — no park frame needed.
306
+ const tabStillOpen = store.tabOpen(scope.sessionId, tabId)
307
+ const sessionSwitched = store.getSnapshot().sessionId !== scope.sessionId
308
+ if (!tabStillOpen
309
+ && socket !== null && socket.readyState === WebSocket.OPEN) {
310
+ socket.send(JSON.stringify({ type: 'close' }))
311
+ } else if (tabStillOpen && sessionSwitched && !isAgentTabId(tabId)
312
+ && socket !== null && socket.readyState === WebSocket.OPEN) {
313
+ socket.send(JSON.stringify({ type: 'park' }))
314
+ }
315
+ socket?.close()
316
+ term.dispose()
317
+ connectRef.current = null
318
+ }
319
+ }, [scope.sessionId, scope.cwd, tabId, store])
320
+
321
+ return (
322
+ <div className={css.terminalWrap}>
323
+ {depsFatal !== null && (
324
+ <TerminalDepsBanner deps={depsFatal} onRetry={() => { setDepsFatal(null); connectRef.current?.() }} />
325
+ )}
326
+ {fatal !== null && (
327
+ <div className={css.terminalBanner}>
328
+ {t('terminalError')}: {fatal}
329
+ {lastUrl !== null && <div className={css.terminalBannerUrl}>{lastUrl}</div>}
330
+ <button
331
+ type="button"
332
+ className={css.terminalRetry}
333
+ onClick={() => { setFatal(null); connectRef.current?.() }}
334
+ >
335
+ {t('terminalRetry')}
336
+ </button>
337
+ </div>
338
+ )}
339
+ {fatal === null && depsFatal === null && !connected && <div className={css.terminalBanner}>{t('disconnected')}</div>}
340
+ <div ref={hostRef} className={css.terminal} />
341
+ </div>
342
+ )
343
+ }
344
+
345
+ /**
346
+ * The node-pty dependency failure banner (issue #140): explains that the
347
+ * terminal's native dependency failed to load and shows the PASTEABLE repair
348
+ * command (bash / cmd / PowerShell) with a copy button — the user pastes it
349
+ * into a terminal where their DSH profile lives and runs it, then retries.
350
+ * Extracted as a standalone component for direct testing.
351
+ */
352
+ export function TerminalDepsBanner(props: { deps: TerminalDepsInfo; onRetry: () => void }) {
353
+ const { deps, onRetry } = props
354
+ const [copied, setCopied] = useState(false)
355
+ const copy = async (): Promise<void> => {
356
+ const written = await writeClipboard(deps.command)
357
+ if (written) {
358
+ setCopied(true)
359
+ window.setTimeout(() => setCopied(false), 2000)
360
+ }
361
+ }
362
+ return (
363
+ <div className={css.terminalDepsBanner}>
364
+ <div className={css.terminalDepsTitle}>{t('terminalDepsFailed')}</div>
365
+ <div className={css.terminalDepsHint}>
366
+ {t('terminalDepsHint')}
367
+ {deps.profile !== null ? t('terminalDepsProfile', { profile: deps.profile }) : ''}
368
+ </div>
369
+ <div className={css.terminalDepsCommandRow}>
370
+ <pre className={css.terminalRepairCommand}>{deps.command}</pre>
371
+ <button type="button" className={css.terminalRetry} onClick={() => { void copy() }} aria-label={t('copy')}>
372
+ {copied ? t('copied') : t('copy')}
373
+ </button>
374
+ </div>
375
+ {deps.note !== undefined && <div className={css.terminalDepsNote}>{deps.note}</div>}
376
+ <div className={css.terminalDepsActions}>
377
+ <button type="button" className={css.terminalRetry} onClick={onRetry}>
378
+ {t('terminalRetry')}
379
+ </button>
380
+ </div>
381
+ </div>
382
+ )
383
+ }