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/lib/types/git.d.ts
CHANGED
|
@@ -112,6 +112,21 @@ export declare function log(cwd: string, count?: number, skip?: number, selected
|
|
|
112
112
|
* revision has no such path (a new/untracked file has no HEAD side).
|
|
113
113
|
*/
|
|
114
114
|
export declare function show(cwd: string, rev: string, path: string, selected?: string): Promise<string | null>;
|
|
115
|
+
/**
|
|
116
|
+
* Both sides' full file contents for a diff-fold expansion. `path` is
|
|
117
|
+
* repo-relative. The sides resolve per diff kind: a commit reads
|
|
118
|
+
* `<hash>^` vs `<hash>`; a staged change reads HEAD vs the index (`:`);
|
|
119
|
+
* an unstaged change reads HEAD vs the working tree file on disk (a side
|
|
120
|
+
* that does not exist — untracked, deleted, binary-refused — comes back
|
|
121
|
+
* null and the client degrades the fold to a static marker).
|
|
122
|
+
*/
|
|
123
|
+
export declare function foldContents(cwd: string, path: string, opts?: {
|
|
124
|
+
staged?: boolean;
|
|
125
|
+
hash?: string;
|
|
126
|
+
}, selected?: string): Promise<{
|
|
127
|
+
old: string | null;
|
|
128
|
+
new: string | null;
|
|
129
|
+
}>;
|
|
115
130
|
/** Full patch text of one commit (`git show` with the commit header suppressed).
|
|
116
131
|
* Merge commits show their diff against the first parent (`-m --first-parent`
|
|
117
132
|
* is a no-op for regular commits), so a history click always has content. */
|
package/lib/types/index.d.ts
CHANGED
|
@@ -45,3 +45,12 @@ export interface SidebarSettingsFace {
|
|
|
45
45
|
* {@link resolveSidebarConfig}.
|
|
46
46
|
*/
|
|
47
47
|
export declare function apply(ctx: Context, config?: SidebarConfig): void;
|
|
48
|
+
/**
|
|
49
|
+
* The WS close reason for a failed terminal attach. A missing configured
|
|
50
|
+
* shell gets a SHORT machine-readable marker (`shell-not-found:<name>`,
|
|
51
|
+
* capped by BYTES — a WS close reason allows at most 123 bytes, which `ws`
|
|
52
|
+
* validates with `Buffer.byteLength`) that the client maps to a localized,
|
|
53
|
+
* actionable banner; every other failure keeps the raw message (the
|
|
54
|
+
* model-side tool errors read it verbatim).
|
|
55
|
+
*/
|
|
56
|
+
export declare function wsCloseReasonOf(error: unknown): string;
|
|
@@ -14,14 +14,16 @@ export interface SidebarPrefs {
|
|
|
14
14
|
/** Default panel width as a percent of the window width (20–60). */
|
|
15
15
|
defaultWidthPercent: number;
|
|
16
16
|
/**
|
|
17
|
-
* Whether the sidebar auto-activates
|
|
18
|
-
*
|
|
17
|
+
* Whether the sidebar auto-activates the Subagent page when the current
|
|
18
|
+
* conversation spawns a new subagent. Wide viewports also open the panel;
|
|
19
|
+
* narrow viewports prepare the tab without opening the full-screen drawer.
|
|
19
20
|
*/
|
|
20
21
|
autoOpenSubagent: boolean;
|
|
21
22
|
/**
|
|
22
|
-
* Whether the sidebar auto-activates
|
|
23
|
-
*
|
|
24
|
-
*
|
|
23
|
+
* Whether the sidebar auto-activates the Jobs page when a NEW background
|
|
24
|
+
* job appears for the current conversation (any new job id, not just the
|
|
25
|
+
* first one). Wide viewports also open the panel; narrow viewports prepare
|
|
26
|
+
* the tab without opening the full-screen drawer.
|
|
25
27
|
*/
|
|
26
28
|
autoOpenJobs: boolean;
|
|
27
29
|
/**
|
|
@@ -134,6 +134,41 @@ export interface ShellResolutionOptions {
|
|
|
134
134
|
/** File-existence probe override (defaults to `existsSync`). */
|
|
135
135
|
exists?: (path: string) => boolean;
|
|
136
136
|
}
|
|
137
|
+
/** Inputs for resolving one configured shell into the executable path passed
|
|
138
|
+
* to node-pty. Injectable so the Windows-only search semantics stay covered
|
|
139
|
+
* on POSIX CI runners. */
|
|
140
|
+
export interface ShellExecutableResolutionOptions {
|
|
141
|
+
/** Platform override (defaults to `process.platform`). */
|
|
142
|
+
platform?: NodeJS.Platform;
|
|
143
|
+
/** Environment override; Windows reads PATH/PATHEXT/SystemRoot plus the
|
|
144
|
+
* PowerShell well-known-location variables. */
|
|
145
|
+
env?: NodeJS.ProcessEnv;
|
|
146
|
+
/** File-existence probe override (defaults to `existsSync`). */
|
|
147
|
+
exists?: (path: string) => boolean;
|
|
148
|
+
}
|
|
149
|
+
/**
|
|
150
|
+
* Resolve the configured shell executable before handing it to node-pty.
|
|
151
|
+
*
|
|
152
|
+
* Windows' native backend does not consistently apply the shell's PATHEXT
|
|
153
|
+
* lookup to a bare value (`pwsh` / `cmd` can fail with the opaque
|
|
154
|
+
* `File not found:` error), so perform the lookup ourselves: an explicit
|
|
155
|
+
* path is accepted as-is when it exists (or with a PATHEXT suffix when the
|
|
156
|
+
* user omitted `.exe`), and a bare name is searched through PATH, System32,
|
|
157
|
+
* and PowerShell's known install directories.
|
|
158
|
+
*
|
|
159
|
+
* POSIX node-pty uses `execvp`, so a bare name would already follow PATH —
|
|
160
|
+
* but a wrong name made the pty die with a bare
|
|
161
|
+
* `[process exited with code N]`, so probe like Windows anyway: a path with
|
|
162
|
+
* a separator must exist; a bare name is searched along PATH (the colon
|
|
163
|
+
* form is fixed by the platform). A miss is a clear, actionable
|
|
164
|
+
* `shell-not-found` error instead of a cryptic exit code.
|
|
165
|
+
*
|
|
166
|
+
* @param shell - the configured shell (settings page or yaml `config.shell`).
|
|
167
|
+
* @param options - platform/env/exists injection points for tests.
|
|
168
|
+
* @returns the executable path passed to node-pty.
|
|
169
|
+
* @throws {SidebarError} `shell-not-found` when no candidate exists.
|
|
170
|
+
*/
|
|
171
|
+
export declare function resolveShellExecutable(shell: string, options?: ShellExecutableResolutionOptions): string;
|
|
137
172
|
/**
|
|
138
173
|
* The interactive shell for this platform, resolved like a terminal
|
|
139
174
|
* emulator: an explicitly configured shell (the `shell` config field) wins,
|
|
@@ -166,3 +201,21 @@ export declare function shellDisplayName(shell: string): string;
|
|
|
166
201
|
* defaults entirely, giving deployments full control over shell startup.
|
|
167
202
|
*/
|
|
168
203
|
export declare function shellSpawnArgs(configured?: string[]): string[];
|
|
204
|
+
/**
|
|
205
|
+
* Strip ONE pair of surrounding quotes from a configured shell path. Users
|
|
206
|
+
* paste Windows paths with spaces pre-quoted (`"C:\Program Files\…"`); the
|
|
207
|
+
* quotes are shell-input syntax, not part of the path. Unpaired quotes and
|
|
208
|
+
* shorter values stay verbatim.
|
|
209
|
+
*/
|
|
210
|
+
export declare function unquotePath(value: string): string;
|
|
211
|
+
/**
|
|
212
|
+
* Split a settings-page shell-arguments string into argv with quote-aware
|
|
213
|
+
* grouping: `'…'` / `"…"` group whitespace, and characters inside quotes are
|
|
214
|
+
* LITERAL — a backslash is never an escape, so Windows paths survive intact
|
|
215
|
+
* (`-File "C:\my init\init.ps1"` → three tokens, the last containing spaces).
|
|
216
|
+
* The price is that an argument containing a literal quote character cannot
|
|
217
|
+
* be expressed; shell startup arguments never need one. An unclosed quote
|
|
218
|
+
* folds the remainder into the current token (settings input stays
|
|
219
|
+
* forgiving); an empty quote pair yields no argument.
|
|
220
|
+
*/
|
|
221
|
+
export declare function splitShellArgs(input: string): string[];
|
package/lib/types/wire.d.ts
CHANGED
|
@@ -6,12 +6,16 @@
|
|
|
6
6
|
*/
|
|
7
7
|
import type { SidebarHttpRequest, SidebarHttpResponse } from './context-types.ts';
|
|
8
8
|
/** Machine-readable error codes of the sidebar API. */
|
|
9
|
-
export type SidebarErrorCode = 'bad-request' | 'not-found' | 'forbidden' | 'method-error' | 'too-large' | 'fs-error' | 'git-error' | 'pty-error' | 'pty-deps-missing' | 'job-error' | 'cdp-down' | 'sidechat-error' | 'subagents-unavailable' | 'settings-rejected' | 'settings-conflict' | 'internal';
|
|
9
|
+
export type SidebarErrorCode = 'bad-request' | 'not-found' | 'forbidden' | 'method-error' | 'too-large' | 'fs-error' | 'git-error' | 'pty-error' | 'pty-deps-missing' | 'shell-not-found' | 'job-error' | 'cdp-down' | 'sidechat-error' | 'subagents-unavailable' | 'settings-rejected' | 'settings-conflict' | 'internal';
|
|
10
10
|
/** One API failure with its wire code and HTTP status. */
|
|
11
11
|
export declare class SidebarError extends Error {
|
|
12
12
|
readonly code: SidebarErrorCode;
|
|
13
13
|
readonly status: number;
|
|
14
|
-
|
|
14
|
+
/** Optional structured context (e.g. `{ shell }` for shell-not-found). */
|
|
15
|
+
readonly meta?: Record<string, string> | undefined;
|
|
16
|
+
constructor(code: SidebarErrorCode, message: string, status?: number,
|
|
17
|
+
/** Optional structured context (e.g. `{ shell }` for shell-not-found). */
|
|
18
|
+
meta?: Record<string, string> | undefined);
|
|
15
19
|
}
|
|
16
20
|
/** Success envelope of one API method. */
|
|
17
21
|
export interface SidebarOk<T> {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-coding-sidebar",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.9",
|
|
4
4
|
"description": "DSH web plugin: a VSCode-like right sidebar (explorer / editor / terminal / git / browser), isolated per conversation session. Exposes the betterSidebar service for other plugins to register sidebar tabs and file viewers. KCoder-maintained fork of DSH-better-sidebar 0.17.2 (bottom panel removed, upstream-decoupled release line).",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"repository": {
|
package/src/agent-pty.ts
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
*/
|
|
14
14
|
import { randomUUID } from 'node:crypto'
|
|
15
15
|
import type { IPty } from 'node-pty'
|
|
16
|
-
import { ensureSpawnHelper, shellSpawnArgs } from './pty-manager.ts'
|
|
16
|
+
import { ensureSpawnHelper, resolveShellExecutable, shellSpawnArgs } from './pty-manager.ts'
|
|
17
17
|
import { loadRequiredNodePty, type NodePtyModule } from './pty-deps.ts'
|
|
18
18
|
import { SidebarError } from './wire.ts'
|
|
19
19
|
|
|
@@ -40,6 +40,79 @@ export function clampDims(cols: number, rows: number): { cols: number; rows: num
|
|
|
40
40
|
return { cols: clamp(cols), rows: clamp(rows) }
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
+
/**
|
|
44
|
+
* Per-pty Windows resize-gate state (see {@link armPtyResizeGate}).
|
|
45
|
+
*/
|
|
46
|
+
interface ResizeGateState {
|
|
47
|
+
/** Whether the pty has produced any output yet (ConPTY flush completed). */
|
|
48
|
+
sawData: boolean
|
|
49
|
+
/** Latest dims requested before the first output; replayed once it arrives. */
|
|
50
|
+
pending?: { cols: number; rows: number }
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* node-pty's Windows terminal queues resize calls that arrive before the
|
|
55
|
+
* ConPTY control socket's first data flush (`_deferNoArgs` in
|
|
56
|
+
* windowsTerminal.js). If the pty exits before the queue flushes, the
|
|
57
|
+
* deferred resize throws inside the socket's 'data' handler — uncatchable
|
|
58
|
+
* by any caller and fatal to the host process. POSIX terminals have no such
|
|
59
|
+
* queue (resize is synchronous), so the gate is armed on Windows only:
|
|
60
|
+
* {@link tryResizePty} parks dims requested before the first output and
|
|
61
|
+
* replays them after the flush, when node-pty executes resizes
|
|
62
|
+
* synchronously (and the throw for an exited pty is catchable).
|
|
63
|
+
*/
|
|
64
|
+
const ptyResizeGates = new WeakMap<object, ResizeGateState>()
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Arm the Windows pre-ready resize gate for one freshly spawned pty.
|
|
68
|
+
* No-op on POSIX and for injected ptys without `onData`.
|
|
69
|
+
*/
|
|
70
|
+
export function armPtyResizeGate(pty: IPty): void {
|
|
71
|
+
if (process.platform !== 'win32') return
|
|
72
|
+
if (typeof pty.onData !== 'function' || ptyResizeGates.has(pty)) return
|
|
73
|
+
const state: ResizeGateState = { sawData: false }
|
|
74
|
+
ptyResizeGates.set(pty, state)
|
|
75
|
+
pty.onData(() => {
|
|
76
|
+
if (state.sawData) return
|
|
77
|
+
state.sawData = true
|
|
78
|
+
const dims = state.pending
|
|
79
|
+
state.pending = undefined
|
|
80
|
+
if (dims === undefined) return
|
|
81
|
+
// node-pty's deferred flush shares this very 'data' dispatch; hop past
|
|
82
|
+
// it so the replayed resize executes synchronously (catchable) instead
|
|
83
|
+
// of being queued into the same deferred list we are draining.
|
|
84
|
+
setImmediate(() => { tryResizePty(pty, dims.cols, dims.rows) })
|
|
85
|
+
})
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Best-effort resize for WebSocket-driven terminal views. Layout animation
|
|
90
|
+
* can briefly produce unusable dimensions, and node-pty can reject a resize
|
|
91
|
+
* after the socket setup's outer try/catch has returned. Ignore that one
|
|
92
|
+
* frame so the host stays alive and a later valid measurement can retry.
|
|
93
|
+
* Returns whether node-pty accepted the resize (or parked it for replay on
|
|
94
|
+
* the first output — the Windows pre-ready window).
|
|
95
|
+
*/
|
|
96
|
+
export function tryResizePty(
|
|
97
|
+
pty: Pick<IPty, 'resize'>,
|
|
98
|
+
cols: number,
|
|
99
|
+
rows: number,
|
|
100
|
+
): boolean {
|
|
101
|
+
if (!Number.isFinite(cols) || !Number.isFinite(rows)) return false
|
|
102
|
+
const dims = clampDims(cols, rows)
|
|
103
|
+
const gate = ptyResizeGates.get(pty)
|
|
104
|
+
if (gate !== undefined && !gate.sawData) {
|
|
105
|
+
gate.pending = dims
|
|
106
|
+
return true
|
|
107
|
+
}
|
|
108
|
+
try {
|
|
109
|
+
pty.resize(dims.cols, dims.rows)
|
|
110
|
+
return true
|
|
111
|
+
} catch {
|
|
112
|
+
return false
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
43
116
|
/**
|
|
44
117
|
* Serializable snapshot of one agent terminal — the shape the model sees
|
|
45
118
|
* through `terminal_list` and the sidebar sees through the push endpoint.
|
|
@@ -61,6 +134,13 @@ export interface AgentTerminalSnapshot {
|
|
|
61
134
|
exitCode?: number | null
|
|
62
135
|
/** Exit signal name if the process was killed by a signal; null otherwise. */
|
|
63
136
|
exitSignal?: string | null
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* The model's active `terminal_wait_for` on this terminal (the sidebar
|
|
140
|
+
* renders the wait banner from it). Present only while a wait is
|
|
141
|
+
* registered; carries the LATEST wait when several overlap.
|
|
142
|
+
*/
|
|
143
|
+
waiting?: { needle: string; since: number }
|
|
64
144
|
}
|
|
65
145
|
|
|
66
146
|
/** Map a POSIX signal number to its conventional name (best-effort). */
|
|
@@ -76,22 +156,56 @@ function signalNameOf(signal: number | null | undefined): string | null {
|
|
|
76
156
|
return SIGNAL_NAMES[signal] ?? `signal ${signal}`
|
|
77
157
|
}
|
|
78
158
|
|
|
79
|
-
/**
|
|
80
|
-
|
|
159
|
+
/**
|
|
160
|
+
* Compile a wait needle into a RegExp. The needle is treated as a JavaScript
|
|
161
|
+
* regular expression; a pattern that fails to compile (e.g. an unbalanced
|
|
162
|
+
* group typed as a literal) degrades to verbatim substring matching so
|
|
163
|
+
* legacy literal needles keep working.
|
|
164
|
+
*/
|
|
165
|
+
function compileNeedle(needle: string): RegExp | null {
|
|
166
|
+
try {
|
|
167
|
+
return new RegExp(needle)
|
|
168
|
+
} catch {
|
|
169
|
+
return null
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Locate the first occurrence of `needle` in `transcript`, returning its
|
|
175
|
+
* line/column plus the actual matched text — for alternation patterns
|
|
176
|
+
* (e.g. `(BUILD_OK|BUILD_FAIL)`) the match tells which alternative hit.
|
|
177
|
+
* `re` is the precompiled form of `needle` (from {@link compileNeedle});
|
|
178
|
+
* `null` means verbatim substring matching.
|
|
179
|
+
*/
|
|
180
|
+
function locateNeedle(
|
|
181
|
+
transcript: string,
|
|
182
|
+
needle: string,
|
|
183
|
+
re: RegExp | null,
|
|
184
|
+
): { line: number; column: number; match: string } | undefined {
|
|
81
185
|
if (needle === '') return undefined
|
|
82
|
-
|
|
83
|
-
|
|
186
|
+
// Regex hit → use the match index/text; literal fallback → indexOf with
|
|
187
|
+
// the needle itself as the matched text.
|
|
188
|
+
let hit: { index: number; text: string } | undefined
|
|
189
|
+
if (re !== null) {
|
|
190
|
+
re.lastIndex = 0
|
|
191
|
+
const m = re.exec(transcript)
|
|
192
|
+
hit = m === null ? undefined : { index: m.index, text: m[0] }
|
|
193
|
+
} else {
|
|
194
|
+
const idx = transcript.indexOf(needle)
|
|
195
|
+
hit = idx === -1 ? undefined : { index: idx, text: needle }
|
|
196
|
+
}
|
|
197
|
+
if (hit === undefined) return undefined
|
|
84
198
|
// Walk the transcript up to the match index, counting newlines to derive
|
|
85
199
|
// the 0-based line; the column is the offset within that line.
|
|
86
200
|
let line = 0
|
|
87
201
|
let lineStart = 0
|
|
88
|
-
for (let i = 0; i <
|
|
202
|
+
for (let i = 0; i < hit.index; i += 1) {
|
|
89
203
|
if (transcript.charCodeAt(i) === 0x0a /* \n */) {
|
|
90
204
|
line += 1
|
|
91
205
|
lineStart = i + 1
|
|
92
206
|
}
|
|
93
207
|
}
|
|
94
|
-
return { line, column:
|
|
208
|
+
return { line, column: hit.index - lineStart, match: hit.text }
|
|
95
209
|
}
|
|
96
210
|
|
|
97
211
|
/** One live agent terminal. */
|
|
@@ -116,6 +230,18 @@ export interface AgentTerminalHandle {
|
|
|
116
230
|
exitCode?: number | null
|
|
117
231
|
/** Exit signal number once known (POSIX only; undefined on Windows). */
|
|
118
232
|
exitSignal?: number | null
|
|
233
|
+
/** Active wait_for registrations (skip bookkeeping; empty while idle). */
|
|
234
|
+
waits: AgentTerminalActiveWait[]
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/** One active wait_for registration on a handle (banner + skip bookkeeping). */
|
|
238
|
+
export interface AgentTerminalActiveWait {
|
|
239
|
+
/** The needle being awaited (shown on the sidebar wait banner). */
|
|
240
|
+
needle: string
|
|
241
|
+
/** Epoch ms when the wait registered (age display / debugging). */
|
|
242
|
+
since: number
|
|
243
|
+
/** Flipped by `skipWait()`; the waiting poll returns `skipped` within one tick. */
|
|
244
|
+
skipped: boolean
|
|
119
245
|
}
|
|
120
246
|
|
|
121
247
|
/** Read result shape (mirrors the official tool-pty terminal_read contract). */
|
|
@@ -133,14 +259,18 @@ export interface AgentTerminalReadResult {
|
|
|
133
259
|
/** Outcome of {@link AgentPtyRegistry.waitFor}. */
|
|
134
260
|
export type AgentTerminalWaitResult =
|
|
135
261
|
| {
|
|
136
|
-
/** The
|
|
262
|
+
/** The pattern that was awaited. */
|
|
137
263
|
kind: 'found'
|
|
138
|
-
/** The matched substring. */
|
|
139
264
|
needle: string
|
|
140
265
|
/** 0-based line index (in the retained transcript) where the needle first appeared. */
|
|
141
266
|
line: number
|
|
142
267
|
/** 0-based column index within that line where the match starts. */
|
|
143
268
|
column: number
|
|
269
|
+
/**
|
|
270
|
+
* The text that actually matched — for multi-outcome patterns
|
|
271
|
+
* (e.g. `(BUILD_OK|BUILD_FAIL)`) this tells which alternative matched.
|
|
272
|
+
*/
|
|
273
|
+
match: string
|
|
144
274
|
/** Elapsed wall-clock milliseconds from the wait start to the match. */
|
|
145
275
|
elapsedMs: number
|
|
146
276
|
}
|
|
@@ -164,6 +294,12 @@ export type AgentTerminalWaitResult =
|
|
|
164
294
|
/** The exit signal name, if the process was killed by a signal. */
|
|
165
295
|
exitSignal?: string | null
|
|
166
296
|
}
|
|
297
|
+
| {
|
|
298
|
+
/** The user skipped the wait from the sidebar banner. */
|
|
299
|
+
kind: 'skipped'
|
|
300
|
+
/** The needle that was awaited. */
|
|
301
|
+
needle: string
|
|
302
|
+
}
|
|
167
303
|
|
|
168
304
|
/** Snapshot projection of a handle (drops the pty reference and transcript). */
|
|
169
305
|
export function snapshotOf(handle: AgentTerminalHandle): AgentTerminalSnapshot {
|
|
@@ -177,6 +313,8 @@ export function snapshotOf(handle: AgentTerminalHandle): AgentTerminalSnapshot {
|
|
|
177
313
|
out.exitCode = handle.exitCode ?? null
|
|
178
314
|
out.exitSignal = signalNameOf(handle.exitSignal)
|
|
179
315
|
}
|
|
316
|
+
const active = handle.waits.at(-1)
|
|
317
|
+
if (active !== undefined) out.waiting = { needle: active.needle, since: active.since }
|
|
180
318
|
return out
|
|
181
319
|
}
|
|
182
320
|
|
|
@@ -219,13 +357,15 @@ export class AgentPtyRegistry {
|
|
|
219
357
|
): string {
|
|
220
358
|
const uuid = randomUUID()
|
|
221
359
|
const dims = clampDims(cols, rows)
|
|
222
|
-
const
|
|
360
|
+
const executable = resolveShellExecutable(shell ?? this.shell)
|
|
361
|
+
const pty = this.nodePty.spawn(executable, shellSpawnArgs(shellArgs ?? this.shellArgs), {
|
|
223
362
|
name: 'xterm-256color',
|
|
224
363
|
cols: dims.cols,
|
|
225
364
|
rows: dims.rows,
|
|
226
365
|
cwd,
|
|
227
366
|
env: { ...process.env },
|
|
228
367
|
})
|
|
368
|
+
armPtyResizeGate(pty)
|
|
229
369
|
const handle: AgentTerminalHandle = {
|
|
230
370
|
uuid,
|
|
231
371
|
sessionId,
|
|
@@ -235,6 +375,7 @@ export class AgentPtyRegistry {
|
|
|
235
375
|
pty,
|
|
236
376
|
transcript: '',
|
|
237
377
|
exited: false,
|
|
378
|
+
waits: [],
|
|
238
379
|
}
|
|
239
380
|
pty.onData((data) => {
|
|
240
381
|
handle.transcript += data
|
|
@@ -353,7 +494,11 @@ export class AgentPtyRegistry {
|
|
|
353
494
|
resize(uuid: string, cols: number, rows: number): { cols: number; rows: number } {
|
|
354
495
|
const handle = this.expect(uuid)
|
|
355
496
|
const dims = clampDims(cols, rows)
|
|
356
|
-
|
|
497
|
+
// Through tryResizePty: node-pty throws on a just-exited pty (the exit
|
|
498
|
+
// event may not have flipped `handle.exited` yet), and on Windows a
|
|
499
|
+
// pre-ready resize must go through the gate instead of node-pty's own
|
|
500
|
+
// deferred queue (which flushes uncatchably inside its socket handler).
|
|
501
|
+
if (!handle.exited) tryResizePty(handle.pty, dims.cols, dims.rows)
|
|
357
502
|
return dims
|
|
358
503
|
}
|
|
359
504
|
|
|
@@ -377,10 +522,17 @@ export class AgentPtyRegistry {
|
|
|
377
522
|
* make event-driven wakeups unreliable. A 50ms poll is fast enough for
|
|
378
523
|
* interactive use and simple enough to be obviously correct.
|
|
379
524
|
* @param uuid - terminal to watch.
|
|
380
|
-
* @param needle -
|
|
525
|
+
* @param needle - JavaScript regular expression to search for
|
|
526
|
+
* (case-sensitive); a pattern that fails to compile falls back to
|
|
527
|
+
* verbatim substring matching. May cover several outcomes at once
|
|
528
|
+
* (e.g. `(BUILD_OK|BUILD_FAIL)` for build success vs failure) — the
|
|
529
|
+
* returned `match` reports the text that actually matched, so callers
|
|
530
|
+
* can tell which outcome hit.
|
|
381
531
|
* @param timeoutMs - max wait; default 10000 (10s). Clamped to ≥100ms.
|
|
382
532
|
* @param signal - caller-owned cancellation; aborts the wait re-throwing.
|
|
383
|
-
*
|
|
533
|
+
* A wait can also be skipped by the user from the sidebar banner
|
|
534
|
+
* (`skipWait`), which resolves it with `{kind:'skipped'}`.
|
|
535
|
+
* @returns one of `found` / `timeout` / `exited` / `skipped`.
|
|
384
536
|
*/
|
|
385
537
|
async waitFor(
|
|
386
538
|
uuid: string,
|
|
@@ -395,37 +547,73 @@ export class AgentPtyRegistry {
|
|
|
395
547
|
const timeout = Math.max(100, Math.floor(timeoutMs))
|
|
396
548
|
const start = Date.now()
|
|
397
549
|
const deadline = start + timeout
|
|
550
|
+
// Compile the needle once (regex semantics; invalid patterns degrade to
|
|
551
|
+
// verbatim matching) and reuse the compiled form across every poll.
|
|
552
|
+
const re = compileNeedle(needle)
|
|
398
553
|
// Fast path: already exited, or the needle is already in the transcript
|
|
399
554
|
// (a `terminal_send` may have produced the expected output before this
|
|
400
555
|
// call even started).
|
|
401
556
|
if (handle.exited) {
|
|
402
557
|
return { kind: 'exited', needle, exitCode: handle.exitCode ?? null, exitSignal: signalNameOf(handle.exitSignal) }
|
|
403
558
|
}
|
|
404
|
-
const firstHit = locateNeedle(handle.transcript, needle)
|
|
559
|
+
const firstHit = locateNeedle(handle.transcript, needle, re)
|
|
405
560
|
if (firstHit !== undefined) {
|
|
406
|
-
return { kind: 'found', needle, line: firstHit.line, column: firstHit.column, elapsedMs: Date.now() - start }
|
|
561
|
+
return { kind: 'found', needle, line: firstHit.line, column: firstHit.column, match: firstHit.match, elapsedMs: Date.now() - start }
|
|
407
562
|
}
|
|
408
|
-
//
|
|
409
|
-
//
|
|
410
|
-
//
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
563
|
+
// Register the active wait so the sidebar can show the wait banner (the
|
|
564
|
+
// snapshot's `waiting` field rides the agent-terminals push). Registered
|
|
565
|
+
// only after the fast paths: a wait that resolves instantly never
|
|
566
|
+
// flashes the banner. The notify() makes every push subscriber (the
|
|
567
|
+
// sidebar view) converge on the new waiting state immediately.
|
|
568
|
+
const record: AgentTerminalActiveWait = { needle, since: start, skipped: false }
|
|
569
|
+
handle.waits.push(record)
|
|
570
|
+
this.notify()
|
|
571
|
+
try {
|
|
572
|
+
while (true) {
|
|
573
|
+
if (signal?.aborted) signal.throwIfAborted()
|
|
574
|
+
if (handle.exited) {
|
|
575
|
+
return { kind: 'exited', needle, exitCode: handle.exitCode ?? null, exitSignal: signalNameOf(handle.exitSignal) }
|
|
576
|
+
}
|
|
577
|
+
if (record.skipped) {
|
|
578
|
+
return { kind: 'skipped', needle }
|
|
579
|
+
}
|
|
580
|
+
const hit = locateNeedle(handle.transcript, needle, re)
|
|
581
|
+
if (hit !== undefined) {
|
|
582
|
+
return { kind: 'found', needle, line: hit.line, column: hit.column, match: hit.match, elapsedMs: Date.now() - start }
|
|
583
|
+
}
|
|
584
|
+
if (Date.now() >= deadline) {
|
|
585
|
+
return { kind: 'timeout', needle, timeoutMs: timeout, totalLines: handle.transcript.split('\n').length }
|
|
586
|
+
}
|
|
587
|
+
await new Promise(resolve => {
|
|
588
|
+
const t = setTimeout(resolve, 50)
|
|
589
|
+
// Allow the Node process to exit even if the timer is pending.
|
|
590
|
+
if (typeof t === 'object' && 'unref' in t) (t as { unref: () => void }).unref()
|
|
591
|
+
})
|
|
419
592
|
}
|
|
420
|
-
|
|
421
|
-
|
|
593
|
+
} finally {
|
|
594
|
+
const index = handle.waits.indexOf(record)
|
|
595
|
+
if (index !== -1) handle.waits.splice(index, 1)
|
|
596
|
+
this.notify()
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
/**
|
|
601
|
+
* Mark every active wait on one terminal as skipped (the sidebar banner's
|
|
602
|
+
* skip button). Each waiting poll loop observes its record's flag within
|
|
603
|
+
* one 50ms tick and returns `{kind:'skipped'}`. Idempotent: 0 when nothing
|
|
604
|
+
* is waiting (a stale banner racing a wait that already resolved).
|
|
605
|
+
* @returns the number of waits that transitioned to skipped.
|
|
606
|
+
*/
|
|
607
|
+
skipWait(uuid: string): number {
|
|
608
|
+
const handle = this.expect(uuid)
|
|
609
|
+
let count = 0
|
|
610
|
+
for (const record of handle.waits) {
|
|
611
|
+
if (!record.skipped) {
|
|
612
|
+
record.skipped = true
|
|
613
|
+
count += 1
|
|
422
614
|
}
|
|
423
|
-
await new Promise(resolve => {
|
|
424
|
-
const t = setTimeout(resolve, 50)
|
|
425
|
-
// Allow the Node process to exit even if the timer is pending.
|
|
426
|
-
if (typeof t === 'object' && 'unref' in t) (t as { unref: () => void }).unref()
|
|
427
|
-
})
|
|
428
615
|
}
|
|
616
|
+
return count
|
|
429
617
|
}
|
|
430
618
|
|
|
431
619
|
/**
|
package/src/bundle-route.ts
CHANGED
|
@@ -19,7 +19,7 @@ import { fileURLToPath } from 'node:url'
|
|
|
19
19
|
import type { Context, SidebarHttpRequest, SidebarHttpResponse } from './context-types.ts'
|
|
20
20
|
|
|
21
21
|
/** The chunk names the client may request (mirror of src/client/chunk-loader.ts). */
|
|
22
|
-
export const CHUNK_NAMES = ['terminal', 'editor'] as const
|
|
22
|
+
export const CHUNK_NAMES = ['terminal', 'editor', 'locale'] as const
|
|
23
23
|
export type ChunkName = (typeof CHUNK_NAMES)[number]
|
|
24
24
|
|
|
25
25
|
/** Directory of this host-half module (lib/ — the chunk scripts live next to it). */
|
package/src/client/DiffTab.tsx
CHANGED
|
@@ -103,7 +103,16 @@ export function DiffTab(props: { sessionId: string; cwd: string | undefined; dif
|
|
|
103
103
|
<>
|
|
104
104
|
{data.untracked !== undefined
|
|
105
105
|
? <DiffView diff="" untrackedPath={diff.kind === 'worktree' ? diff.path : ''} untrackedContent={data.untracked} />
|
|
106
|
-
:
|
|
106
|
+
: (
|
|
107
|
+
<DiffView
|
|
108
|
+
diff={data.diff}
|
|
109
|
+
foldSource={{
|
|
110
|
+
scope: { sessionId, cwd, ...(diff.repoRoot !== undefined ? { repoRoot: diff.repoRoot } : {}) },
|
|
111
|
+
ref: diff,
|
|
112
|
+
cwd,
|
|
113
|
+
}}
|
|
114
|
+
/>
|
|
115
|
+
)}
|
|
107
116
|
{data.diff === '' && data.untracked === undefined && (
|
|
108
117
|
<div className={css.gitEmpty}>{t('diffEmpty')}</div>
|
|
109
118
|
)}
|