Skip to content

Commit a3774d3

Browse files
authored
Fix windows launching and shell detection (#11)
2 parents 40318fc + 66d3243 commit a3774d3

File tree

19 files changed

+3111
-49
lines changed

19 files changed

+3111
-49
lines changed

docs/specs/vscode.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -144,12 +144,13 @@ All types defined in `message-types.ts`. Webview-side handling in `vscode-adapte
144144

145145
| Message | Purpose |
146146
|---------|---------|
147-
| `pty:spawn` | Create new PTY (id, optional cols/rows/cwd) |
147+
| `pty:spawn` | Create new PTY (id, optional cols/rows/cwd/shell/args) |
148148
| `pty:input` | Write data to PTY |
149149
| `pty:resize` | Resize PTY dimensions |
150150
| `pty:kill` | Kill PTY and release ownership |
151151
| `pty:getCwd` | Query PTY working directory (request-response via requestId) |
152152
| `pty:getScrollback` | Query PTY scrollback buffer (request-response via requestId) |
153+
| `pty:getShells` | Query available shells (request-response via requestId) |
153154
| `mouseterm:init` | Trigger reconnection: get PTY list + replay data |
154155
| `mouseterm:saveState` | Frontend persisting session state |
155156
| `mouseterm:flushSessionSaveDone` | Ack for deactivate-triggered flush (matched by requestId) |
@@ -175,6 +176,7 @@ All types defined in `message-types.ts`. Webview-side handling in `vscode-adapte
175176
| `pty:replay` | Buffered output since spawn (response to `mouseterm:init`) |
176177
| `pty:cwd` | CWD query response (matched by requestId) |
177178
| `pty:scrollback` | Scrollback query response (matched by requestId) |
179+
| `pty:shells` | Available shells list response (matched by requestId) |
178180
| `mouseterm:flushSessionSave` | Request webview to save state now (deactivate trigger, matched by requestId) |
179181
| `alarm:state` | Alarm state change (status, todo, attentionDismissedRing) |
180182

lib/src/components/Pond.tsx

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ import {
3232
toggleSessionTodo,
3333
destroyTerminal,
3434
swapTerminals,
35+
setPendingShellOpts,
3536
type SessionStatus,
3637
} from '../lib/terminal-registry';
3738
import { resolvePanelElement, findPanelInDirection, findRestoreNeighbor, type DetachDirection } from '../lib/spatial-nav';
@@ -1690,10 +1691,17 @@ export function Pond({
16901691

16911692
// Listen for external "new terminal" requests (e.g. from the standalone AppBar)
16921693
useEffect(() => {
1693-
const handler = () => {
1694+
const handler = (e: Event) => {
16941695
const api = apiRef.current;
16951696
if (!api) return;
1697+
const detail = (e as CustomEvent).detail;
16961698
const newId = generatePaneId();
1699+
1700+
// Store shell options so getOrCreateTerminal picks them up on mount
1701+
if (detail?.shell) {
1702+
setPendingShellOpts(newId, { shell: detail.shell, args: detail.args });
1703+
}
1704+
16971705
const active = api.activePanel;
16981706
let direction: 'right' | 'below' = 'right';
16991707
if (active) {

lib/src/lib/platform/fake-adapter.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,10 @@ export class FakePtyAdapter implements PlatformAdapter {
6666
});
6767
}
6868

69+
async getAvailableShells(): Promise<{ name: string; path: string; args?: string[] }[]> {
70+
return [{ name: 'fake-shell', path: '/bin/fake', args: [] }];
71+
}
72+
6973
spawnPty(id: string): void {
7074
this.terminals.add(id);
7175
const scenario = this.scenarioMap.get(id) ?? this.defaultScenario;

lib/src/lib/platform/types.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,11 @@ export interface PlatformAdapter {
1313
init(): Promise<void>;
1414
shutdown(): void;
1515

16+
// Shell detection
17+
getAvailableShells(): Promise<{ name: string; path: string; args?: string[] }[]>;
18+
1619
// PTY operations
17-
spawnPty(id: string, options?: { cols?: number; rows?: number; cwd?: string }): void;
20+
spawnPty(id: string, options?: { cols?: number; rows?: number; cwd?: string; shell?: string; args?: string[] }): void;
1821
writePty(id: string, data: string): void;
1922
resizePty(id: string, cols: number, rows: number): void;
2023
killPty(id: string): void;

lib/src/lib/platform/vscode-adapter.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,16 @@ export class VSCodeAdapter implements PlatformAdapter {
8080
// No-op — the extension host handles cleanup
8181
}
8282

83-
spawnPty(id: string, options?: { cols?: number; rows?: number; cwd?: string }): void {
83+
async getAvailableShells(): Promise<{ name: string; path: string; args?: string[] }[]> {
84+
const result = await this.requestResponse(
85+
'pty:getShells', 'pty:shells', {},
86+
(msg) => msg.shells as { name: string; path: string; args?: string[] }[],
87+
5000,
88+
);
89+
return result ?? [];
90+
}
91+
92+
spawnPty(id: string, options?: { cols?: number; rows?: number; cwd?: string; shell?: string; args?: string[] }): void {
8493
this.vscode.postMessage({ type: 'pty:spawn', id, options });
8594
}
8695

lib/src/lib/session-save.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ function createPlatform(savedState: PersistedSession | null): PlatformAdapter {
2424
writePty: () => {},
2525
resizePty: () => {},
2626
killPty: () => {},
27+
getAvailableShells: vi.fn(async () => []),
2728
getCwd: vi.fn(async () => '/tmp/live'),
2829
getScrollback: vi.fn(async () => 'echo hello\n'),
2930
onPtyData: () => {},

lib/src/lib/terminal-registry.ts

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ interface TerminalEntry {
3737
}
3838

3939
const registry = new Map<string, TerminalEntry>();
40+
const pendingShellOpts = new Map<string, { shell?: string; args?: string[] }>();
4041
const primedSessionStates = new Map<string, Partial<SessionUiState>>();
4142

4243
// --- Watch for VSCode theme changes and re-apply xterm themes ---
@@ -390,6 +391,14 @@ function setupTerminalEntry(id: string): TerminalEntry {
390391
return entry;
391392
}
392393

394+
/**
395+
* Store shell options for a terminal that will be created shortly.
396+
* The options are consumed (deleted) by getOrCreateTerminal when the terminal mounts.
397+
*/
398+
export function setPendingShellOpts(id: string, opts: { shell?: string; args?: string[] }): void {
399+
pendingShellOpts.set(id, opts);
400+
}
401+
393402
/**
394403
* Get or create a terminal for the given pane ID.
395404
* The terminal is created once and persists across React mount/unmount cycles.
@@ -400,9 +409,17 @@ export function getOrCreateTerminal(id: string): TerminalEntry {
400409

401410
const entry = setupTerminalEntry(id);
402411

412+
// Consume any pending shell options set before panel creation
413+
const shellOpts = pendingShellOpts.get(id);
414+
pendingShellOpts.delete(id);
415+
403416
// Spawn PTY
404417
const dims = entry.fit.proposeDimensions();
405-
getPlatform().spawnPty(id, { cols: dims?.cols || 80, rows: dims?.rows || 30 });
418+
getPlatform().spawnPty(id, {
419+
cols: dims?.cols || 80,
420+
rows: dims?.rows || 30,
421+
...shellOpts,
422+
});
406423

407424
return entry;
408425
}

standalone/sidecar/main.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ rl.on('line', (line) => {
2727
case 'pty:requestInit': mgr.list(); break;
2828
case 'pty:getCwd': mgr.getCwd(data.id, data.requestId); break;
2929
case 'pty:getScrollback': mgr.getScrollback(data.id, data.requestId); break;
30+
case 'pty:getShells': mgr.getShells(data.requestId); break;
3031
case 'pty:gracefulKillAll': mgr.gracefulKillAll(data.timeout); break;
3132
default: console.error(`[sidecar] Unknown event: ${event}`);
3233
}

standalone/sidecar/pty-core.js

Lines changed: 145 additions & 6 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 } = require('node:child_process');
4+
const { execFileSync, execSync } = require('node:child_process');
55

66
function safeResolve(resolver) {
77
try {
@@ -55,14 +55,15 @@ function directoryExists(cwd, fsModule = fs) {
5555
}
5656

5757
function resolveSpawnConfig(options, runtime = {}) {
58-
const { cols = 80, rows = 30, cwd } = options || {};
58+
const { cols = 80, rows = 30, cwd, shell: explicitShell, args: explicitArgs } = options || {};
5959
const env = runtime.env || process.env;
6060
const platform = runtime.platform || process.platform;
6161
const osModule = runtime.osModule || os;
6262
const fsModule = runtime.fsModule || fs;
6363
const defaultCwd = resolveDefaultCwd(platform, env, osModule);
6464
const missingExplicitCwd = Boolean(cwd) && !directoryExists(cwd, fsModule);
65-
const shell = resolveDefaultShell(platform, env);
65+
const shell = explicitShell || resolveDefaultShell(platform, env);
66+
const shellArgs = explicitArgs || resolveLoginArg(shell, platform);
6667

6768
return {
6869
cols,
@@ -71,12 +72,146 @@ function resolveSpawnConfig(options, runtime = {}) {
7172
cwdWarning: missingExplicitCwd ? `unable to restore because directory ${cwd} was removed` : null,
7273
env: { ...env, TERM_PROGRAM: 'MouseTerm' },
7374
shell,
74-
loginArg: resolveLoginArg(shell, platform),
75+
shellArgs,
7576
};
7677
}
7778

7879
module.exports.resolveSpawnConfig = resolveSpawnConfig;
7980

81+
// ── Shell detection ────────────────────────────────────────────────────────
82+
83+
function fileExists(filePath, fsModule = fs) {
84+
try {
85+
return fsModule.statSync(filePath).isFile();
86+
} catch {
87+
return false;
88+
}
89+
}
90+
91+
function detectWindowsShells(runtime = {}) {
92+
const env = runtime.env || process.env;
93+
const fsModule = runtime.fsModule || fs;
94+
const execSyncFn = runtime.execSync || execSync;
95+
const systemRoot = env.SystemRoot || env.SYSTEMROOT || 'C:\\Windows';
96+
const shells = [];
97+
98+
// Windows PowerShell (built-in)
99+
const winPowerShell = path.win32.join(systemRoot, 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe');
100+
if (fileExists(winPowerShell, fsModule)) {
101+
shells.push({ name: 'Windows PowerShell', path: winPowerShell, args: [] });
102+
}
103+
104+
// Command Prompt
105+
const cmdPath = env.ComSpec || env.COMSPEC || path.win32.join(systemRoot, 'System32', 'cmd.exe');
106+
if (fileExists(cmdPath, fsModule)) {
107+
shells.push({ name: 'Command Prompt', path: cmdPath, args: [] });
108+
}
109+
110+
// PowerShell Core (pwsh) — scan Program Files
111+
const pwshDirs = [
112+
path.win32.join(env.ProgramFiles || 'C:\\Program Files', 'PowerShell'),
113+
path.win32.join(env['ProgramFiles(x86)'] || 'C:\\Program Files (x86)', 'PowerShell'),
114+
];
115+
for (const dir of pwshDirs) {
116+
try {
117+
const versions = fsModule.readdirSync(dir).sort().reverse();
118+
for (const ver of versions) {
119+
const pwshPath = path.win32.join(dir, ver, 'pwsh.exe');
120+
if (fileExists(pwshPath, fsModule)) {
121+
shells.push({ name: 'PowerShell', path: pwshPath, args: [] });
122+
break; // only add the newest version
123+
}
124+
}
125+
if (shells.some((s) => s.name === 'PowerShell')) break;
126+
} catch { /* dir doesn't exist */ }
127+
}
128+
129+
// WSL distributions
130+
try {
131+
const wslExe = path.win32.join(systemRoot, 'System32', 'wsl.exe');
132+
if (fileExists(wslExe, fsModule)) {
133+
const raw = execSyncFn(`"${wslExe}" -l -q`, {
134+
encoding: 'utf-16le',
135+
stdio: ['ignore', 'pipe', 'ignore'],
136+
timeout: 5000,
137+
});
138+
const distros = raw.split(/\r?\n/)
139+
.map((line) => line.replace(/\0/g, '').trim())
140+
.filter(Boolean);
141+
for (const distro of distros) {
142+
shells.push({ name: distro, path: wslExe, args: ['-d', distro] });
143+
}
144+
}
145+
} catch { /* WSL not installed or no distros */ }
146+
147+
// Git Bash
148+
const gitBashPaths = [
149+
path.win32.join(env.ProgramFiles || 'C:\\Program Files', 'Git', 'bin', 'bash.exe'),
150+
path.win32.join(env['ProgramFiles(x86)'] || 'C:\\Program Files (x86)', 'Git', 'bin', 'bash.exe'),
151+
];
152+
for (const gbPath of gitBashPaths) {
153+
if (fileExists(gbPath, fsModule)) {
154+
shells.push({ name: 'Git Bash', path: gbPath, args: ['--login', '-i'] });
155+
break;
156+
}
157+
}
158+
159+
// Visual Studio Developer shells
160+
const vsBasePaths = [
161+
env.ProgramFiles || 'C:\\Program Files',
162+
env['ProgramFiles(x86)'] || 'C:\\Program Files (x86)',
163+
];
164+
for (const base of vsBasePaths) {
165+
const vsRoot = path.win32.join(base, 'Microsoft Visual Studio');
166+
let years;
167+
try { years = fsModule.readdirSync(vsRoot); } catch { continue; }
168+
for (const year of years.sort().reverse()) {
169+
let editions;
170+
try { editions = fsModule.readdirSync(path.win32.join(vsRoot, year)); } catch { continue; }
171+
for (const edition of editions) {
172+
const toolsDir = path.win32.join(vsRoot, year, edition, 'Common7', 'Tools');
173+
174+
// Developer Command Prompt
175+
const vsDevCmd = path.win32.join(toolsDir, 'VsDevCmd.bat');
176+
if (fileExists(vsDevCmd, fsModule) && fileExists(cmdPath, fsModule)) {
177+
shells.push({
178+
name: `Developer Command Prompt for VS ${year}`,
179+
path: cmdPath,
180+
args: ['/k', vsDevCmd],
181+
});
182+
}
183+
184+
// Developer PowerShell
185+
const launchScript = path.win32.join(toolsDir, 'Launch-VsDevShell.ps1');
186+
if (fileExists(launchScript, fsModule) && fileExists(winPowerShell, fsModule)) {
187+
shells.push({
188+
name: `Developer PowerShell for VS ${year}`,
189+
path: winPowerShell,
190+
args: ['-NoExit', '-Command', `& { Import-Module "${launchScript}" }`],
191+
});
192+
}
193+
}
194+
}
195+
}
196+
197+
return shells;
198+
}
199+
200+
function detectAvailableShells(runtime = {}) {
201+
const platform = runtime.platform || process.platform;
202+
if (platform === 'win32') {
203+
return detectWindowsShells(runtime);
204+
}
205+
206+
// macOS / Linux: return $SHELL or /bin/sh
207+
const env = runtime.env || process.env;
208+
const shellPath = env.SHELL || '/bin/sh';
209+
const name = path.posix.basename(shellPath);
210+
return [{ name, path: shellPath, args: [] }];
211+
}
212+
213+
module.exports.detectAvailableShells = detectAvailableShells;
214+
80215
function parseCwdFromLsof(output, pid) {
81216
const lines = output.split(/\r?\n/);
82217
let inTargetProcess = false;
@@ -173,7 +308,7 @@ module.exports.create = function create(send, ptyModule) {
173308

174309
let p;
175310
try {
176-
p = pty.spawn(config.shell, config.loginArg, {
311+
p = pty.spawn(config.shell, config.shellArgs, {
177312
name: 'xterm-256color',
178313
cols: config.cols,
179314
rows: config.rows,
@@ -267,5 +402,9 @@ module.exports.create = function create(send, ptyModule) {
267402
}, timeout);
268403
}
269404

270-
return { spawn, write, resize, kill, killAll, list, getCwd, getScrollback, gracefulKillAll };
405+
function getShells(requestId) {
406+
send('shells', { shells: detectAvailableShells(), requestId });
407+
}
408+
409+
return { spawn, write, resize, kill, killAll, list, getCwd, getScrollback, gracefulKillAll, getShells };
271410
};

0 commit comments

Comments
 (0)