dsh-better-sidebar 0.10.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 +139 -0
- package/README_EN.md +139 -0
- package/lib/client-docx.js +7649 -0
- package/lib/client-editor.js +32677 -0
- package/lib/client-pptx.js +63438 -0
- package/lib/client-registry.js +8021 -0
- package/lib/client-terminal.js +8060 -0
- package/lib/client-xlsx.js +359755 -0
- package/lib/client.js +8021 -0
- package/lib/index.js +2604 -0
- package/lib/invariant.js +22 -0
- package/lib/types/agent-pty.d.ts +220 -0
- package/lib/types/browser-probe.d.ts +15 -0
- package/lib/types/bundle-route.d.ts +13 -0
- package/lib/types/client/BrowserView.d.ts +22 -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 +10 -0
- package/lib/types/client/ExplorerView.d.ts +9 -0
- package/lib/types/client/GitView.d.ts +8 -0
- package/lib/types/client/OrphanedTab.d.ts +11 -0
- package/lib/types/client/PdfView.d.ts +6 -0
- package/lib/types/client/PptxView.d.ts +6 -0
- package/lib/types/client/SandboxStatusBar.d.ts +10 -0
- package/lib/types/client/SideCardSection.d.ts +27 -0
- package/lib/types/client/Sidebar.d.ts +6 -0
- package/lib/types/client/SubagentView.d.ts +16 -0
- package/lib/types/client/TabBar.d.ts +38 -0
- package/lib/types/client/TerminalView.d.ts +8 -0
- package/lib/types/client/TextEditor.d.ts +10 -0
- package/lib/types/client/api.d.ts +170 -0
- package/lib/types/client/binary-download.d.ts +12 -0
- package/lib/types/client/breakpoints.d.ts +12 -0
- package/lib/types/client/browser.d.ts +50 -0
- package/lib/types/client/builtins/index.d.ts +17 -0
- package/lib/types/client/builtins/tabs.d.ts +6 -0
- package/lib/types/client/builtins/viewers.d.ts +3 -0
- package/lib/types/client/chunk-loader.d.ts +74 -0
- package/lib/types/client/chunks/docx.d.ts +8 -0
- package/lib/types/client/chunks/editor.d.ts +9 -0
- package/lib/types/client/chunks/pptx.d.ts +8 -0
- package/lib/types/client/chunks/terminal.d.ts +9 -0
- package/lib/types/client/chunks/xlsx.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/docx-view.d.ts +8 -0
- package/lib/types/client/editor-load.d.ts +66 -0
- package/lib/types/client/icons.d.ts +47 -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 +10 -0
- package/lib/types/client/intercept.d.ts +20 -0
- package/lib/types/client/lang.d.ts +12 -0
- package/lib/types/client/lazy-chunk.d.ts +25 -0
- package/lib/types/client/link-intercept.d.ts +35 -0
- package/lib/types/client/locales.d.ts +237 -0
- package/lib/types/client/office-shared.d.ts +33 -0
- package/lib/types/client/office-types.d.ts +36 -0
- package/lib/types/client/office-view.d.ts +12 -0
- package/lib/types/client/openpath-intercept.d.ts +41 -0
- package/lib/types/client/paths.d.ts +19 -0
- package/lib/types/client/pdf-types.d.ts +2 -0
- package/lib/types/client/prefs.d.ts +30 -0
- package/lib/types/client/produced-files.d.ts +27 -0
- package/lib/types/client/selection-payload.d.ts +27 -0
- package/lib/types/client/service.d.ts +214 -0
- package/lib/types/client/split-pane.d.ts +29 -0
- package/lib/types/client/state.d.ts +322 -0
- package/lib/types/client/subagent-activity.d.ts +35 -0
- package/lib/types/client/subagent-detect.d.ts +47 -0
- package/lib/types/client/subagent-jobs.d.ts +63 -0
- package/lib/types/client/theme.d.ts +28 -0
- package/lib/types/client/xlsx-to-univer.d.ts +33 -0
- package/lib/types/client/xlsx-view.d.ts +10 -0
- package/lib/types/config.d.ts +41 -0
- package/lib/types/context-types.d.ts +431 -0
- package/lib/types/fs-tree.d.ts +41 -0
- package/lib/types/git.d.ts +75 -0
- package/lib/types/html-route.d.ts +48 -0
- package/lib/types/index.d.ts +39 -0
- package/lib/types/invariant.d.ts +15 -0
- package/lib/types/jobs-routes.d.ts +45 -0
- package/lib/types/prefs-shared.d.ts +106 -0
- package/lib/types/pty-manager.d.ts +76 -0
- package/lib/types/tools.d.ts +28 -0
- package/lib/types/trust-fence.d.ts +24 -0
- package/lib/types/wire.d.ts +38 -0
- package/package.json +168 -0
- package/src/agent-pty.ts +519 -0
- package/src/browser-probe.ts +26 -0
- package/src/bundle-route.ts +130 -0
- package/src/client/BrowserView.tsx +253 -0
- package/src/client/DiffTab.tsx +110 -0
- package/src/client/DiffView.tsx +266 -0
- package/src/client/EditorHost.tsx +101 -0
- package/src/client/ExplorerView.tsx +283 -0
- package/src/client/GitView.tsx +540 -0
- package/src/client/OrphanedTab.tsx +27 -0
- package/src/client/PdfView.tsx +110 -0
- package/src/client/PptxView.tsx +131 -0
- package/src/client/SandboxStatusBar.tsx +60 -0
- package/src/client/SideCardSection.module.css +271 -0
- package/src/client/SideCardSection.tsx +426 -0
- package/src/client/Sidebar.tsx +861 -0
- package/src/client/SubagentView.module.css +547 -0
- package/src/client/SubagentView.tsx +866 -0
- package/src/client/TabBar.tsx +188 -0
- package/src/client/TerminalView.tsx +233 -0
- package/src/client/TextEditor.tsx +382 -0
- package/src/client/api.ts +215 -0
- package/src/client/binary-download.tsx +23 -0
- package/src/client/breakpoints.ts +51 -0
- package/src/client/browser.ts +119 -0
- package/src/client/builtins/index.ts +33 -0
- package/src/client/builtins/tabs.tsx +211 -0
- package/src/client/builtins/viewers.tsx +151 -0
- package/src/client/chunk-loader.ts +196 -0
- package/src/client/chunks/docx.tsx +8 -0
- package/src/client/chunks/editor.tsx +9 -0
- package/src/client/chunks/pptx.tsx +8 -0
- package/src/client/chunks/terminal.tsx +9 -0
- package/src/client/chunks/xlsx.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/docx-view.tsx +113 -0
- package/src/client/editor-load.ts +92 -0
- package/src/client/icons.tsx +137 -0
- package/src/client/image-types.ts +8 -0
- package/src/client/ime-guard.ts +47 -0
- package/src/client/index.tsx +238 -0
- package/src/client/intercept.tsx +94 -0
- package/src/client/lang.ts +95 -0
- package/src/client/layout.css +54 -0
- package/src/client/lazy-chunk.tsx +89 -0
- package/src/client/link-intercept.ts +66 -0
- package/src/client/locales.ts +476 -0
- package/src/client/office-shared.tsx +40 -0
- package/src/client/office-types.ts +48 -0
- package/src/client/office-view.tsx +12 -0
- package/src/client/openpath-intercept.ts +62 -0
- package/src/client/paths.ts +28 -0
- package/src/client/pdf-types.ts +4 -0
- package/src/client/prefs.ts +100 -0
- package/src/client/produced-files.ts +87 -0
- package/src/client/selection-payload.ts +86 -0
- package/src/client/service.ts +434 -0
- package/src/client/sidebar.module.css +1917 -0
- package/src/client/split-pane.tsx +292 -0
- package/src/client/state.ts +1048 -0
- package/src/client/subagent-activity.ts +67 -0
- package/src/client/subagent-detect.ts +125 -0
- package/src/client/subagent-jobs.ts +161 -0
- package/src/client/theme.ts +44 -0
- package/src/client/xlsx-to-univer.ts +260 -0
- package/src/client/xlsx-view.tsx +116 -0
- package/src/config.ts +95 -0
- package/src/context-types.ts +445 -0
- package/src/fs-tree.ts +115 -0
- package/src/git.ts +236 -0
- package/src/html-route.ts +70 -0
- package/src/index.ts +897 -0
- package/src/invariant.ts +32 -0
- package/src/jobs-routes.ts +257 -0
- package/src/prefs-shared.ts +127 -0
- package/src/pty-manager.ts +199 -0
- package/src/tools.ts +477 -0
- package/src/trust-fence.ts +77 -0
- package/src/wire.ts +94 -0
|
@@ -0,0 +1,188 @@
|
|
|
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.
|
|
7
|
+
*/
|
|
8
|
+
import { useEffect, useState, type ReactNode } from 'react'
|
|
9
|
+
import clsx from 'clsx'
|
|
10
|
+
import {
|
|
11
|
+
IconCloseFill14, IconPlusOutline16, Menu,
|
|
12
|
+
} from '@deepseek-ai/dsh-client-ui-primitives'
|
|
13
|
+
import type { SidebarTab } from './state.ts'
|
|
14
|
+
import { t } from './locales.ts'
|
|
15
|
+
import css from './sidebar.module.css'
|
|
16
|
+
|
|
17
|
+
/** One + menu option. */
|
|
18
|
+
export interface NewTabOption {
|
|
19
|
+
id: string
|
|
20
|
+
label: string
|
|
21
|
+
disabled?: boolean
|
|
22
|
+
/** Leading icon (Menu row). */
|
|
23
|
+
icon?: ReactNode
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Drag payload for tab moves (HTML5 DnD dataTransfer). */
|
|
27
|
+
export const TAB_DRAG_TYPE = 'application/x-dsh-tab'
|
|
28
|
+
|
|
29
|
+
export interface TabDragPayload {
|
|
30
|
+
tabId: string
|
|
31
|
+
paneId: string
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function serializeDrag(payload: TabDragPayload): string {
|
|
35
|
+
return JSON.stringify(payload)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function parseDrag(raw: string): TabDragPayload | null {
|
|
39
|
+
try {
|
|
40
|
+
const parsed = JSON.parse(raw) as TabDragPayload
|
|
41
|
+
if (typeof parsed.tabId === 'string' && typeof parsed.paneId === 'string') return parsed
|
|
42
|
+
return null
|
|
43
|
+
} catch {
|
|
44
|
+
return null
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Global tab-drag flag: PDF iframes become non-interactive synchronously. */
|
|
49
|
+
function setTabDragging(active: boolean): void {
|
|
50
|
+
if (active) document.body.setAttribute('data-dsh-tab-dragging', '')
|
|
51
|
+
else document.body.removeAttribute('data-dsh-tab-dragging')
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function TabBar(props: {
|
|
55
|
+
paneId: string
|
|
56
|
+
tabs: SidebarTab[]
|
|
57
|
+
active: string | null
|
|
58
|
+
onActivate: (tabId: string) => void
|
|
59
|
+
onClose: (tabId: string) => void
|
|
60
|
+
onNewTab: (optionId: string) => void
|
|
61
|
+
newTabOptions: NewTabOption[]
|
|
62
|
+
/** Drop of a tab from any pane: (payload, insertBeforeTabId | null). */
|
|
63
|
+
onDropTab: (payload: TabDragPayload, before: string | null) => void
|
|
64
|
+
/** Icon resolver for tab labels (reads from the tab descriptor registry). */
|
|
65
|
+
getTabIcon?: (tab: SidebarTab) => ReactNode
|
|
66
|
+
}) {
|
|
67
|
+
const {
|
|
68
|
+
paneId, tabs, active, onActivate, onClose, onNewTab, newTabOptions, onDropTab, getTabIcon,
|
|
69
|
+
} = props
|
|
70
|
+
const [menuOpen, setMenuOpen] = useState(false)
|
|
71
|
+
const [dragOver, setDragOver] = useState(false)
|
|
72
|
+
|
|
73
|
+
useEffect(() => {
|
|
74
|
+
const clear = (): void => { setTabDragging(false); setDragOver(false) }
|
|
75
|
+
window.addEventListener('dragend', clear, true)
|
|
76
|
+
window.addEventListener('drop', clear, true)
|
|
77
|
+
window.addEventListener('blur', clear)
|
|
78
|
+
return () => {
|
|
79
|
+
window.removeEventListener('dragend', clear, true)
|
|
80
|
+
window.removeEventListener('drop', clear, true)
|
|
81
|
+
window.removeEventListener('blur', clear)
|
|
82
|
+
}
|
|
83
|
+
}, [])
|
|
84
|
+
|
|
85
|
+
return (
|
|
86
|
+
<div
|
|
87
|
+
className={clsx(css.tabBar, dragOver && css.tabBarDrop)}
|
|
88
|
+
onDragOver={(event) => {
|
|
89
|
+
// The strip owns drops on itself (merge into this pane); stopping
|
|
90
|
+
// propagation keeps the pane root from also running its edge-zone
|
|
91
|
+
// handler on the same drop.
|
|
92
|
+
event.preventDefault()
|
|
93
|
+
event.stopPropagation()
|
|
94
|
+
setDragOver(true)
|
|
95
|
+
}}
|
|
96
|
+
onDragLeave={() => { setDragOver(false) }}
|
|
97
|
+
onDrop={(event) => {
|
|
98
|
+
event.preventDefault()
|
|
99
|
+
event.stopPropagation()
|
|
100
|
+
setDragOver(false)
|
|
101
|
+
setTabDragging(false)
|
|
102
|
+
const raw = event.dataTransfer.getData(TAB_DRAG_TYPE)
|
|
103
|
+
const payload = parseDrag(raw)
|
|
104
|
+
if (payload !== null) onDropTab(payload, null)
|
|
105
|
+
}}
|
|
106
|
+
>
|
|
107
|
+
<div className={css.tabList}>
|
|
108
|
+
{tabs.map(tab => (
|
|
109
|
+
<div
|
|
110
|
+
key={tab.id}
|
|
111
|
+
className={clsx(css.tab, active === tab.id && css.tabActive)}
|
|
112
|
+
title={tab.title}
|
|
113
|
+
draggable
|
|
114
|
+
onDragStart={(event) => {
|
|
115
|
+
setTabDragging(true)
|
|
116
|
+
event.dataTransfer.setData(TAB_DRAG_TYPE, serializeDrag({ tabId: tab.id, paneId }))
|
|
117
|
+
event.dataTransfer.effectAllowed = 'move'
|
|
118
|
+
}}
|
|
119
|
+
onDragEnd={() => { setTabDragging(false); setDragOver(false) }}
|
|
120
|
+
onDragOver={(event) => { event.preventDefault(); event.stopPropagation() }}
|
|
121
|
+
onDrop={(event) => {
|
|
122
|
+
event.preventDefault()
|
|
123
|
+
event.stopPropagation()
|
|
124
|
+
setTabDragging(false)
|
|
125
|
+
const raw = event.dataTransfer.getData(TAB_DRAG_TYPE)
|
|
126
|
+
const payload = parseDrag(raw)
|
|
127
|
+
if (payload !== null) onDropTab(payload, tab.id)
|
|
128
|
+
}}
|
|
129
|
+
onClick={() => { onActivate(tab.id) }}
|
|
130
|
+
onAuxClick={(event) => {
|
|
131
|
+
// Middle-click closes the tab (and suppresses autoscroll).
|
|
132
|
+
if (event.button === 1) {
|
|
133
|
+
event.preventDefault()
|
|
134
|
+
onClose(tab.id)
|
|
135
|
+
}
|
|
136
|
+
}}
|
|
137
|
+
>
|
|
138
|
+
{getTabIcon?.(tab) ?? null}
|
|
139
|
+
<span className={css.tabTitle}>{tab.title}</span>
|
|
140
|
+
<button
|
|
141
|
+
type="button"
|
|
142
|
+
className={css.tabClose}
|
|
143
|
+
aria-label={t('close')}
|
|
144
|
+
onClick={(event) => {
|
|
145
|
+
event.stopPropagation()
|
|
146
|
+
onClose(tab.id)
|
|
147
|
+
}}
|
|
148
|
+
>
|
|
149
|
+
<IconCloseFill14 />
|
|
150
|
+
</button>
|
|
151
|
+
</div>
|
|
152
|
+
))}
|
|
153
|
+
{/*
|
|
154
|
+
The + sits immediately after the rightmost tab (sticky at the
|
|
155
|
+
right edge of the scrollport when the tabs overflow, so it stays
|
|
156
|
+
reachable no matter how many tabs are open).
|
|
157
|
+
*/}
|
|
158
|
+
<Menu
|
|
159
|
+
open={menuOpen}
|
|
160
|
+
onClose={() => { setMenuOpen(false) }}
|
|
161
|
+
items={newTabOptions.map(option => ({
|
|
162
|
+
id: option.id,
|
|
163
|
+
label: option.label,
|
|
164
|
+
...(option.disabled === true ? { disabled: true } : {}),
|
|
165
|
+
...(option.icon !== undefined ? { icon: option.icon } : {}),
|
|
166
|
+
}))}
|
|
167
|
+
onSelect={(id) => {
|
|
168
|
+
onNewTab(id)
|
|
169
|
+
setMenuOpen(false)
|
|
170
|
+
}}
|
|
171
|
+
portal
|
|
172
|
+
align="end"
|
|
173
|
+
anchor={(
|
|
174
|
+
<button
|
|
175
|
+
type="button"
|
|
176
|
+
className={css.tabBarPlus}
|
|
177
|
+
aria-label={t('newTab')}
|
|
178
|
+
title={t('newTab')}
|
|
179
|
+
onClick={() => { setMenuOpen(v => !v) }}
|
|
180
|
+
>
|
|
181
|
+
<IconPlusOutline16 />
|
|
182
|
+
</button>
|
|
183
|
+
)}
|
|
184
|
+
/>
|
|
185
|
+
</div>
|
|
186
|
+
</div>
|
|
187
|
+
)
|
|
188
|
+
}
|
|
@@ -0,0 +1,233 @@
|
|
|
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
|
+
* Two attach modes share one upgrade endpoint:
|
|
12
|
+
* - `tabId` starting with `agent:` is an agent-owned terminal (created by
|
|
13
|
+
* the `terminal_create` tool). The uuid is the suffix after `agent:`; the
|
|
14
|
+
* view connects with `?uuid=...`. A close frame kills the pty (the agent's
|
|
15
|
+
* terminal closes when the user closes the tab); a bare socket drop
|
|
16
|
+
* leaves the pty alive (the agent owns the lifetime).
|
|
17
|
+
* - Any other `tabId` is a UI-tab terminal (the user created it from the +
|
|
18
|
+
* menu). The view connects with `?tab=...&sessionId=...&cwd=...`. A close
|
|
19
|
+
* frame schedules a 0-ms close; a bare socket drop gets the host's
|
|
20
|
+
* reconnect grace.
|
|
21
|
+
*/
|
|
22
|
+
import { useEffect, useRef, useState } from 'react'
|
|
23
|
+
import { Terminal, type ITheme } from 'xterm'
|
|
24
|
+
import { FitAddon } from '@xterm/addon-fit'
|
|
25
|
+
import 'xterm/css/xterm.css'
|
|
26
|
+
import { t } from './locales.ts'
|
|
27
|
+
import type { SessionScope } from './api.ts'
|
|
28
|
+
import { agentUuidOf, isAgentTabId, type SidebarStore } from './state.ts'
|
|
29
|
+
import { isDarkScheme, subscribeColorScheme, tokenValue } from './theme.ts'
|
|
30
|
+
import css from './sidebar.module.css'
|
|
31
|
+
|
|
32
|
+
/** How many consecutive unreasoned failures before showing the error banner. */
|
|
33
|
+
const FAILURE_LIMIT = 3
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Curated ANSI palettes for the terminal. The surface colors (background,
|
|
37
|
+
* foreground, cursor, selection) ride the theme tokens so the terminal
|
|
38
|
+
* blends with the panel in both schemes; the 16 ANSI colors are the same
|
|
39
|
+
* designed palettes the app's code surfaces use (one-dark family for dark,
|
|
40
|
+
* one-light family for light), read live so a scheme flip re-themes in
|
|
41
|
+
* place.
|
|
42
|
+
*/
|
|
43
|
+
const ANSI_DARK: Record<string, string> = {
|
|
44
|
+
black: '#282c34', red: '#e06c75', green: '#98c379', yellow: '#e5c07b',
|
|
45
|
+
blue: '#61afef', magenta: '#c678dd', cyan: '#56b6c2', white: '#abb2bf',
|
|
46
|
+
brightBlack: '#5c6370', brightRed: '#e06c75', brightGreen: '#98c379',
|
|
47
|
+
brightYellow: '#e5c07b', brightBlue: '#61afef', brightMagenta: '#c678dd',
|
|
48
|
+
brightCyan: '#56b6c2', brightWhite: '#ffffff',
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const ANSI_LIGHT: Record<string, string> = {
|
|
52
|
+
black: '#383a42', red: '#e45649', green: '#50a14f', yellow: '#c18401',
|
|
53
|
+
blue: '#0184bc', magenta: '#a626a4', cyan: '#0997b3', white: '#a0a1a7',
|
|
54
|
+
brightBlack: '#4f525e', brightRed: '#e45649', brightGreen: '#50a14f',
|
|
55
|
+
brightYellow: '#c18401', brightBlue: '#0184bc', brightMagenta: '#a626a4',
|
|
56
|
+
brightCyan: '#0997b3', brightWhite: '#fafafa',
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** The xterm theme for the current scheme (surface from tokens, ANSI curated). */
|
|
60
|
+
function xtermTheme(): ITheme {
|
|
61
|
+
const dark = isDarkScheme()
|
|
62
|
+
const background = tokenValue('--dsw-alias-bg-base') || (dark ? '#111114' : '#ffffff')
|
|
63
|
+
const foreground = tokenValue('--dsw-alias-label-primary') || (dark ? '#e6e6e6' : '#1a1a1a')
|
|
64
|
+
return {
|
|
65
|
+
background,
|
|
66
|
+
foreground,
|
|
67
|
+
cursor: foreground,
|
|
68
|
+
cursorAccent: background,
|
|
69
|
+
selectionBackground: dark ? 'rgba(255,255,255,0.22)' : 'rgba(0,0,0,0.12)',
|
|
70
|
+
...(dark ? ANSI_DARK : ANSI_LIGHT),
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function TerminalView(props: { scope: SessionScope; tabId: string; store: SidebarStore }) {
|
|
75
|
+
const { scope, tabId, store } = props
|
|
76
|
+
const hostRef = useRef<HTMLDivElement>(null)
|
|
77
|
+
const [connected, setConnected] = useState(false)
|
|
78
|
+
const [fatal, setFatal] = useState<string | null>(null)
|
|
79
|
+
const [lastUrl, setLastUrl] = useState<string | null>(null)
|
|
80
|
+
const connectRef = useRef<(() => void) | null>(null)
|
|
81
|
+
|
|
82
|
+
useEffect(() => {
|
|
83
|
+
const host = hostRef.current
|
|
84
|
+
if (host === null) return
|
|
85
|
+
const term = new Terminal({
|
|
86
|
+
cursorBlink: true,
|
|
87
|
+
fontSize: 13,
|
|
88
|
+
fontFamily: tokenValue('--ds-font-family-code') || '"SF Mono", Menlo, Consolas, "Liberation Mono", monospace',
|
|
89
|
+
allowTransparency: true,
|
|
90
|
+
convertEol: false,
|
|
91
|
+
scrollback: 4000,
|
|
92
|
+
theme: xtermTheme(),
|
|
93
|
+
})
|
|
94
|
+
const fit = new FitAddon()
|
|
95
|
+
term.loadAddon(fit)
|
|
96
|
+
term.open(host)
|
|
97
|
+
fit.fit()
|
|
98
|
+
// Re-theme in place when the app's scheme flips (tokens + palette).
|
|
99
|
+
const applyTheme = (): void => {
|
|
100
|
+
term.options.theme = xtermTheme()
|
|
101
|
+
term.refresh(0, term.rows - 1)
|
|
102
|
+
}
|
|
103
|
+
const schemeSub = subscribeColorScheme(applyTheme)
|
|
104
|
+
|
|
105
|
+
let socket: WebSocket | null = null
|
|
106
|
+
let closed = false
|
|
107
|
+
let retry: number | undefined
|
|
108
|
+
let failures = 0
|
|
109
|
+
|
|
110
|
+
const wsUrl = (): string => {
|
|
111
|
+
const url = new URL('/sidebar/ws/terminal', location.origin)
|
|
112
|
+
url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:'
|
|
113
|
+
// Agent terminals attach by uuid (the host looks them up in the agent
|
|
114
|
+
// pty registry); UI-tab terminals attach by sessionId+tab (the host
|
|
115
|
+
// uses the UI-tab pty manager). Same upgrade endpoint, different query.
|
|
116
|
+
if (isAgentTabId(tabId)) {
|
|
117
|
+
url.search = new URLSearchParams({ uuid: agentUuidOf(tabId) }).toString()
|
|
118
|
+
} else {
|
|
119
|
+
const params = new URLSearchParams({ sessionId: scope.sessionId, tab: tabId })
|
|
120
|
+
if (scope.cwd !== undefined && scope.cwd !== '') params.set('cwd', scope.cwd)
|
|
121
|
+
url.search = params.toString()
|
|
122
|
+
}
|
|
123
|
+
// Same construction the app's own downlink WebSockets use (new URL
|
|
124
|
+
// over location.origin + protocol swap): whatever the environment
|
|
125
|
+
// does to the app's websockets applies identically here.
|
|
126
|
+
return url.toString()
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const sendResize = (): void => {
|
|
130
|
+
if (socket !== null && socket.readyState === WebSocket.OPEN) {
|
|
131
|
+
socket.send(JSON.stringify({ type: 'resize', cols: term.cols, rows: term.rows }))
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const connect = (): void => {
|
|
136
|
+
if (closed) return
|
|
137
|
+
const url = wsUrl()
|
|
138
|
+
setLastUrl(url)
|
|
139
|
+
socket = new WebSocket(url)
|
|
140
|
+
socket.onopen = () => {
|
|
141
|
+
failures = 0
|
|
142
|
+
setConnected(true)
|
|
143
|
+
setFatal(null)
|
|
144
|
+
sendResize()
|
|
145
|
+
}
|
|
146
|
+
socket.onmessage = (event) => {
|
|
147
|
+
if (typeof event.data === 'string') term.write(event.data)
|
|
148
|
+
}
|
|
149
|
+
socket.onclose = (event) => {
|
|
150
|
+
setConnected(false)
|
|
151
|
+
// A server-side refusal carries a close code + reason; retrying it
|
|
152
|
+
// forever would only spin the banner, so surface it with a retry.
|
|
153
|
+
if (event.code === 1011 && event.reason !== '') {
|
|
154
|
+
setFatal(event.reason)
|
|
155
|
+
return
|
|
156
|
+
}
|
|
157
|
+
// Unreasoned drops (upgrade rejected, host down, mid-handshake
|
|
158
|
+
// refusal) normally recover on the next attempt; after a few
|
|
159
|
+
// consecutive failures stop spinning and show the close code.
|
|
160
|
+
failures += 1
|
|
161
|
+
if (failures >= FAILURE_LIMIT) {
|
|
162
|
+
const detail = event.reason !== '' ? ` (${event.code}: ${event.reason})` : ` (${event.code})`
|
|
163
|
+
console.error('[dsh-better-sidebar] terminal connection failed:', event.code, event.reason, url)
|
|
164
|
+
setFatal(`${t('terminalConnectFailed')}${detail}`)
|
|
165
|
+
return
|
|
166
|
+
}
|
|
167
|
+
if (!closed) retry = window.setTimeout(connect, 2000)
|
|
168
|
+
}
|
|
169
|
+
socket.onerror = () => {
|
|
170
|
+
socket?.close()
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
connectRef.current = connect
|
|
174
|
+
|
|
175
|
+
const inputSub = term.onData((data) => {
|
|
176
|
+
if (socket !== null && socket.readyState === WebSocket.OPEN) socket.send(data)
|
|
177
|
+
})
|
|
178
|
+
const observer = new ResizeObserver(() => {
|
|
179
|
+
try {
|
|
180
|
+
fit.fit()
|
|
181
|
+
sendResize()
|
|
182
|
+
} catch {
|
|
183
|
+
// The terminal may be mid-dispose; ignore.
|
|
184
|
+
}
|
|
185
|
+
})
|
|
186
|
+
observer.observe(host)
|
|
187
|
+
|
|
188
|
+
connect()
|
|
189
|
+
return () => {
|
|
190
|
+
closed = true
|
|
191
|
+
window.clearTimeout(retry)
|
|
192
|
+
observer.disconnect()
|
|
193
|
+
schemeSub()
|
|
194
|
+
inputSub.dispose()
|
|
195
|
+
// The close frame tells the host the owning tab is GONE (immediate
|
|
196
|
+
// pty release). A bare unmount — conversation switch, re-render,
|
|
197
|
+
// page unload — leaves the tab open, so the socket drop alone hands
|
|
198
|
+
// the process to the host's reconnect grace: switching back or
|
|
199
|
+
// refreshing reattaches the SAME shell instead of respawning one.
|
|
200
|
+
// (The host respawns on its own when the authoritative cwd changed.)
|
|
201
|
+
// Agent terminals follow the same rule: a close frame kills the pty
|
|
202
|
+
// (the user closed the sidebar tab); a bare socket drop leaves it
|
|
203
|
+
// alive (the agent owns the lifetime).
|
|
204
|
+
if (!store.tabOpen(scope.sessionId, tabId)
|
|
205
|
+
&& socket !== null && socket.readyState === WebSocket.OPEN) {
|
|
206
|
+
socket.send(JSON.stringify({ type: 'close' }))
|
|
207
|
+
}
|
|
208
|
+
socket?.close()
|
|
209
|
+
term.dispose()
|
|
210
|
+
connectRef.current = null
|
|
211
|
+
}
|
|
212
|
+
}, [scope.sessionId, scope.cwd, tabId, store])
|
|
213
|
+
|
|
214
|
+
return (
|
|
215
|
+
<div className={css.terminalWrap}>
|
|
216
|
+
{fatal !== null && (
|
|
217
|
+
<div className={css.terminalBanner}>
|
|
218
|
+
{t('terminalError')}: {fatal}
|
|
219
|
+
{lastUrl !== null && <div className={css.terminalBannerUrl}>{lastUrl}</div>}
|
|
220
|
+
<button
|
|
221
|
+
type="button"
|
|
222
|
+
className={css.terminalRetry}
|
|
223
|
+
onClick={() => { setFatal(null); connectRef.current?.() }}
|
|
224
|
+
>
|
|
225
|
+
{t('terminalRetry')}
|
|
226
|
+
</button>
|
|
227
|
+
</div>
|
|
228
|
+
)}
|
|
229
|
+
{fatal === null && !connected && <div className={css.terminalBanner}>{t('disconnected')}</div>}
|
|
230
|
+
<div ref={hostRef} className={css.terminal} />
|
|
231
|
+
</div>
|
|
232
|
+
)
|
|
233
|
+
}
|