dsh-coding-sidebar 1.0.7 → 1.0.8
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/README.md +2 -2
- package/lib/client-editor.js +436 -227
- package/lib/client-registry.js +627 -7391
- package/lib/client-terminal.js +234 -182
- package/lib/client.js +634 -7398
- package/lib/index.js +367 -67
- package/lib/types/agent-pty.d.ts +62 -4
- package/lib/types/bundle-route.d.ts +1 -1
- package/lib/types/client/EditorHost.d.ts +1 -1
- package/lib/types/client/FileTree.d.ts +2 -2
- package/lib/types/client/TreePanel.d.ts +1 -1
- package/lib/types/client/chunk-loader.d.ts +1 -1
- package/lib/types/client/chunks/locale.d.ts +3 -0
- package/lib/types/client/conversation-draft.d.ts +85 -4
- package/lib/types/client/locales.d.ts +1 -20
- package/lib/types/client/selection-popup.d.ts +58 -0
- package/lib/types/client/service.d.ts +1 -1
- package/lib/types/client/terminal-font.d.ts +25 -2
- package/lib/types/context-types.d.ts +3 -1
- package/lib/types/index.d.ts +9 -0
- package/lib/types/prefs-shared.d.ts +7 -5
- package/lib/types/pty-manager.d.ts +53 -0
- package/lib/types/wire.d.ts +6 -2
- package/package.json +1 -1
- package/src/agent-pty.ts +221 -33
- package/src/bundle-route.ts +1 -1
- package/src/client/EditorHost.tsx +1 -1
- package/src/client/FileTree.tsx +4 -4
- package/src/client/Sidebar.tsx +41 -22
- package/src/client/TerminalView.tsx +13 -0
- package/src/client/TextEditor.tsx +27 -45
- package/src/client/TreePanel.tsx +16 -1
- package/src/client/chunk-loader.ts +1 -1
- package/src/client/chunks/locale.tsx +60 -0
- package/src/client/conversation-draft.ts +233 -7
- package/src/client/index.tsx +19 -8
- package/src/client/locales-ar.ts +5 -4
- package/src/client/locales-de.ts +5 -4
- package/src/client/locales-fr.ts +5 -4
- package/src/client/locales-hi.ts +5 -4
- package/src/client/locales-id.ts +5 -4
- package/src/client/locales-it.ts +5 -4
- package/src/client/locales-ja.ts +5 -4
- package/src/client/locales-ko.ts +5 -4
- package/src/client/locales-nl.ts +5 -4
- package/src/client/locales-pl.ts +5 -4
- package/src/client/locales-pt.ts +5 -4
- package/src/client/locales-ru.ts +5 -4
- package/src/client/locales-sv.ts +5 -4
- package/src/client/locales-th.ts +5 -4
- package/src/client/locales-tr.ts +5 -4
- package/src/client/locales-vi.ts +5 -4
- package/src/client/locales-zh-HK.ts +5 -4
- package/src/client/locales-zh-MO.ts +5 -4
- package/src/client/locales-zh-TW.ts +5 -4
- package/src/client/locales.ts +17 -51
- package/src/client/selection-popup.ts +155 -0
- package/src/client/service.ts +1 -1
- package/src/client/state.ts +14 -7
- package/src/client/terminal-font.ts +34 -3
- package/src/context-types.ts +3 -2
- package/src/git.ts +17 -1
- package/src/index.ts +39 -6
- package/src/open-external.ts +3 -4
- package/src/prefs-shared.ts +7 -5
- package/src/pty-manager.ts +176 -5
- package/src/sidechat-routes.ts +13 -1
- package/src/tools.ts +24 -6
- package/src/wire.ts +3 -0
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The floating "add selection to conversation" popup shared by the text
|
|
3
|
+
* viewer: a viewport-anchored button portaled to `document.body`, kept alive
|
|
4
|
+
* across the selection gesture and committed on click.
|
|
5
|
+
*
|
|
6
|
+
* Dismissal contract (upstream issue #425): the popup must never outlive its
|
|
7
|
+
* editor surface. The sidebar keeps every tab MOUNTED — switching tabs only
|
|
8
|
+
* flips the pane cell to `display:none` and collapsing the panel translates
|
|
9
|
+
* it off-screen — while the portaled `position:fixed` button stays pinned to
|
|
10
|
+
* its viewport anchor, ignoring both. The caller already hides on surface
|
|
11
|
+
* scrolls, selection collapse and content swaps; this hook adds the global
|
|
12
|
+
* dismissal that covers everything else:
|
|
13
|
+
*
|
|
14
|
+
* - any `mousedown` outside the button (tab bar, composer, another pane,
|
|
15
|
+
* the collapse toggle, …) closes it;
|
|
16
|
+
* - `Escape` closes it;
|
|
17
|
+
* - the document going hidden (`visibilitychange`) or the window losing
|
|
18
|
+
* focus closes it;
|
|
19
|
+
* - an `IntersectionObserver` on the editor surface closes it as soon as the
|
|
20
|
+
* surface leaves the viewport — the tab-switch (`display:none`) and
|
|
21
|
+
* panel-collapse (translated off-screen) paths have no DOM events of their
|
|
22
|
+
* own, so the geometry signal is the only reliable one.
|
|
23
|
+
*
|
|
24
|
+
* The button's own `mousedown` is never treated as an outside click: the
|
|
25
|
+
* caller preventDefaults it to keep the selection/caret alive until the
|
|
26
|
+
* click commits (the hook's capture-phase listener runs first and must not
|
|
27
|
+
* hide for it).
|
|
28
|
+
*/
|
|
29
|
+
import { useEffect, useRef, useState, type RefObject } from 'react'
|
|
30
|
+
|
|
31
|
+
/** The floating "add to conversation" action: payload + viewport anchor. */
|
|
32
|
+
export interface SelectionPopup {
|
|
33
|
+
insert: string
|
|
34
|
+
left: number
|
|
35
|
+
top: number
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface SelectionPopupOptions {
|
|
39
|
+
/** Commit the payload into the composer draft (button click). */
|
|
40
|
+
onCommit(insert: string): void
|
|
41
|
+
/**
|
|
42
|
+
* The DOM surface that must stay on screen for the popup to live: the
|
|
43
|
+
* CodeMirror host element. Called lazily (refs are null until the content
|
|
44
|
+
* loads).
|
|
45
|
+
*/
|
|
46
|
+
getSurface(): HTMLElement | null
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface SelectionPopupControls {
|
|
50
|
+
/** The current popup (null = hidden). */
|
|
51
|
+
popup: SelectionPopup | null
|
|
52
|
+
/** Attach to the portaled button element. */
|
|
53
|
+
buttonRef: RefObject<HTMLButtonElement>
|
|
54
|
+
/** Anchor the popup above a selection (viewport-clamped). */
|
|
55
|
+
show(insert: string, left: number, top: number): void
|
|
56
|
+
/** Hide the popup (idempotent). */
|
|
57
|
+
hide(): void
|
|
58
|
+
/** The button's click: commit the stored payload, then hide. */
|
|
59
|
+
commit(): void
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function useSelectionPopup(options: SelectionPopupOptions): SelectionPopupControls {
|
|
63
|
+
// Latest-callback refs: the dismissal listeners live for the mount's
|
|
64
|
+
// lifetime, so they must not capture stale closures across renders.
|
|
65
|
+
const onCommitRef = useRef(options.onCommit)
|
|
66
|
+
const getSurfaceRef = useRef(options.getSurface)
|
|
67
|
+
onCommitRef.current = options.onCommit
|
|
68
|
+
getSurfaceRef.current = options.getSurface
|
|
69
|
+
|
|
70
|
+
const [popup, setPopup] = useState<SelectionPopup | null>(null)
|
|
71
|
+
/** Live mirror for click/event-time reads (no re-render race). */
|
|
72
|
+
const popupRef = useRef<SelectionPopup | null>(null)
|
|
73
|
+
/** The portaled button itself (for the outside-click guard). */
|
|
74
|
+
const buttonRef = useRef<HTMLButtonElement>(null)
|
|
75
|
+
/** The surface visibility observer (created lazily on open). */
|
|
76
|
+
const observerRef = useRef<IntersectionObserver | null>(null)
|
|
77
|
+
|
|
78
|
+
const show = (insert: string, left: number, top: number): void => {
|
|
79
|
+
const next: SelectionPopup = {
|
|
80
|
+
insert,
|
|
81
|
+
left: Math.min(Math.max(left, 80), window.innerWidth - 80),
|
|
82
|
+
top,
|
|
83
|
+
}
|
|
84
|
+
popupRef.current = next
|
|
85
|
+
setPopup(next)
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const hide = (): void => {
|
|
89
|
+
popupRef.current = null
|
|
90
|
+
setPopup(null)
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const commit = (): void => {
|
|
94
|
+
const current = popupRef.current
|
|
95
|
+
if (current === null) return
|
|
96
|
+
onCommitRef.current(current.insert)
|
|
97
|
+
hide()
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Global dismissal listeners, registered once; every handler reads the
|
|
101
|
+
// live popup ref so it no-ops while no popup is open.
|
|
102
|
+
useEffect(() => {
|
|
103
|
+
const onMouseDown = (event: MouseEvent): void => {
|
|
104
|
+
if (popupRef.current === null) return
|
|
105
|
+
const button = buttonRef.current
|
|
106
|
+
if (button !== null && (button === event.target || button.contains(event.target as Node))) return
|
|
107
|
+
hide()
|
|
108
|
+
}
|
|
109
|
+
const onKeyDown = (event: KeyboardEvent): void => {
|
|
110
|
+
if (event.key === 'Escape' && popupRef.current !== null) hide()
|
|
111
|
+
}
|
|
112
|
+
const onVisibilityChange = (): void => {
|
|
113
|
+
if (document.hidden && popupRef.current !== null) hide()
|
|
114
|
+
}
|
|
115
|
+
const onWindowBlur = (): void => {
|
|
116
|
+
if (popupRef.current !== null) hide()
|
|
117
|
+
}
|
|
118
|
+
document.addEventListener('mousedown', onMouseDown, true)
|
|
119
|
+
document.addEventListener('keydown', onKeyDown, true)
|
|
120
|
+
document.addEventListener('visibilitychange', onVisibilityChange)
|
|
121
|
+
window.addEventListener('blur', onWindowBlur)
|
|
122
|
+
return () => {
|
|
123
|
+
document.removeEventListener('mousedown', onMouseDown, true)
|
|
124
|
+
document.removeEventListener('keydown', onKeyDown, true)
|
|
125
|
+
document.removeEventListener('visibilitychange', onVisibilityChange)
|
|
126
|
+
window.removeEventListener('blur', onWindowBlur)
|
|
127
|
+
observerRef.current?.disconnect()
|
|
128
|
+
observerRef.current = null
|
|
129
|
+
}
|
|
130
|
+
}, [])
|
|
131
|
+
|
|
132
|
+
// Re-target the visibility observer whenever a popup opens: the surface
|
|
133
|
+
// refs may have been null at mount (content loads async), so the surface
|
|
134
|
+
// is only trustworthy at open time.
|
|
135
|
+
useEffect(() => {
|
|
136
|
+
if (popup === null) return
|
|
137
|
+
observerRef.current?.disconnect()
|
|
138
|
+
observerRef.current = null
|
|
139
|
+
if (typeof IntersectionObserver === 'undefined') return
|
|
140
|
+
const surface = getSurfaceRef.current()
|
|
141
|
+
if (surface === null) return
|
|
142
|
+
const observer = new IntersectionObserver((entries) => {
|
|
143
|
+
for (const entry of entries) {
|
|
144
|
+
if (!entry.isIntersecting) hide()
|
|
145
|
+
}
|
|
146
|
+
}, { threshold: 0 })
|
|
147
|
+
observerRef.current = observer
|
|
148
|
+
observer.observe(surface)
|
|
149
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps -- the boolean
|
|
150
|
+
// flip is the only thing that must re-run this; an anchor move while
|
|
151
|
+
// open keeps the same surface.
|
|
152
|
+
}, [popup !== null])
|
|
153
|
+
|
|
154
|
+
return { popup, buttonRef, show, hide, commit }
|
|
155
|
+
}
|
package/src/client/service.ts
CHANGED
|
@@ -150,7 +150,7 @@ export interface TabComponentProps {
|
|
|
150
150
|
/** The explorer's reveal-highlight set (ExplorerView; "Show in folder" targets). */
|
|
151
151
|
revealed?: string[]
|
|
152
152
|
onToggleDir?: (path: string) => void
|
|
153
|
-
onReferenceFile?: (path: string) => void
|
|
153
|
+
onReferenceFile?: (path: string, isDir: boolean) => void
|
|
154
154
|
onOpenFile?: (path: string) => void
|
|
155
155
|
onOpenDiff?: (tab: SidebarTab) => void
|
|
156
156
|
onSubagentJump?: (childSessionId: string) => void
|
package/src/client/state.ts
CHANGED
|
@@ -137,7 +137,7 @@ export function mintTabId(): string {
|
|
|
137
137
|
|
|
138
138
|
/**
|
|
139
139
|
* The largest numeric suffix across a raw persisted state's counter ids
|
|
140
|
-
* (`pane:N` / `tab:N` / `split:N`). The uid counter is module-global and
|
|
140
|
+
* (`pane:N` / `tab:N` / `split:N` / `float:N`). The uid counter is module-global and
|
|
141
141
|
* resets on every reload, so a split minted AFTER a reload would collide
|
|
142
142
|
* with the persisted ids (a fresh "pane:1" beside the persisted "pane:1");
|
|
143
143
|
* mapLeaf would then visit BOTH leaves and every open would land in both
|
|
@@ -148,7 +148,7 @@ function maxCounterId(parsed: unknown): number {
|
|
|
148
148
|
let max = 0
|
|
149
149
|
const consider = (id: unknown): void => {
|
|
150
150
|
if (typeof id !== 'string') return
|
|
151
|
-
const match = /^(?:pane|tab|split):(\d+)$/.exec(id)
|
|
151
|
+
const match = /^(?:pane|tab|split|float):(\d+)$/.exec(id)
|
|
152
152
|
if (match !== null) max = Math.max(max, Number(match[1]))
|
|
153
153
|
}
|
|
154
154
|
const walk = (node: unknown): void => {
|
|
@@ -994,6 +994,7 @@ function resetRequested(): boolean {
|
|
|
994
994
|
|
|
995
995
|
function loadState(sessionId: string, prefs: SidebarPrefs): SidebarState {
|
|
996
996
|
const reset = resetRequested()
|
|
997
|
+
const viewport = typeof window !== 'undefined' ? window.innerWidth : undefined
|
|
997
998
|
if (reset) {
|
|
998
999
|
try {
|
|
999
1000
|
localStorage.removeItem(`${STORAGE_PREFIX}:${sessionId}`)
|
|
@@ -1016,7 +1017,15 @@ function loadState(sessionId: string, prefs: SidebarPrefs): SidebarState {
|
|
|
1016
1017
|
nextIdCounter = maxCounterId(parsed)
|
|
1017
1018
|
const sanitized = sanitizeState(parsed)
|
|
1018
1019
|
if (sanitized !== undefined) {
|
|
1019
|
-
|
|
1020
|
+
const restored = globalWidth === undefined ? sanitized : { ...sanitized, width: globalWidth }
|
|
1021
|
+
// A narrow panel is a full-screen drawer, so every page/session load
|
|
1022
|
+
// starts it closed even when the prior desktop/mobile state persisted
|
|
1023
|
+
// `panelOpen: true`. Preserve the entire restored workbench (panes,
|
|
1024
|
+
// tabs, and free windows); visibility is the sole viewport-derived
|
|
1025
|
+
// override.
|
|
1026
|
+
return viewport !== undefined && isNarrowWidth(viewport) && restored.panelOpen
|
|
1027
|
+
? { ...restored, panelOpen: false }
|
|
1028
|
+
: restored
|
|
1020
1029
|
}
|
|
1021
1030
|
}
|
|
1022
1031
|
} catch {
|
|
@@ -1031,10 +1040,8 @@ function loadState(sessionId: string, prefs: SidebarPrefs): SidebarState {
|
|
|
1031
1040
|
// disabled editor type seeds nothing. On a NARROW viewport a brand-new
|
|
1032
1041
|
// session starts collapsed instead — the panel is a full-screen drawer
|
|
1033
1042
|
// there, and auto-opening it on first paint would cover the conversation
|
|
1034
|
-
// before the user asked.
|
|
1035
|
-
//
|
|
1036
|
-
// state.
|
|
1037
|
-
const viewport = typeof window !== 'undefined' ? window.innerWidth : undefined
|
|
1043
|
+
// before the user asked. Persisted layouts follow the same narrow-load
|
|
1044
|
+
// visibility rule above, while their workbench contents remain intact.
|
|
1038
1045
|
const width = globalWidth ?? (viewport === undefined
|
|
1039
1046
|
? PANEL_DEFAULT
|
|
1040
1047
|
: defaultWidthFor(viewport, prefs.defaultWidthPercent))
|
|
@@ -198,12 +198,43 @@ function usableBase(value: string | undefined): string {
|
|
|
198
198
|
return trimmed
|
|
199
199
|
}
|
|
200
200
|
|
|
201
|
+
/**
|
|
202
|
+
* Guarantee the stack ends in a generic family, so a font the browser cannot
|
|
203
|
+
* resolve degrades to a monospace one.
|
|
204
|
+
*
|
|
205
|
+
* Without this a stack of unresolvable names (a misspelled family, or one only
|
|
206
|
+
* installed on the machine running the shell rather than the one running the
|
|
207
|
+
* browser) falls through to the browser's *standard* font, which is
|
|
208
|
+
* proportional — xterm then measures its cell from a proportional advance and
|
|
209
|
+
* the whole grid breaks, rather than merely losing the requested typeface.
|
|
210
|
+
*
|
|
211
|
+
* This does not sniff whether the named families exist (an explicit non-goal
|
|
212
|
+
* of the terminal font design): it only terminates the stack. A stack that
|
|
213
|
+
* already names a generic family is returned untouched, so a user who
|
|
214
|
+
* deliberately wrote one keeps it. Generics are matched unquoted only — a
|
|
215
|
+
* quoted `"monospace"` is a family *name*, not the keyword.
|
|
216
|
+
*
|
|
217
|
+
* @param stack - the resolved font-family value.
|
|
218
|
+
* @returns the stack, ending in a generic family.
|
|
219
|
+
*/
|
|
220
|
+
export function withMonospaceFallback(stack: string): string {
|
|
221
|
+
const families = splitFamilies(stack)
|
|
222
|
+
if (families.length === 0) return DEFAULT_TERMINAL_FONT_FAMILY
|
|
223
|
+
const only = families.length === 1 ? families[0] : undefined
|
|
224
|
+
if (only !== undefined && CSS_WIDE_KEYWORDS.has(only.toLowerCase())) return DEFAULT_TERMINAL_FONT_FAMILY
|
|
225
|
+
if (families.some((family) => GENERIC_FAMILIES.has(family.toLowerCase()))) return families.join(', ')
|
|
226
|
+
return `${families.join(', ')}, monospace`
|
|
227
|
+
}
|
|
228
|
+
|
|
201
229
|
/**
|
|
202
230
|
* Resolve the xterm font options for the given prefs.
|
|
203
231
|
*
|
|
204
232
|
* The base family keeps its existing precedence — user pref > theme code
|
|
205
|
-
* font > built-in stack
|
|
206
|
-
*
|
|
233
|
+
* font > built-in stack. {@link withMonospaceFallback} then terminates the
|
|
234
|
+
* stack with a generic family so an unresolvable name degrades to a
|
|
235
|
+
* monospace instead of the proportional standard font, and finally
|
|
236
|
+
* {@link withIconFontFallbacks} tops it up so prompt icons resolve
|
|
237
|
+
* regardless of which base won.
|
|
207
238
|
*
|
|
208
239
|
* @param prefs - the current side card preferences.
|
|
209
240
|
* @param themeFontFamily - the app's theme code font (`--ds-font-family-code`
|
|
@@ -218,7 +249,7 @@ export function resolveTerminalFont(
|
|
|
218
249
|
|| usableBase(themeFontFamily)
|
|
219
250
|
|| DEFAULT_TERMINAL_FONT_FAMILY
|
|
220
251
|
return {
|
|
221
|
-
fontFamily: withIconFontFallbacks(base),
|
|
252
|
+
fontFamily: withIconFontFallbacks(withMonospaceFallback(base)),
|
|
222
253
|
fontSize: clampTerminalFontSize(prefs.terminalFontSize),
|
|
223
254
|
}
|
|
224
255
|
}
|
package/src/context-types.ts
CHANGED
|
@@ -428,9 +428,10 @@ export interface SidebarLocaleService {
|
|
|
428
428
|
|
|
429
429
|
/** The composer draft face the sidebar reaches through `ctx.conversation.input`. */
|
|
430
430
|
export interface SidebarSessionInput {
|
|
431
|
-
/** The live input store (draft read for append).
|
|
431
|
+
/** The live input store (draft read for append). `draftRev` is the machine's
|
|
432
|
+
* span-CAS revision — required to mint a structured file-reference chip. */
|
|
432
433
|
state: {
|
|
433
|
-
getSnapshot(): { draft: string }
|
|
434
|
+
getSnapshot(): { draft: string; draftRev?: number }
|
|
434
435
|
}
|
|
435
436
|
/** Replace the draft text (the input machine's single public write path). */
|
|
436
437
|
setDraft(text: string): void
|
package/src/git.ts
CHANGED
|
@@ -323,11 +323,27 @@ function pathIdentity(path: string): string {
|
|
|
323
323
|
return process.platform === 'win32' ? absolute.toLowerCase() : absolute
|
|
324
324
|
}
|
|
325
325
|
|
|
326
|
+
/** Whether the current Git binary supports NUL-framed `worktree list` output.
|
|
327
|
+
* Git < 2.36 rejects `-z`; cache the capability after the first attempt so
|
|
328
|
+
* the SCM panel's polling does not repeatedly spawn a command known to fail. */
|
|
329
|
+
let worktreeListSupportsZ: boolean | undefined
|
|
330
|
+
|
|
326
331
|
/** Raw usable checkout records, shared by inventory and target validation.
|
|
327
332
|
* Prunable records point at missing paths and are deliberately excluded from
|
|
328
333
|
* both the selector and the command-target allowlist. */
|
|
329
334
|
async function listedWorktrees(cwd: string): Promise<GitWorktreeRecord[]> {
|
|
330
|
-
|
|
335
|
+
let raw: string
|
|
336
|
+
if (worktreeListSupportsZ === false) {
|
|
337
|
+
raw = await runGit(cwd, ['worktree', 'list', '--porcelain'])
|
|
338
|
+
} else {
|
|
339
|
+
try {
|
|
340
|
+
raw = await runGit(cwd, ['worktree', 'list', '--porcelain', '-z'])
|
|
341
|
+
worktreeListSupportsZ = true
|
|
342
|
+
} catch {
|
|
343
|
+
worktreeListSupportsZ = false
|
|
344
|
+
raw = await runGit(cwd, ['worktree', 'list', '--porcelain'])
|
|
345
|
+
}
|
|
346
|
+
}
|
|
331
347
|
return parseWorktreeList(raw).filter(entry => !entry.prunable)
|
|
332
348
|
}
|
|
333
349
|
|
package/src/index.ts
CHANGED
|
@@ -40,7 +40,7 @@ import { launchExternal } from './open-external.ts'
|
|
|
40
40
|
import * as git from './git.ts'
|
|
41
41
|
import { SettingsConflictError, type SettingsNamespace } from '@deepseek-ai/dsh-settings'
|
|
42
42
|
import { defaultShell, ensureSpawnHelper, PtyManager, shellDisplayName } from './pty-manager.ts'
|
|
43
|
-
import { AgentPtyRegistry,
|
|
43
|
+
import { AgentPtyRegistry, armPtyResizeGate, tryResizePty, type AgentTerminalHandle } from './agent-pty.ts'
|
|
44
44
|
import {
|
|
45
45
|
DSH_NODE_PTY_RANGE,
|
|
46
46
|
depsStatus,
|
|
@@ -1036,6 +1036,38 @@ async function attachAgentList(
|
|
|
1036
1036
|
}
|
|
1037
1037
|
}
|
|
1038
1038
|
|
|
1039
|
+
/**
|
|
1040
|
+
* The WS close reason for a failed terminal attach. A missing configured
|
|
1041
|
+
* shell gets a SHORT machine-readable marker (`shell-not-found:<name>`,
|
|
1042
|
+
* capped by BYTES — a WS close reason allows at most 123 bytes, which `ws`
|
|
1043
|
+
* validates with `Buffer.byteLength`) that the client maps to a localized,
|
|
1044
|
+
* actionable banner; every other failure keeps the raw message (the
|
|
1045
|
+
* model-side tool errors read it verbatim).
|
|
1046
|
+
*/
|
|
1047
|
+
export function wsCloseReasonOf(error: unknown): string {
|
|
1048
|
+
if (error instanceof SidebarError && error.code === 'shell-not-found') {
|
|
1049
|
+
const name = truncateUtf8Bytes(shellDisplayName(String(error.meta?.shell ?? '')), 100)
|
|
1050
|
+
return `shell-not-found:${name}`
|
|
1051
|
+
}
|
|
1052
|
+
return error instanceof Error ? error.message : String(error)
|
|
1053
|
+
}
|
|
1054
|
+
|
|
1055
|
+
/**
|
|
1056
|
+
* Truncate to at most `maxBytes` UTF-8 bytes without splitting a code point.
|
|
1057
|
+
* A character-count `slice` does not bound the WS close reason: `ws` measures
|
|
1058
|
+
* `Buffer.byteLength` against its 123-byte cap, and the resulting throw would
|
|
1059
|
+
* replace the very error the reason describes.
|
|
1060
|
+
*/
|
|
1061
|
+
function truncateUtf8Bytes(value: string, maxBytes: number): string {
|
|
1062
|
+
if (Buffer.byteLength(value) <= maxBytes) return value
|
|
1063
|
+
let truncated = ''
|
|
1064
|
+
for (const character of value) {
|
|
1065
|
+
if (Buffer.byteLength(truncated + character) > maxBytes) break
|
|
1066
|
+
truncated += character
|
|
1067
|
+
}
|
|
1068
|
+
return truncated
|
|
1069
|
+
}
|
|
1070
|
+
|
|
1039
1071
|
/**
|
|
1040
1072
|
* Wire one terminal socket to its pty: replay transcript, pump both ways.
|
|
1041
1073
|
* Two attach modes share the wire protocol:
|
|
@@ -1097,6 +1129,9 @@ async function attachTerminal(
|
|
|
1097
1129
|
// terminals opened from now on (existing pty handles keep their shell).
|
|
1098
1130
|
const overrides = shellOverridesOf(getSettings)
|
|
1099
1131
|
const handle = ptyManager.open(sessionId, tabId, cwd, 80, 24, overrides.shell, overrides.shellArgs)
|
|
1132
|
+
// Windows pre-ready gate for the resize frames this socket may deliver
|
|
1133
|
+
// (see armPtyResizeGate; inert on POSIX).
|
|
1134
|
+
armPtyResizeGate(handle.pty)
|
|
1100
1135
|
// Replay the transcript, then follow live output.
|
|
1101
1136
|
if (handle.transcript !== '') ws.send(handle.transcript)
|
|
1102
1137
|
const onData = (data: string): void => {
|
|
@@ -1143,8 +1178,7 @@ async function attachTerminal(
|
|
|
1143
1178
|
&& control.type === 'resize'
|
|
1144
1179
|
&& typeof control.cols === 'number' && typeof control.rows === 'number'
|
|
1145
1180
|
) {
|
|
1146
|
-
|
|
1147
|
-
handle.pty.resize(dims.cols, dims.rows)
|
|
1181
|
+
tryResizePty(handle.pty, control.cols, control.rows)
|
|
1148
1182
|
} else {
|
|
1149
1183
|
handle.pty.write(text)
|
|
1150
1184
|
}
|
|
@@ -1162,7 +1196,7 @@ async function attachTerminal(
|
|
|
1162
1196
|
}
|
|
1163
1197
|
})
|
|
1164
1198
|
} catch (error) {
|
|
1165
|
-
ws.close(1011,
|
|
1199
|
+
ws.close(1011, wsCloseReasonOf(error))
|
|
1166
1200
|
}
|
|
1167
1201
|
}
|
|
1168
1202
|
|
|
@@ -1212,8 +1246,7 @@ function pumpAgentTerminal(
|
|
|
1212
1246
|
&& control.type === 'resize'
|
|
1213
1247
|
&& typeof control.cols === 'number' && typeof control.rows === 'number'
|
|
1214
1248
|
) {
|
|
1215
|
-
|
|
1216
|
-
handle.pty.resize(dims.cols, dims.rows)
|
|
1249
|
+
tryResizePty(handle.pty, control.cols, control.rows)
|
|
1217
1250
|
} else if (control === null) {
|
|
1218
1251
|
// Raw text input (a JSON-looking string the pty would have received
|
|
1219
1252
|
// verbatim is reachable in theory but is exotic for an agent terminal;
|
package/src/open-external.ts
CHANGED
|
@@ -28,11 +28,10 @@ export function revealCommand(path: string, platform: NodeJS.Platform = process.
|
|
|
28
28
|
switch (platform) {
|
|
29
29
|
case 'darwin':
|
|
30
30
|
return { command: 'open', args: ['-R', path] }
|
|
31
|
-
//
|
|
32
|
-
//
|
|
33
|
-
// `cmd /c start "" explorer.exe "/select,<path>"` as the fallback.
|
|
31
|
+
// Explorer expects `/select,<path>` as one argument. Keep the spawn
|
|
32
|
+
// shell-free: a command shell would reinterpret valid path characters.
|
|
34
33
|
case 'win32':
|
|
35
|
-
return { command: 'explorer.exe', args: [
|
|
34
|
+
return { command: 'explorer.exe', args: [`/select,${path}`] }
|
|
36
35
|
default: {
|
|
37
36
|
const parent = parentOf(path)
|
|
38
37
|
return { command: 'xdg-open', args: [parent ?? path] }
|
package/src/prefs-shared.ts
CHANGED
|
@@ -16,14 +16,16 @@ export interface SidebarPrefs {
|
|
|
16
16
|
/** Default panel width as a percent of the window width (20–60). */
|
|
17
17
|
defaultWidthPercent: number
|
|
18
18
|
/**
|
|
19
|
-
* Whether the sidebar auto-activates
|
|
20
|
-
*
|
|
19
|
+
* Whether the sidebar auto-activates the Subagent page when the current
|
|
20
|
+
* conversation spawns a new subagent. Wide viewports also open the panel;
|
|
21
|
+
* narrow viewports prepare the tab without opening the full-screen drawer.
|
|
21
22
|
*/
|
|
22
23
|
autoOpenSubagent: boolean
|
|
23
24
|
/**
|
|
24
|
-
* Whether the sidebar auto-activates
|
|
25
|
-
*
|
|
26
|
-
*
|
|
25
|
+
* Whether the sidebar auto-activates the Jobs page when a NEW background
|
|
26
|
+
* job appears for the current conversation (any new job id, not just the
|
|
27
|
+
* first one). Wide viewports also open the panel; narrow viewports prepare
|
|
28
|
+
* the tab without opening the full-screen drawer.
|
|
27
29
|
*/
|
|
28
30
|
autoOpenJobs: boolean
|
|
29
31
|
/**
|