Skip to content

Commit e97cbaa

Browse files
authored
OSC 633 shell integration for PowerShell (#147)
2 parents 94a0e0c + 3ebbf5e commit e97cbaa

4 files changed

Lines changed: 572 additions & 41 deletions

File tree

docs/specs/terminal-escapes.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -135,9 +135,10 @@ A binary on `PATH` only has to be **found**, so it injects via one env var (`DOR
135135
| Shell | Mechanism | Channel | Notes |
136136
|---|---|---|---|
137137
| zsh | `ZDOTDIR` → our dotfiles chain to the user's, then install `precmd`/`preexec` hooks | env (as reliable as the `PATH` prepend) | User's real `ZDOTDIR` is passed through as `USER_ZDOTDIR`; our `.zshrc` hands `ZDOTDIR` back so `.zlogin` and child shells are unaffected. |
138-
| bash | `--init-file` → our script replicates login-profile sourcing, then installs a `DEBUG`-trap / `PROMPT_COMMAND` hook | shellArgs | `--init-file` and login mode are mutually exclusive, so Dormouse drops `-l` and the script sources `/etc/profile` + the user's profile itself. Written for bash 3.2 (macOS system bash): no `PS0`, no array `PROMPT_COMMAND`. The `E` command line is the first simple command of a pipeline (a `DEBUG`-trap limitation); boundaries and exit codes stay exact. |
138+
| bash | `--init-file` → our script replicates login-profile sourcing, then installs a `DEBUG`-trap / `PROMPT_COMMAND` hook | shellArgs | `--init-file` and login mode are mutually exclusive, so Dormouse drops `-l` and the script sources `/etc/profile` + the user's profile itself. Injected whenever the launch args are *only* interactive/login flags (`-i`/`-l`/`--login`) — so Git Bash, launched with `--login -i`, is covered too; a specific invocation like `-c <cmd>` is left untouched. Written for bash 3.2 (macOS system bash): no `PS0`, no array `PROMPT_COMMAND`. The `E` command line is the first simple command of a pipeline (a `DEBUG`-trap limitation); boundaries and exit codes stay exact. |
139139
| fish | `XDG_DATA_DIRS` → fish auto-sources `*/fish/vendor_conf.d/*.fish` | env | (not yet implemented) |
140-
| PowerShell | `-NoExit -Command <dot-source>` | shellArgs | (not yet implemented) |
140+
| PowerShell | `-Command ". '<script>'"` → the dot-sourced script wraps the user's `prompt` and PSReadLine's `PSConsoleHostReadLine` (covers `pwsh` and Windows `powershell.exe`) | shellArgs | `-NoProfile` is *not* passed, so the user's profile loads and defines their prompt before we wrap it. Injected for any **interactive** launch: a bare REPL gets `-NoExit -Command ". '<script>'"`, and a launch that already runs a startup command — e.g. the VS "Developer PowerShell" (`-NoExit -Command "& { Import-Module … }"`) — gets our dot-source *appended* to that command, so its environment is set up first and our wrapper installs after it. Non-interactive one-offs (a `-Command`/`-File`/`-EncodedCommand` without `-NoExit`) are left untouched. PowerShell has no `preexec`, so `E`/`C` (command line + start) are emitted by wrapping `PSConsoleHostReadLine`, which runs just before a submitted command executes — so the running command shows immediately, like bash/zsh. The matching `D` (finish, exit code from `$?`/`$LASTEXITCODE`) is emitted from the next `prompt` render. If PSReadLine is absent, the whole `E`/`C`/`D` triple is reported from the next prompt instead (command line from history): boundaries and exit codes stay exact, but the running command isn't shown until it finishes. |
141+
| WSL | `wsl.exe -d <distro> -- sh -c <detector>` → the detector execs the distro's bash with our `--init-file` (the Windows bash script, referenced via its `/mnt/...` path) | shellArgs | Windows-side injection can't reach the Linux shell, so we append a command. The detector reads the login shell from `/etc/passwd`: it steps aside for an explicit zsh/fish login shell, execs bash+integration when bash exists (covering bash and an empty detection — the safe default), and falls back to the login shell only when bash is absent (e.g. Alpine). bash is the only WSL shell integrated for now. Assumes the default `/mnt` automount root. |
141142
| cmd.exe | no per-command hook exists || Never gets real OSC 633; always uses the keystroke fallback below. |
142143

143144
Injection is wired in `resolveSpawnConfig` (`standalone/sidecar/pty-core.js`) and applies to both distributions (the standalone sidecar and the VS Code pty-host both spawn through it). The integration scripts are static files under `standalone/sidecar/shell-integration/`; the directory is resolved from `DORMOUSE_SHELL_INTEGRATION_DIR` (set by the host, mirroring `DORMOUSE_CLI_BIN`) and falls back to the sidecar's own directory. Standalone ships them via the tauri `../sidecar/**/*` resources glob; the VS Code build copies them into `dist/shell-integration`. If the scripts are missing, injection is skipped and the shell spawns exactly as before — injection is fail-safe.

standalone/sidecar/pty-core.js

Lines changed: 155 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
const fs = require('node:fs');
22
const os = require('node:os');
33
const path = require('node:path');
4-
const { execFileSync, execSync } = require('node:child_process');
4+
const { execFileSync } = require('node:child_process');
55

66
function safeResolve(resolver) {
77
try {
@@ -26,13 +26,58 @@ function resolveDefaultShell(platform = process.platform, env = process.env) {
2626
const LOGIN_ARG_UNSUPPORTED_SHELLS = new Set(['csh', 'tcsh']);
2727
const ITERM2_COMPAT_VERSION = '3.5.0';
2828

29+
// bash flags that merely select an interactive and/or login shell. When the args
30+
// are only these, OSC 633 injection can safely replace them: it spawns an
31+
// interactive shell and the `--init-file` script sources the login profile
32+
// itself, so it subsumes them — including the `--login -i` that Git Bash on
33+
// Windows is launched with. Anything else (e.g. `-c <cmd>` or a script file)
34+
// means the caller wants a specific invocation we must not clobber.
35+
const BASH_INJECTABLE_ARGS = new Set(['-i', '-l', '--login']);
36+
37+
function bashArgsAreInjectable(shellArgs) {
38+
return (shellArgs || []).every((arg) => BASH_INJECTABLE_ARGS.has(arg));
39+
}
40+
41+
// Build the PowerShell argument list that dot-sources our integration script,
42+
// or return null to leave the launch untouched. Profiles still load (no
43+
// -NoProfile). The path is single-quoted so spaces (e.g. "Program Files")
44+
// survive; it's host-controlled and won't contain a single quote.
45+
//
46+
// We key on interactivity rather than "are there args": a launch that already
47+
// runs a startup command (e.g. the VS "Developer PowerShell", which is
48+
// `-NoExit -Command "& { Import-Module ... }"`) gets our dot-source appended to
49+
// that command, so its environment is set up first and our prompt wrapper
50+
// installs after it. A non-interactive one-off (a -Command/-File/-EncodedCommand
51+
// without -NoExit) is returned as null so we don't alter it.
52+
function powerShellIntegratedArgs(shellArgs, script) {
53+
const dotSource = `. '${script}'`;
54+
const args = [...(shellArgs || [])];
55+
56+
// No args at all → a plain interactive REPL; add our own dot-source.
57+
if (args.length === 0) return ['-NoExit', '-Command', dotSource];
58+
59+
const is = (arg, ...names) => names.includes(arg.toLowerCase());
60+
// Only augment interactive sessions; without -NoExit a command/file runs and
61+
// exits, so there's no prompt to wrap.
62+
if (!args.some((a) => is(a, '-noexit', '-noe'))) return null;
63+
// Command forms we can't safely concatenate onto — leave them alone.
64+
if (args.some((a) => is(a, '-encodedcommand', '-ec', '-e', '-file', '-f'))) return null;
65+
66+
const cmdIdx = args.findIndex((a) => is(a, '-command', '-c'));
67+
if (cmdIdx !== -1 && cmdIdx + 1 < args.length) {
68+
args[cmdIdx + 1] = `${args[cmdIdx + 1]}; ${dotSource}`;
69+
return args;
70+
}
71+
// Interactive but no inline command (e.g. just `-NoExit`); add ours.
72+
return [...args, '-Command', dotSource];
73+
}
74+
2975
function resolveLoginArg(shell, platform = process.platform) {
3076
if (platform === 'win32') {
3177
return [];
3278
}
3379

34-
const shellName = path.posix.basename(shell || '').toLowerCase();
35-
return LOGIN_ARG_UNSUPPORTED_SHELLS.has(shellName) ? [] : ['-l'];
80+
return LOGIN_ARG_UNSUPPORTED_SHELLS.has(shellStem(shell)) ? [] : ['-l'];
3681
}
3782

3883
function resolveDefaultCwd(platform = process.platform, env = process.env, osModule = os) {
@@ -85,23 +130,55 @@ function resolveShellIntegrationDir(env, runtime = {}) {
85130
return env.DORMOUSE_SHELL_INTEGRATION_DIR || path.join(runtime.dirname || __dirname, 'shell-integration');
86131
}
87132

133+
// Basename of a shell path, lowercased and with any `.exe` dropped, handling
134+
// both `/` and `\` separators so Windows paths (e.g. the absolute pwsh.exe path)
135+
// resolve correctly — `path.posix.basename` would return a Windows path whole.
136+
function shellStem(shell) {
137+
const base = String(shell || '').split(/[\\/]/).pop() || '';
138+
return base.toLowerCase().replace(/\.exe$/, '');
139+
}
140+
141+
// Translate a Windows path to its WSL mount path (`C:\a\b` -> `/mnt/c/a/b`) so a
142+
// script on the Windows filesystem can be referenced from inside a distro. Strips
143+
// the `\\?\` verbatim prefix. Assumes the default automount root (`/mnt`); a
144+
// distro that remaps it via /etc/wsl.conf won't resolve, and injection is skipped.
145+
function winPathToWslMount(winPath) {
146+
const stripped = String(winPath).replace(/^\\\\\?\\/, '');
147+
const match = /^([A-Za-z]):[\\/]([\s\S]*)$/.exec(stripped);
148+
if (!match) return null;
149+
return `/mnt/${match[1].toLowerCase()}/${match[2].replace(/\\/g, '/')}`;
150+
}
151+
88152
// Enable OSC 633 shell integration for shells that support reliable injection,
89153
// returning possibly-modified { env, shellArgs }. The keystroke-based command
90154
// heuristic remains the fallback for shells we can't inject (cmd.exe, others)
91155
// or when the scripts aren't present on disk. See docs/specs/terminal-escapes.md.
92156
//
93-
// zsh — injected purely via env (`ZDOTDIR`), as reliable as a PATH prepend. We
94-
// point ZDOTDIR at our scripts and pass the user's real ZDOTDIR through
157+
// zsh — injected purely via env (`ZDOTDIR`), as reliable as a PATH prepend.
158+
// We point ZDOTDIR at our scripts and pass the user's real ZDOTDIR through
95159
// `USER_ZDOTDIR`; our dotfiles chain to the user's then install the hooks.
96-
// bash — injected via `--init-file`, which has no env equivalent. Because that
160+
// bash — injected via `--init-file`, which has no env equivalent. Because that
97161
// flag and login mode are mutually exclusive, we drop the login flag and
98-
// the script replicates login-profile sourcing itself. Skipped when the
99-
// caller passed explicit args, since we'd be replacing them.
100-
function applyShellIntegration(shell, env, shellArgs, integrationDir, hasExplicitArgs, runtime = {}) {
162+
// the script replicates login-profile sourcing itself. Injected whenever
163+
// the args are only interactive/login flags (so Git Bash, launched with
164+
// `--login -i`, is covered too); skipped for specific invocations like
165+
// `-c <cmd>`, since we'd be replacing them.
166+
// PowerShell — injected by dot-sourcing our script via `-Command` (pwsh and
167+
// Windows PowerShell). We omit `-NoProfile` so the user's profile loads
168+
// first; the dot-sourced script then wraps their `prompt`. Augments an
169+
// interactive launch that already runs a startup command (so the VS
170+
// "Developer PowerShell" is covered); skips non-interactive one-offs.
171+
// WSL — `wsl.exe -d <distro>` launches the distro's login shell, where the
172+
// Windows-side injection can't reach. We append a `sh -c` detector that
173+
// execs bash with our `--init-file` (the bash script on the Windows FS,
174+
// referenced via its `/mnt/...` path) when the user's login shell is bash,
175+
// and otherwise execs their login shell unchanged — so non-bash users keep
176+
// their shell. bash is the only WSL shell we integrate for now.
177+
function applyShellIntegration(shell, env, shellArgs, integrationDir, runtime = {}) {
101178
const fsModule = runtime.fsModule || fs;
102-
const shellName = path.posix.basename(shell || '').toLowerCase();
179+
const stem = shellStem(shell);
103180

104-
if (shellName === 'zsh') {
181+
if (stem === 'zsh') {
105182
const zshDir = path.join(integrationDir, 'zsh');
106183
if (fileExists(path.join(zshDir, '.zshrc'), fsModule)) {
107184
return {
@@ -111,13 +188,46 @@ function applyShellIntegration(shell, env, shellArgs, integrationDir, hasExplici
111188
}
112189
}
113190

114-
if (shellName === 'bash' && !hasExplicitArgs) {
191+
if (stem === 'bash' && bashArgsAreInjectable(shellArgs)) {
115192
const script = path.join(integrationDir, 'bash', 'shellIntegration.bash');
116193
if (fileExists(script, fsModule)) {
117194
return { env, shellArgs: ['--init-file', script] };
118195
}
119196
}
120197

198+
if (stem === 'pwsh' || stem === 'powershell') {
199+
const script = path.join(integrationDir, 'pwsh', 'shellIntegration.ps1');
200+
if (fileExists(script, fsModule)) {
201+
const integratedArgs = powerShellIntegratedArgs(shellArgs, script);
202+
if (integratedArgs) return { env, shellArgs: integratedArgs };
203+
}
204+
}
205+
206+
// WSL: only the standard `-d <distro>` launch (the shape the picker emits).
207+
if (stem === 'wsl' && shellArgs.length === 2 && shellArgs[0] === '-d') {
208+
const script = path.join(integrationDir, 'bash', 'shellIntegration.bash');
209+
const mount = winPathToWslMount(script);
210+
if (mount && fileExists(script, fsModule)) {
211+
// A `sh -c` detector, passed as one argv element so node-pty hands it to
212+
// wsl.exe → sh verbatim (no shell-quoting games). It reads the login shell
213+
// from /etc/passwd (NSS-independent, unlike getent which flaked on cold
214+
// starts) and: steps aside for an explicit zsh/fish login shell; otherwise
215+
// execs bash with our init-file when bash exists (covering bash and an empty
216+
// detection — the safe default, since bash is near-universal on WSL); and
217+
// falls back to the login shell only when bash is absent (e.g. Alpine). The
218+
// init-file path is single-quoted for sh so spaces ("Program Files") survive.
219+
// One sh statement per line for readability; joined into a single -c string.
220+
const detector = [
221+
'u=$(whoami 2>/dev/null);',
222+
'login=$(grep "^$u:" /etc/passwd 2>/dev/null | cut -d: -f7);',
223+
'if command -v bash >/dev/null 2>&1; then',
224+
`case "$login" in *zsh|*fish) exec "$login" -l;; *) exec bash --init-file '${mount}' -i;; esac; fi;`,
225+
'exec "${login:-/bin/sh}" -l',
226+
].join(' ');
227+
return { env, shellArgs: [...shellArgs, '--', 'sh', '-c', detector] };
228+
}
229+
}
230+
121231
return { env, shellArgs };
122232
}
123233

@@ -151,8 +261,7 @@ function resolveSpawnConfig(options, runtime = {}) {
151261
LC_TERMINAL_VERSION: ITERM2_COMPAT_VERSION,
152262
DORMOUSE_SURFACE_ID: surfaceId || options?.id || '',
153263
};
154-
const hasExplicitArgs = Boolean(explicitArgs && explicitArgs.length > 0);
155-
const integrated = applyShellIntegration(shell, childEnv, shellArgs, integrationDir, hasExplicitArgs, runtime);
264+
const integrated = applyShellIntegration(shell, childEnv, shellArgs, integrationDir, runtime);
156265

157266
return {
158267
cols,
@@ -181,7 +290,7 @@ function fileExists(filePath, fsModule = fs) {
181290
function detectWindowsShells(runtime = {}) {
182291
const env = runtime.env || process.env;
183292
const fsModule = runtime.fsModule || fs;
184-
const execSyncFn = runtime.execSync || execSync;
293+
const execFileSyncFn = runtime.execFileSync || execFileSync;
185294
const systemRoot = env.SystemRoot || env.SYSTEMROOT || 'C:\\Windows';
186295
const shells = [];
187296

@@ -216,23 +325,34 @@ function detectWindowsShells(runtime = {}) {
216325
} catch { /* dir doesn't exist */ }
217326
}
218327

219-
// WSL distributions
220-
try {
221-
const wslExe = path.win32.join(systemRoot, 'System32', 'wsl.exe');
222-
if (fileExists(wslExe, fsModule)) {
223-
const raw = execSyncFn(`"${wslExe}" -l -q`, {
224-
encoding: 'utf-16le',
225-
stdio: ['ignore', 'pipe', 'ignore'],
226-
timeout: 5000,
227-
});
228-
const distros = raw.split(/\r?\n/)
229-
.map((line) => line.replace(/\0/g, '').trim())
230-
.filter(Boolean);
231-
for (const distro of distros) {
232-
shells.push({ name: distro, path: wslExe, args: ['-d', distro] });
328+
// WSL distributions. Read from the registry rather than `wsl.exe -l -q`: that
329+
// call hangs on its piped stdio when the sidecar has no console (the normal
330+
// packaged/GUI launch), so it would hit its timeout and drop every distro. The
331+
// registry (`HKCU\...\Lxss\<guid>\DistributionName`) is the same source wsl.exe
332+
// reads, mirroring how Windows Terminal enumerates WSL.
333+
//
334+
// windowsHide (CREATE_NO_WINDOW) below is essential, not cosmetic: the sidecar
335+
// is itself spawned CREATE_NO_WINDOW, and a *synchronous* spawn of a console
336+
// child without that flag deadlocks on Windows console allocation — that is the
337+
// actual reason the old wsl.exe call timed out, and reg.exe times out the same
338+
// way without it.
339+
const wslExe = path.win32.join(systemRoot, 'System32', 'wsl.exe');
340+
if (fileExists(wslExe, fsModule)) {
341+
try {
342+
const regExe = path.win32.join(systemRoot, 'System32', 'reg.exe');
343+
const raw = execFileSyncFn(
344+
regExe,
345+
['query', 'HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Lxss', '/s', '/v', 'DistributionName'],
346+
{ encoding: 'utf-8', stdio: ['ignore', 'pipe', 'pipe'], timeout: 5000, windowsHide: true },
347+
);
348+
for (const line of raw.split(/\r?\n/)) {
349+
const match = line.match(/^\s*DistributionName\s+REG_SZ\s+(.+?)\s*$/);
350+
if (match) {
351+
shells.push({ name: match[1], path: wslExe, args: ['-d', match[1]] });
352+
}
233353
}
234-
}
235-
} catch { /* WSL not installed or no distros */ }
354+
} catch { /* no Lxss key (no distros installed) or reg.exe unavailable */ }
355+
}
236356

237357
// Git Bash
238358
const gitBashPaths = [
@@ -767,7 +887,9 @@ function runPowerShell(script, execFileSyncFn) {
767887
return execFileSyncFn(
768888
'powershell.exe',
769889
['-NoProfile', '-NonInteractive', '-Command', script],
770-
{ encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'], timeout: OPEN_PORT_TIMEOUT_MS },
890+
// windowsHide: a synchronous spawn of a console child from the CREATE_NO_WINDOW
891+
// sidecar deadlocks on console allocation without it (see the WSL note above).
892+
{ encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'], timeout: OPEN_PORT_TIMEOUT_MS, windowsHide: true },
771893
);
772894
}
773895

@@ -807,6 +929,7 @@ function windowsListeningPorts(pids, runtime = {}) {
807929
encoding: 'utf-8',
808930
stdio: ['ignore', 'pipe', 'ignore'],
809931
timeout: OPEN_PORT_TIMEOUT_MS,
932+
windowsHide: true, // see runPowerShell: avoid the console-allocation deadlock
810933
});
811934
return parseNetstatListening(out, pidSet, nameByPid);
812935
} catch {

0 commit comments

Comments
 (0)