dsh-coding-sidebar 1.0.6 → 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.
Files changed (70) hide show
  1. package/README.md +2 -2
  2. package/lib/client-editor.js +436 -227
  3. package/lib/client-registry.js +639 -7397
  4. package/lib/client-terminal.js +239 -187
  5. package/lib/client.js +644 -7402
  6. package/lib/index.js +367 -67
  7. package/lib/types/agent-pty.d.ts +62 -4
  8. package/lib/types/bundle-route.d.ts +1 -1
  9. package/lib/types/client/EditorHost.d.ts +1 -1
  10. package/lib/types/client/FileTree.d.ts +2 -2
  11. package/lib/types/client/TreePanel.d.ts +1 -1
  12. package/lib/types/client/chunk-loader.d.ts +1 -1
  13. package/lib/types/client/chunks/locale.d.ts +3 -0
  14. package/lib/types/client/conversation-draft.d.ts +85 -4
  15. package/lib/types/client/locales.d.ts +1 -20
  16. package/lib/types/client/selection-popup.d.ts +58 -0
  17. package/lib/types/client/service.d.ts +1 -1
  18. package/lib/types/client/terminal-font.d.ts +25 -2
  19. package/lib/types/context-types.d.ts +3 -1
  20. package/lib/types/index.d.ts +9 -0
  21. package/lib/types/prefs-shared.d.ts +7 -5
  22. package/lib/types/pty-manager.d.ts +53 -0
  23. package/lib/types/wire.d.ts +6 -2
  24. package/package.json +1 -1
  25. package/src/agent-pty.ts +221 -33
  26. package/src/bundle-route.ts +1 -1
  27. package/src/client/BrowserView.tsx +17 -9
  28. package/src/client/EditorHost.tsx +1 -1
  29. package/src/client/FileTree.tsx +4 -4
  30. package/src/client/Sidebar.tsx +41 -22
  31. package/src/client/TerminalView.tsx +13 -0
  32. package/src/client/TextEditor.tsx +27 -45
  33. package/src/client/TreePanel.tsx +16 -1
  34. package/src/client/chunk-loader.ts +1 -1
  35. package/src/client/chunks/locale.tsx +60 -0
  36. package/src/client/conversation-draft.ts +233 -7
  37. package/src/client/index.tsx +19 -8
  38. package/src/client/locales-ar.ts +5 -4
  39. package/src/client/locales-de.ts +5 -4
  40. package/src/client/locales-fr.ts +5 -4
  41. package/src/client/locales-hi.ts +5 -4
  42. package/src/client/locales-id.ts +5 -4
  43. package/src/client/locales-it.ts +5 -4
  44. package/src/client/locales-ja.ts +5 -4
  45. package/src/client/locales-ko.ts +5 -4
  46. package/src/client/locales-nl.ts +5 -4
  47. package/src/client/locales-pl.ts +5 -4
  48. package/src/client/locales-pt.ts +5 -4
  49. package/src/client/locales-ru.ts +5 -4
  50. package/src/client/locales-sv.ts +5 -4
  51. package/src/client/locales-th.ts +5 -4
  52. package/src/client/locales-tr.ts +5 -4
  53. package/src/client/locales-vi.ts +5 -4
  54. package/src/client/locales-zh-HK.ts +5 -4
  55. package/src/client/locales-zh-MO.ts +5 -4
  56. package/src/client/locales-zh-TW.ts +5 -4
  57. package/src/client/locales.ts +17 -51
  58. package/src/client/selection-popup.ts +155 -0
  59. package/src/client/service.ts +1 -1
  60. package/src/client/state.ts +14 -7
  61. package/src/client/terminal-font.ts +34 -3
  62. package/src/context-types.ts +3 -2
  63. package/src/git.ts +17 -1
  64. package/src/index.ts +39 -6
  65. package/src/open-external.ts +3 -4
  66. package/src/prefs-shared.ts +7 -5
  67. package/src/pty-manager.ts +176 -5
  68. package/src/sidechat-routes.ts +13 -1
  69. package/src/tools.ts +24 -6
  70. 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
+ }
@@ -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
@@ -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
- return globalWidth === undefined ? sanitized : { ...sanitized, width: globalWidth }
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. Only the first seeding is affected: once the
1035
- // user expands the drawer, `panelOpen: true` persists like any other
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 — and then {@link withIconFontFallbacks} tops it up
206
- * so prompt icons resolve regardless of which base won.
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
  }
@@ -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
- const raw = await runGit(cwd, ['worktree', 'list', '--porcelain', '-z'])
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, clampDims, type AgentTerminalHandle } from './agent-pty.ts'
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
- const dims = clampDims(control.cols, control.rows)
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, error instanceof Error ? error.message : String(error))
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
- const dims = clampDims(control.cols, control.rows)
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;
@@ -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
- // `explorer /select,<path>` the classic "show in folder" incantation.
32
- // Passed as separate argv entries (no shell); verify on Windows, with
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: ['/select,', path] }
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] }
@@ -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 (opens the panel) and expands the
20
- * Subagent page when the current conversation spawns a new subagent.
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 (opens the panel) and expands the
25
- * Jobs page when a NEW background job appears for the current
26
- * conversation (any new job id, not just the first one).
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
  /**