dsh-coding-sidebar 1.0.7 → 1.0.9
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 +485 -228
- package/lib/client-registry.js +1238 -7583
- package/lib/client-terminal.js +345 -189
- package/lib/client.js +1213 -7558
- package/lib/index.js +518 -69
- package/lib/types/agent-pty.d.ts +62 -4
- package/lib/types/bundle-route.d.ts +1 -1
- package/lib/types/client/DiffView.d.ts +33 -1
- package/lib/types/client/EditorHost.d.ts +4 -1
- package/lib/types/client/FileTree.d.ts +6 -2
- package/lib/types/client/TerminalWaitBanner.d.ts +6 -0
- package/lib/types/client/TreePanel.d.ts +4 -1
- package/lib/types/client/api.d.ts +26 -0
- 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 +13 -20
- package/lib/types/client/selection-popup.d.ts +58 -0
- package/lib/types/client/service.d.ts +1 -1
- package/lib/types/client/state.d.ts +15 -0
- package/lib/types/client/terminal-font.d.ts +25 -2
- package/lib/types/context-types.d.ts +3 -1
- package/lib/types/fs-operations.d.ts +42 -0
- package/lib/types/git.d.ts +15 -0
- 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/DiffTab.tsx +10 -1
- package/src/client/DiffView.tsx +174 -19
- package/src/client/EditorHost.tsx +9 -2
- package/src/client/FileTree.tsx +151 -8
- package/src/client/Sidebar.tsx +95 -25
- package/src/client/TerminalView.tsx +41 -0
- package/src/client/TerminalWaitBanner.tsx +32 -0
- package/src/client/TextEditor.tsx +27 -45
- package/src/client/TreePanel.tsx +22 -2
- package/src/client/api.ts +20 -0
- 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 +17 -4
- package/src/client/locales-de.ts +17 -4
- package/src/client/locales-fr.ts +17 -4
- package/src/client/locales-hi.ts +17 -4
- package/src/client/locales-id.ts +17 -4
- package/src/client/locales-it.ts +17 -4
- package/src/client/locales-ja.ts +17 -4
- package/src/client/locales-ko.ts +17 -4
- package/src/client/locales-nl.ts +17 -4
- package/src/client/locales-pl.ts +17 -4
- package/src/client/locales-pt.ts +17 -4
- package/src/client/locales-ru.ts +17 -4
- package/src/client/locales-sv.ts +17 -4
- package/src/client/locales-th.ts +17 -4
- package/src/client/locales-tr.ts +17 -4
- package/src/client/locales-vi.ts +17 -4
- package/src/client/locales-zh-HK.ts +17 -4
- package/src/client/locales-zh-MO.ts +17 -4
- package/src/client/locales-zh-TW.ts +17 -4
- package/src/client/locales.ts +41 -51
- package/src/client/selection-popup.ts +155 -0
- package/src/client/service.ts +1 -1
- package/src/client/sidebar.module.css +68 -0
- package/src/client/state.ts +56 -10
- package/src/client/terminal-font.ts +34 -3
- package/src/context-types.ts +3 -2
- package/src/fs-operations.ts +126 -4
- package/src/git.ts +56 -3
- package/src/index.ts +82 -7
- 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
package/src/fs-operations.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Workspace-safe file mutations for the sidebar
|
|
2
|
+
* Workspace-safe file mutations for the sidebar: the upload route plus the
|
|
3
|
+
* file tree's rename and delete.
|
|
3
4
|
*
|
|
4
5
|
* Every write is confined to the real session workspace: the upload
|
|
5
6
|
* directory is resolved absolute and its target is checked through existing
|
|
@@ -9,14 +10,21 @@
|
|
|
9
10
|
* to a uniquely named temp sibling
|
|
10
11
|
* and are renamed into place, so a failed, aborted, or oversized upload never
|
|
11
12
|
* leaves a partial file at the target path.
|
|
13
|
+
*
|
|
14
|
+
* The tree's rename/delete (below) are link-aware: existence and containment
|
|
15
|
+
* are verified against the fully resolved target (a symlink pointing outside
|
|
16
|
+
* the workspace is refused), but the operation itself addresses the lexical
|
|
17
|
+
* row path — renaming or deleting a symlink row renames/unlinks the LINK,
|
|
18
|
+
* never its target, matching what the tree row visually names (VS Code
|
|
19
|
+
* semantics). Containment is always enforced (no fence toggle on this fork).
|
|
12
20
|
*/
|
|
13
21
|
import { randomUUID } from 'node:crypto'
|
|
14
22
|
import { once } from 'node:events'
|
|
15
23
|
import { createWriteStream } from 'node:fs'
|
|
16
|
-
import { mkdir, rename, rm, stat } from 'node:fs/promises'
|
|
17
|
-
import { basename, dirname, join } from 'node:path'
|
|
24
|
+
import { access, lstat, mkdir, realpath, rename, rm, stat, unlink } from 'node:fs/promises'
|
|
25
|
+
import { basename, dirname, isAbsolute, join } from 'node:path'
|
|
18
26
|
import type { SidebarHttpRequest } from './context-types.ts'
|
|
19
|
-
import { requireAbsolute } from './fs-tree.ts'
|
|
27
|
+
import { isWithin, requireAbsolute } from './fs-tree.ts'
|
|
20
28
|
import { ensureWorkspacePath, ensureWorkspaceWritePath } from './path-security.ts'
|
|
21
29
|
import { SidebarError } from './wire.ts'
|
|
22
30
|
|
|
@@ -93,3 +101,117 @@ export async function writeWorkspaceUpload(input: WorkspaceUploadInput): Promise
|
|
|
93
101
|
throw error
|
|
94
102
|
}
|
|
95
103
|
}
|
|
104
|
+
|
|
105
|
+
/** Resolve a tree-row path against the session workspace (absolute rows pass through). */
|
|
106
|
+
function resolveRowPath(cwd: string, target: string): string {
|
|
107
|
+
return isAbsolute(target) ? target : join(cwd, target)
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Resolve one existing entry for a link-aware mutation: the lexical row path
|
|
111
|
+
* plus its fully resolved real target (containment-checked). Resolution
|
|
112
|
+
* failures become fs-errors, mirroring path-security's semantics. */
|
|
113
|
+
async function resolveEntry(
|
|
114
|
+
cwd: string,
|
|
115
|
+
target: string,
|
|
116
|
+
): Promise<{ absolute: string; real: string; realCwd: string }> {
|
|
117
|
+
const absolute = requireAbsolute(resolveRowPath(cwd, target))
|
|
118
|
+
let real: string
|
|
119
|
+
let realCwd: string
|
|
120
|
+
try {
|
|
121
|
+
;[realCwd, real] = await Promise.all([realpath(cwd), realpath(absolute)])
|
|
122
|
+
} catch (error) {
|
|
123
|
+
throw new SidebarError('fs-error', `cannot resolve "${target}": ${error instanceof Error ? error.message : String(error)}`, 400)
|
|
124
|
+
}
|
|
125
|
+
if (!isWithin(realCwd, real)) {
|
|
126
|
+
throw new SidebarError('forbidden', `path "${target}" is outside workspace`, 403)
|
|
127
|
+
}
|
|
128
|
+
return { absolute, real, realCwd }
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Whether a path exists (ENOENT → false; other failures propagate). */
|
|
132
|
+
async function pathExists(target: string): Promise<boolean> {
|
|
133
|
+
try {
|
|
134
|
+
await access(target)
|
|
135
|
+
return true
|
|
136
|
+
} catch (error) {
|
|
137
|
+
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return false
|
|
138
|
+
throw error
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** Inputs of one tree-row rename. */
|
|
143
|
+
export interface WorkspaceRenameInput {
|
|
144
|
+
/** The session workspace root; the renamed entry must stay inside it. */
|
|
145
|
+
cwd: string
|
|
146
|
+
/** Absolute path of the row as the tree displays it (may be a symlink). */
|
|
147
|
+
path: string
|
|
148
|
+
/** The new base name (single segment — rename never moves across directories). */
|
|
149
|
+
name: string
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Rename one tree row within its directory: `path` → `<parent>/<name>`.
|
|
154
|
+
* The new name must be a single path segment (this is rename, not move);
|
|
155
|
+
* an existing destination is refused (POSIX rename would clobber it
|
|
156
|
+
* silently); the workspace root itself is never renamable; a symlink row
|
|
157
|
+
* renames the link, not its target. A no-op rename (same name) succeeds
|
|
158
|
+
* without touching the filesystem.
|
|
159
|
+
*
|
|
160
|
+
* @throws SidebarError with a wire code for shape, containment, existence
|
|
161
|
+
* and root failures.
|
|
162
|
+
*/
|
|
163
|
+
export async function renameWorkspaceEntry(input: WorkspaceRenameInput): Promise<{ path: string }> {
|
|
164
|
+
const { cwd, path, name } = input
|
|
165
|
+
if (name === '' || name === '.' || name === '..' || name.includes('/') || name.includes('\\')) {
|
|
166
|
+
throw new SidebarError('bad-request', 'name must be a single path segment', 400)
|
|
167
|
+
}
|
|
168
|
+
const { absolute, real, realCwd } = await resolveEntry(cwd, path)
|
|
169
|
+
if (real === realCwd) {
|
|
170
|
+
throw new SidebarError('fs-error', 'cannot rename the workspace root', 400)
|
|
171
|
+
}
|
|
172
|
+
if (basename(absolute) === name) return { path: absolute }
|
|
173
|
+
const destination = join(dirname(absolute), name)
|
|
174
|
+
const safeDestination = await ensureWorkspaceWritePath(cwd, destination)
|
|
175
|
+
if (await pathExists(safeDestination)) {
|
|
176
|
+
throw new SidebarError('fs-error', `"${name}" already exists`, 409)
|
|
177
|
+
}
|
|
178
|
+
try {
|
|
179
|
+
await rename(absolute, safeDestination)
|
|
180
|
+
} catch (error) {
|
|
181
|
+
throw new SidebarError('fs-error', `cannot rename "${path}" to "${name}": ${error instanceof Error ? error.message : String(error)}`, 400)
|
|
182
|
+
}
|
|
183
|
+
return { path: safeDestination }
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** Inputs of one tree-row delete. */
|
|
187
|
+
export interface WorkspaceRemoveInput {
|
|
188
|
+
/** The session workspace root; the removed entry must stay inside it. */
|
|
189
|
+
cwd: string
|
|
190
|
+
/** Absolute path of the row as the tree displays it (may be a symlink). */
|
|
191
|
+
path: string
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Delete one tree row permanently (there is no trash on the host): files are
|
|
196
|
+
* unlinked, directories removed recursively, a symlink row unlinks the LINK
|
|
197
|
+
* only (lstat decides, so a link to a directory does not recurse into its
|
|
198
|
+
* target). The workspace root itself is never removable.
|
|
199
|
+
*
|
|
200
|
+
* @throws SidebarError with a wire code for containment, existence and
|
|
201
|
+
* root failures.
|
|
202
|
+
*/
|
|
203
|
+
export async function removeWorkspaceEntry(input: WorkspaceRemoveInput): Promise<{ path: string }> {
|
|
204
|
+
const { cwd, path } = input
|
|
205
|
+
const { absolute, real, realCwd } = await resolveEntry(cwd, path)
|
|
206
|
+
if (real === realCwd) {
|
|
207
|
+
throw new SidebarError('fs-error', 'cannot remove the workspace root', 400)
|
|
208
|
+
}
|
|
209
|
+
try {
|
|
210
|
+
const info = await lstat(absolute)
|
|
211
|
+
if (info.isDirectory()) await rm(absolute, { recursive: true })
|
|
212
|
+
else await unlink(absolute)
|
|
213
|
+
} catch (error) {
|
|
214
|
+
throw new SidebarError('fs-error', `cannot remove "${path}": ${error instanceof Error ? error.message : String(error)}`, 400)
|
|
215
|
+
}
|
|
216
|
+
return { path: absolute }
|
|
217
|
+
}
|
package/src/git.ts
CHANGED
|
@@ -9,8 +9,8 @@
|
|
|
9
9
|
* Commits use the user's git global identity untouched (never sets
|
|
10
10
|
* user.name/user.email).
|
|
11
11
|
*/
|
|
12
|
-
import { readdir } from 'node:fs/promises'
|
|
13
|
-
import { join } from 'node:path'
|
|
12
|
+
import { readdir, readFile } from 'node:fs/promises'
|
|
13
|
+
import { isAbsolute, join } from 'node:path'
|
|
14
14
|
import { spawn } from 'node:child_process'
|
|
15
15
|
import { resolve } from 'node:path'
|
|
16
16
|
|
|
@@ -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
|
|
|
@@ -423,6 +439,43 @@ export async function show(cwd: string, rev: string, path: string, selected?: st
|
|
|
423
439
|
}
|
|
424
440
|
}
|
|
425
441
|
|
|
442
|
+
/**
|
|
443
|
+
* Both sides' full file contents for a diff-fold expansion. `path` is
|
|
444
|
+
* repo-relative. The sides resolve per diff kind: a commit reads
|
|
445
|
+
* `<hash>^` vs `<hash>`; a staged change reads HEAD vs the index (`:`);
|
|
446
|
+
* an unstaged change reads HEAD vs the working tree file on disk (a side
|
|
447
|
+
* that does not exist — untracked, deleted, binary-refused — comes back
|
|
448
|
+
* null and the client degrades the fold to a static marker).
|
|
449
|
+
*/
|
|
450
|
+
export async function foldContents(
|
|
451
|
+
cwd: string,
|
|
452
|
+
path: string,
|
|
453
|
+
opts: { staged?: boolean; hash?: string } = {},
|
|
454
|
+
selected?: string,
|
|
455
|
+
): Promise<{ old: string | null; new: string | null }> {
|
|
456
|
+
const root = await repoRoot(cwd, selected)
|
|
457
|
+
if (opts.hash !== undefined) {
|
|
458
|
+
const [old, neu] = await Promise.all([
|
|
459
|
+
show(root, `${opts.hash}^`, path, selected),
|
|
460
|
+
show(root, opts.hash, path, selected),
|
|
461
|
+
])
|
|
462
|
+
return { old, new: neu }
|
|
463
|
+
}
|
|
464
|
+
if (opts.staged === true) {
|
|
465
|
+
// `git show :<path>` reads the index (stage 0) — the staged side.
|
|
466
|
+
const [old, neu] = await Promise.all([
|
|
467
|
+
show(root, 'HEAD', path, selected),
|
|
468
|
+
show(root, ':', path, selected),
|
|
469
|
+
])
|
|
470
|
+
return { old, new: neu }
|
|
471
|
+
}
|
|
472
|
+
const [old, neu] = await Promise.all([
|
|
473
|
+
show(root, 'HEAD', path, selected),
|
|
474
|
+
readFile(isAbsolute(path) ? path : join(root, path), 'utf8').catch(() => null),
|
|
475
|
+
])
|
|
476
|
+
return { old, new: neu }
|
|
477
|
+
}
|
|
478
|
+
|
|
426
479
|
/** Full patch text of one commit (`git show` with the commit header suppressed).
|
|
427
480
|
* Merge commits show their diff against the first parent (`-m --first-parent`
|
|
428
481
|
* is a no-op for regular commits), so a history click always has content. */
|
package/src/index.ts
CHANGED
|
@@ -30,7 +30,7 @@ import {
|
|
|
30
30
|
type SidebarPrefs,
|
|
31
31
|
} from './config.ts'
|
|
32
32
|
import { parentOf, requireAbsolute, listDirectory, rootLabel } from './fs-tree.ts'
|
|
33
|
-
import { writeWorkspaceUpload } from './fs-operations.ts'
|
|
33
|
+
import { removeWorkspaceEntry, renameWorkspaceEntry, writeWorkspaceUpload } from './fs-operations.ts'
|
|
34
34
|
import { ensureWorkspacePath, ensureWorkspaceWritePath } from './path-security.ts'
|
|
35
35
|
import { searchFiles } from './fs-search.ts'
|
|
36
36
|
import { extractFrameAncestors } from './browser-probe.ts'
|
|
@@ -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,
|
|
@@ -351,6 +351,26 @@ function buildApi(
|
|
|
351
351
|
}
|
|
352
352
|
return { ok: true }
|
|
353
353
|
},
|
|
354
|
+
// The tree row's rename: single-segment name, destination-existence and
|
|
355
|
+
// workspace-root refusals, link-aware (renames the row, not its target).
|
|
356
|
+
// fs-operations.ts owns the containment and shape rules.
|
|
357
|
+
'fs.rename': async (payload) => {
|
|
358
|
+
const { cwd } = await cwdOf(payload)
|
|
359
|
+
return renameWorkspaceEntry({
|
|
360
|
+
cwd,
|
|
361
|
+
path: requireString(payload, 'path'),
|
|
362
|
+
name: requireString(payload, 'name'),
|
|
363
|
+
})
|
|
364
|
+
},
|
|
365
|
+
// The tree row's delete (permanent — the host has no trash): recursive
|
|
366
|
+
// for directories, unlinks a symlink row without touching its target.
|
|
367
|
+
'fs.remove': async (payload) => {
|
|
368
|
+
const { cwd } = await cwdOf(payload)
|
|
369
|
+
return removeWorkspaceEntry({
|
|
370
|
+
cwd,
|
|
371
|
+
path: requireString(payload, 'path'),
|
|
372
|
+
})
|
|
373
|
+
},
|
|
354
374
|
'git.worktrees': async (payload) => {
|
|
355
375
|
const { cwd } = await gitCwdOf(payload)
|
|
356
376
|
const selected = selectedRepoOf(payload)
|
|
@@ -438,6 +458,19 @@ function buildApi(
|
|
|
438
458
|
const rev = requireString(payload, 'rev')
|
|
439
459
|
return { content: await git.show(cwd, rev, path, repoRoot) }
|
|
440
460
|
},
|
|
461
|
+
// Diff-fold expansion data: both sides' full file contents so the client
|
|
462
|
+
// can materialize the hidden context rows a -U3 hunk gap omitted. The
|
|
463
|
+
// sides resolve per diff kind (commit hash / staged / unstaged) inside
|
|
464
|
+
// git.foldContents; a missing side is null and the client degrades.
|
|
465
|
+
'git.fold-contents': async (payload) => {
|
|
466
|
+
const { cwd } = await gitCwdOf(payload)
|
|
467
|
+
const repoRoot = selectedRepoOf(payload)
|
|
468
|
+
const record = payload as { path?: unknown; staged?: unknown; hash?: unknown }
|
|
469
|
+
const path = await resolveGitPath(cwd, requireString(record, 'path'), repoRoot)
|
|
470
|
+
const staged = record.staged === true
|
|
471
|
+
const hash = typeof record.hash === 'string' ? record.hash : undefined
|
|
472
|
+
return await git.foldContents(cwd, path, { staged, hash }, repoRoot)
|
|
473
|
+
},
|
|
441
474
|
// Release a terminal immediately. The WebSocket close frame already does
|
|
442
475
|
// this while the socket is open; this route covers the tab-close that
|
|
443
476
|
// happens while the socket is down (reconnect loop), so a closed tab can
|
|
@@ -459,6 +492,15 @@ function buildApi(
|
|
|
459
492
|
agentPtyRegistry?.close(uuid)
|
|
460
493
|
return { ok: true }
|
|
461
494
|
},
|
|
495
|
+
// The sidebar wait banner's skip button: abort every active
|
|
496
|
+
// terminal_wait_for on one agent terminal. Idempotent — 0 when nothing
|
|
497
|
+
// is waiting (a stale banner racing a wait that already resolved).
|
|
498
|
+
// Degraded mode (node-pty unavailable) has no registry and no waits: an
|
|
499
|
+
// honest ok.
|
|
500
|
+
'agent-pty.skip-wait': (payload) => {
|
|
501
|
+
const uuid = requireString(payload, 'uuid')
|
|
502
|
+
return { ok: true, skipped: agentPtyRegistry?.skipWait(uuid) ?? 0 }
|
|
503
|
+
},
|
|
462
504
|
// Terminal dependency status (issue #140): after a WS close 1011 with
|
|
463
505
|
// reason `pty-deps-missing` the client fetches the full repair details
|
|
464
506
|
// here — the close reason itself is capped at 123 bytes, too small for
|
|
@@ -1036,6 +1078,38 @@ async function attachAgentList(
|
|
|
1036
1078
|
}
|
|
1037
1079
|
}
|
|
1038
1080
|
|
|
1081
|
+
/**
|
|
1082
|
+
* The WS close reason for a failed terminal attach. A missing configured
|
|
1083
|
+
* shell gets a SHORT machine-readable marker (`shell-not-found:<name>`,
|
|
1084
|
+
* capped by BYTES — a WS close reason allows at most 123 bytes, which `ws`
|
|
1085
|
+
* validates with `Buffer.byteLength`) that the client maps to a localized,
|
|
1086
|
+
* actionable banner; every other failure keeps the raw message (the
|
|
1087
|
+
* model-side tool errors read it verbatim).
|
|
1088
|
+
*/
|
|
1089
|
+
export function wsCloseReasonOf(error: unknown): string {
|
|
1090
|
+
if (error instanceof SidebarError && error.code === 'shell-not-found') {
|
|
1091
|
+
const name = truncateUtf8Bytes(shellDisplayName(String(error.meta?.shell ?? '')), 100)
|
|
1092
|
+
return `shell-not-found:${name}`
|
|
1093
|
+
}
|
|
1094
|
+
return error instanceof Error ? error.message : String(error)
|
|
1095
|
+
}
|
|
1096
|
+
|
|
1097
|
+
/**
|
|
1098
|
+
* Truncate to at most `maxBytes` UTF-8 bytes without splitting a code point.
|
|
1099
|
+
* A character-count `slice` does not bound the WS close reason: `ws` measures
|
|
1100
|
+
* `Buffer.byteLength` against its 123-byte cap, and the resulting throw would
|
|
1101
|
+
* replace the very error the reason describes.
|
|
1102
|
+
*/
|
|
1103
|
+
function truncateUtf8Bytes(value: string, maxBytes: number): string {
|
|
1104
|
+
if (Buffer.byteLength(value) <= maxBytes) return value
|
|
1105
|
+
let truncated = ''
|
|
1106
|
+
for (const character of value) {
|
|
1107
|
+
if (Buffer.byteLength(truncated + character) > maxBytes) break
|
|
1108
|
+
truncated += character
|
|
1109
|
+
}
|
|
1110
|
+
return truncated
|
|
1111
|
+
}
|
|
1112
|
+
|
|
1039
1113
|
/**
|
|
1040
1114
|
* Wire one terminal socket to its pty: replay transcript, pump both ways.
|
|
1041
1115
|
* Two attach modes share the wire protocol:
|
|
@@ -1097,6 +1171,9 @@ async function attachTerminal(
|
|
|
1097
1171
|
// terminals opened from now on (existing pty handles keep their shell).
|
|
1098
1172
|
const overrides = shellOverridesOf(getSettings)
|
|
1099
1173
|
const handle = ptyManager.open(sessionId, tabId, cwd, 80, 24, overrides.shell, overrides.shellArgs)
|
|
1174
|
+
// Windows pre-ready gate for the resize frames this socket may deliver
|
|
1175
|
+
// (see armPtyResizeGate; inert on POSIX).
|
|
1176
|
+
armPtyResizeGate(handle.pty)
|
|
1100
1177
|
// Replay the transcript, then follow live output.
|
|
1101
1178
|
if (handle.transcript !== '') ws.send(handle.transcript)
|
|
1102
1179
|
const onData = (data: string): void => {
|
|
@@ -1143,8 +1220,7 @@ async function attachTerminal(
|
|
|
1143
1220
|
&& control.type === 'resize'
|
|
1144
1221
|
&& typeof control.cols === 'number' && typeof control.rows === 'number'
|
|
1145
1222
|
) {
|
|
1146
|
-
|
|
1147
|
-
handle.pty.resize(dims.cols, dims.rows)
|
|
1223
|
+
tryResizePty(handle.pty, control.cols, control.rows)
|
|
1148
1224
|
} else {
|
|
1149
1225
|
handle.pty.write(text)
|
|
1150
1226
|
}
|
|
@@ -1162,7 +1238,7 @@ async function attachTerminal(
|
|
|
1162
1238
|
}
|
|
1163
1239
|
})
|
|
1164
1240
|
} catch (error) {
|
|
1165
|
-
ws.close(1011,
|
|
1241
|
+
ws.close(1011, wsCloseReasonOf(error))
|
|
1166
1242
|
}
|
|
1167
1243
|
}
|
|
1168
1244
|
|
|
@@ -1212,8 +1288,7 @@ function pumpAgentTerminal(
|
|
|
1212
1288
|
&& control.type === 'resize'
|
|
1213
1289
|
&& typeof control.cols === 'number' && typeof control.rows === 'number'
|
|
1214
1290
|
) {
|
|
1215
|
-
|
|
1216
|
-
handle.pty.resize(dims.cols, dims.rows)
|
|
1291
|
+
tryResizePty(handle.pty, control.cols, control.rows)
|
|
1217
1292
|
} else if (control === null) {
|
|
1218
1293
|
// Raw text input (a JSON-looking string the pty would have received
|
|
1219
1294
|
// 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
|
/**
|
package/src/pty-manager.ts
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* tab is closed or the plugin tears down.
|
|
8
8
|
*/
|
|
9
9
|
import { chmodSync, existsSync } from 'node:fs'
|
|
10
|
-
import { dirname, join } from 'node:path'
|
|
10
|
+
import { dirname, join, win32 as win32Path } from 'node:path'
|
|
11
11
|
import { createRequire } from 'node:module'
|
|
12
12
|
import { userInfo } from 'node:os'
|
|
13
13
|
import type { IPty } from 'node-pty'
|
|
@@ -153,7 +153,7 @@ export class PtyManager {
|
|
|
153
153
|
sessionId,
|
|
154
154
|
tabId,
|
|
155
155
|
cwd,
|
|
156
|
-
pty: this.nodePty.spawn(shell ?? this.shell, shellSpawnArgs(shellArgs ?? this.shellArgs), {
|
|
156
|
+
pty: this.nodePty.spawn(resolveShellExecutable(shell ?? this.shell), shellSpawnArgs(shellArgs ?? this.shellArgs), {
|
|
157
157
|
name: 'xterm-256color',
|
|
158
158
|
cols: Math.max(2, Math.floor(cols)),
|
|
159
159
|
rows: Math.max(2, Math.floor(rows)),
|
|
@@ -270,6 +270,32 @@ export interface ShellResolutionOptions {
|
|
|
270
270
|
exists?: (path: string) => boolean
|
|
271
271
|
}
|
|
272
272
|
|
|
273
|
+
/** Inputs for resolving one configured shell into the executable path passed
|
|
274
|
+
* to node-pty. Injectable so the Windows-only search semantics stay covered
|
|
275
|
+
* on POSIX CI runners. */
|
|
276
|
+
export interface ShellExecutableResolutionOptions {
|
|
277
|
+
/** Platform override (defaults to `process.platform`). */
|
|
278
|
+
platform?: NodeJS.Platform
|
|
279
|
+
/** Environment override; Windows reads PATH/PATHEXT/SystemRoot plus the
|
|
280
|
+
* PowerShell well-known-location variables. */
|
|
281
|
+
env?: NodeJS.ProcessEnv
|
|
282
|
+
/** File-existence probe override (defaults to `existsSync`). */
|
|
283
|
+
exists?: (path: string) => boolean
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/** Read one Windows environment value case-insensitively. Real
|
|
287
|
+
* `process.env` has case-insensitive lookup on Windows, but injected objects
|
|
288
|
+
* and some embedders do not preserve that behavior. */
|
|
289
|
+
function windowsEnv(env: NodeJS.ProcessEnv, name: string): string | undefined {
|
|
290
|
+
const direct = env[name]
|
|
291
|
+
if (direct !== undefined) return direct
|
|
292
|
+
const lowered = name.toLowerCase()
|
|
293
|
+
for (const [key, value] of Object.entries(env)) {
|
|
294
|
+
if (key.toLowerCase() === lowered) return value
|
|
295
|
+
}
|
|
296
|
+
return undefined
|
|
297
|
+
}
|
|
298
|
+
|
|
273
299
|
/**
|
|
274
300
|
* Candidate directories that may contain a `pwsh.exe` on Windows: PATH
|
|
275
301
|
* entries first, then the well-known machine/user install locations
|
|
@@ -281,7 +307,7 @@ export interface ShellResolutionOptions {
|
|
|
281
307
|
*/
|
|
282
308
|
function windowsPwshCandidateDirs(env: NodeJS.ProcessEnv): string[] {
|
|
283
309
|
const dirs: string[] = []
|
|
284
|
-
const pathEntries = env
|
|
310
|
+
const pathEntries = windowsEnv(env, 'PATH')
|
|
285
311
|
if (pathEntries !== undefined) {
|
|
286
312
|
// The win32 branch always uses the Windows PATH separator; hardcoding it
|
|
287
313
|
// keeps the function testable from POSIX runners without a delimiter
|
|
@@ -291,12 +317,12 @@ function windowsPwshCandidateDirs(env: NodeJS.ProcessEnv): string[] {
|
|
|
291
317
|
if (trimmed !== '') dirs.push(trimmed)
|
|
292
318
|
}
|
|
293
319
|
}
|
|
294
|
-
for (const programFiles of [env
|
|
320
|
+
for (const programFiles of [windowsEnv(env, 'ProgramW6432'), windowsEnv(env, 'ProgramFiles')]) {
|
|
295
321
|
if (programFiles === undefined || programFiles.trim() === '') continue
|
|
296
322
|
dirs.push(join(programFiles, 'PowerShell', '7'))
|
|
297
323
|
dirs.push(join(programFiles, 'PowerShell', '7-preview'))
|
|
298
324
|
}
|
|
299
|
-
const localAppData = env
|
|
325
|
+
const localAppData = windowsEnv(env, 'LOCALAPPDATA')
|
|
300
326
|
if (localAppData !== undefined && localAppData.trim() !== '') {
|
|
301
327
|
dirs.push(join(localAppData, 'Microsoft', 'PowerShell', '7'))
|
|
302
328
|
dirs.push(join(localAppData, 'Microsoft', 'PowerShell', '7-preview'))
|
|
@@ -306,6 +332,95 @@ function windowsPwshCandidateDirs(env: NodeJS.ProcessEnv): string[] {
|
|
|
306
332
|
return [...new Set(dirs)]
|
|
307
333
|
}
|
|
308
334
|
|
|
335
|
+
/**
|
|
336
|
+
* Resolve the configured shell executable before handing it to node-pty.
|
|
337
|
+
*
|
|
338
|
+
* Windows' native backend does not consistently apply the shell's PATHEXT
|
|
339
|
+
* lookup to a bare value (`pwsh` / `cmd` can fail with the opaque
|
|
340
|
+
* `File not found:` error), so perform the lookup ourselves: an explicit
|
|
341
|
+
* path is accepted as-is when it exists (or with a PATHEXT suffix when the
|
|
342
|
+
* user omitted `.exe`), and a bare name is searched through PATH, System32,
|
|
343
|
+
* and PowerShell's known install directories.
|
|
344
|
+
*
|
|
345
|
+
* POSIX node-pty uses `execvp`, so a bare name would already follow PATH —
|
|
346
|
+
* but a wrong name made the pty die with a bare
|
|
347
|
+
* `[process exited with code N]`, so probe like Windows anyway: a path with
|
|
348
|
+
* a separator must exist; a bare name is searched along PATH (the colon
|
|
349
|
+
* form is fixed by the platform). A miss is a clear, actionable
|
|
350
|
+
* `shell-not-found` error instead of a cryptic exit code.
|
|
351
|
+
*
|
|
352
|
+
* @param shell - the configured shell (settings page or yaml `config.shell`).
|
|
353
|
+
* @param options - platform/env/exists injection points for tests.
|
|
354
|
+
* @returns the executable path passed to node-pty.
|
|
355
|
+
* @throws {SidebarError} `shell-not-found` when no candidate exists.
|
|
356
|
+
*/
|
|
357
|
+
export function resolveShellExecutable(
|
|
358
|
+
shell: string,
|
|
359
|
+
options: ShellExecutableResolutionOptions = {},
|
|
360
|
+
): string {
|
|
361
|
+
const configured = unquotePath(shell.trim())
|
|
362
|
+
if (configured === '') return configured
|
|
363
|
+
|
|
364
|
+
const platform = options.platform ?? process.platform
|
|
365
|
+
const env = options.env ?? process.env
|
|
366
|
+
const exists = options.exists ?? existsSync
|
|
367
|
+
const notFound = (): SidebarError =>
|
|
368
|
+
new SidebarError('shell-not-found', `shell executable not found: "${configured}"`, 400, { shell: configured })
|
|
369
|
+
|
|
370
|
+
if (platform === 'win32') {
|
|
371
|
+
const rawPathext = windowsEnv(env, 'PATHEXT')
|
|
372
|
+
const executableExts = (rawPathext ?? '.COM;.EXE')
|
|
373
|
+
.split(';')
|
|
374
|
+
.map(extension => extension.trim())
|
|
375
|
+
// node-pty ultimately calls CreateProcess; batch files need an
|
|
376
|
+
// intermediate cmd.exe and therefore are not valid shell executables.
|
|
377
|
+
.filter(extension => /^\.(?:com|exe)$/i.test(extension))
|
|
378
|
+
if (executableExts.length === 0) executableExts.push('.EXE', '.COM')
|
|
379
|
+
|
|
380
|
+
const hasExtension = win32Path.extname(configured) !== ''
|
|
381
|
+
const names = hasExtension
|
|
382
|
+
? [configured]
|
|
383
|
+
: executableExts.map(extension => configured + extension.toLowerCase())
|
|
384
|
+
const hasPath = win32Path.isAbsolute(configured) || /[\\/]/.test(configured)
|
|
385
|
+
const candidates: string[] = []
|
|
386
|
+
if (hasPath) {
|
|
387
|
+
candidates.push(...names)
|
|
388
|
+
} else {
|
|
389
|
+
const path = windowsEnv(env, 'PATH')
|
|
390
|
+
if (path !== undefined) {
|
|
391
|
+
for (const dir of path.split(';').map(entry => entry.trim()).filter(Boolean)) {
|
|
392
|
+
for (const name of names) candidates.push(win32Path.join(dir, name))
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
const systemRoot = windowsEnv(env, 'SystemRoot')
|
|
396
|
+
if (systemRoot !== undefined && systemRoot.trim() !== '') {
|
|
397
|
+
for (const name of names) candidates.push(win32Path.join(systemRoot, 'System32', name))
|
|
398
|
+
}
|
|
399
|
+
if (/^pwsh(?:\.exe)?$/i.test(configured)) {
|
|
400
|
+
for (const dir of windowsPwshCandidateDirs(env)) {
|
|
401
|
+
candidates.push(win32Path.join(dir, 'pwsh.exe'))
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
for (const candidate of [...new Set(candidates)]) {
|
|
407
|
+
if (exists(candidate)) return candidate
|
|
408
|
+
}
|
|
409
|
+
throw notFound()
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
if (configured.includes('/')) {
|
|
413
|
+
if (!exists(configured)) throw notFound()
|
|
414
|
+
return configured
|
|
415
|
+
}
|
|
416
|
+
const path = env.PATH ?? '/usr/bin:/bin'
|
|
417
|
+
for (const dir of path.split(':').map(entry => entry.trim()).filter(Boolean)) {
|
|
418
|
+
const candidate = join(dir, configured)
|
|
419
|
+
if (exists(candidate)) return candidate
|
|
420
|
+
}
|
|
421
|
+
throw notFound()
|
|
422
|
+
}
|
|
423
|
+
|
|
309
424
|
/**
|
|
310
425
|
* The interactive shell for this platform, resolved like a terminal
|
|
311
426
|
* emulator: an explicitly configured shell (the `shell` config field) wins,
|
|
@@ -374,3 +489,59 @@ export function shellSpawnArgs(configured: string[] = []): string[] {
|
|
|
374
489
|
if (configured.length > 0) return [...configured]
|
|
375
490
|
return process.platform === 'win32' ? [] : ['-l']
|
|
376
491
|
}
|
|
492
|
+
|
|
493
|
+
/**
|
|
494
|
+
* Strip ONE pair of surrounding quotes from a configured shell path. Users
|
|
495
|
+
* paste Windows paths with spaces pre-quoted (`"C:\Program Files\…"`); the
|
|
496
|
+
* quotes are shell-input syntax, not part of the path. Unpaired quotes and
|
|
497
|
+
* shorter values stay verbatim.
|
|
498
|
+
*/
|
|
499
|
+
export function unquotePath(value: string): string {
|
|
500
|
+
if (value.length >= 2) {
|
|
501
|
+
const first = value[0]
|
|
502
|
+
const last = value[value.length - 1]
|
|
503
|
+
if ((first === '"' && last === '"') || (first === "'" && last === "'")) return value.slice(1, -1)
|
|
504
|
+
}
|
|
505
|
+
return value
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
/**
|
|
509
|
+
* Split a settings-page shell-arguments string into argv with quote-aware
|
|
510
|
+
* grouping: `'…'` / `"…"` group whitespace, and characters inside quotes are
|
|
511
|
+
* LITERAL — a backslash is never an escape, so Windows paths survive intact
|
|
512
|
+
* (`-File "C:\my init\init.ps1"` → three tokens, the last containing spaces).
|
|
513
|
+
* The price is that an argument containing a literal quote character cannot
|
|
514
|
+
* be expressed; shell startup arguments never need one. An unclosed quote
|
|
515
|
+
* folds the remainder into the current token (settings input stays
|
|
516
|
+
* forgiving); an empty quote pair yields no argument.
|
|
517
|
+
*/
|
|
518
|
+
export function splitShellArgs(input: string): string[] {
|
|
519
|
+
const args: string[] = []
|
|
520
|
+
let current = ''
|
|
521
|
+
let quote: '"' | "'" | null = null
|
|
522
|
+
let started = false
|
|
523
|
+
for (const ch of input) {
|
|
524
|
+
if (quote !== null) {
|
|
525
|
+
if (ch === quote) quote = null
|
|
526
|
+
else current += ch
|
|
527
|
+
continue
|
|
528
|
+
}
|
|
529
|
+
if (ch === '"' || ch === "'") {
|
|
530
|
+
quote = ch
|
|
531
|
+
started = true
|
|
532
|
+
continue
|
|
533
|
+
}
|
|
534
|
+
if (/\s/.test(ch)) {
|
|
535
|
+
if (started) {
|
|
536
|
+
args.push(current)
|
|
537
|
+
current = ''
|
|
538
|
+
started = false
|
|
539
|
+
}
|
|
540
|
+
continue
|
|
541
|
+
}
|
|
542
|
+
current += ch
|
|
543
|
+
started = true
|
|
544
|
+
}
|
|
545
|
+
if (started) args.push(current)
|
|
546
|
+
return args.filter(arg => arg !== '')
|
|
547
|
+
}
|