Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion docs/specs/vscode.md
Original file line number Diff line number Diff line change
Expand Up @@ -144,12 +144,13 @@ All types defined in `message-types.ts`. Webview-side handling in `vscode-adapte

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

Expand Down
10 changes: 9 additions & 1 deletion lib/src/components/Pond.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import {
toggleSessionTodo,
destroyTerminal,
swapTerminals,
setPendingShellOpts,
type SessionStatus,
} from '../lib/terminal-registry';
import { resolvePanelElement, findPanelInDirection, findRestoreNeighbor, type DetachDirection } from '../lib/spatial-nav';
Expand Down Expand Up @@ -1690,10 +1691,17 @@ export function Pond({

// Listen for external "new terminal" requests (e.g. from the standalone AppBar)
useEffect(() => {
const handler = () => {
const handler = (e: Event) => {
const api = apiRef.current;
if (!api) return;
const detail = (e as CustomEvent).detail;
const newId = generatePaneId();

// Store shell options so getOrCreateTerminal picks them up on mount
if (detail?.shell) {
setPendingShellOpts(newId, { shell: detail.shell, args: detail.args });
}

const active = api.activePanel;
let direction: 'right' | 'below' = 'right';
if (active) {
Expand Down
4 changes: 4 additions & 0 deletions lib/src/lib/platform/fake-adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,10 @@ export class FakePtyAdapter implements PlatformAdapter {
});
}

async getAvailableShells(): Promise<{ name: string; path: string; args?: string[] }[]> {
return [{ name: 'fake-shell', path: '/bin/fake', args: [] }];
}

spawnPty(id: string): void {
this.terminals.add(id);
const scenario = this.scenarioMap.get(id) ?? this.defaultScenario;
Expand Down
5 changes: 4 additions & 1 deletion lib/src/lib/platform/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,11 @@ export interface PlatformAdapter {
init(): Promise<void>;
shutdown(): void;

// Shell detection
getAvailableShells(): Promise<{ name: string; path: string; args?: string[] }[]>;

// PTY operations
spawnPty(id: string, options?: { cols?: number; rows?: number; cwd?: string }): void;
spawnPty(id: string, options?: { cols?: number; rows?: number; cwd?: string; shell?: string; args?: string[] }): void;
writePty(id: string, data: string): void;
resizePty(id: string, cols: number, rows: number): void;
killPty(id: string): void;
Expand Down
11 changes: 10 additions & 1 deletion lib/src/lib/platform/vscode-adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,16 @@ export class VSCodeAdapter implements PlatformAdapter {
// No-op — the extension host handles cleanup
}

spawnPty(id: string, options?: { cols?: number; rows?: number; cwd?: string }): void {
async getAvailableShells(): Promise<{ name: string; path: string; args?: string[] }[]> {
const result = await this.requestResponse(
'pty:getShells', 'pty:shells', {},
(msg) => msg.shells as { name: string; path: string; args?: string[] }[],
5000,
);
return result ?? [];
}

spawnPty(id: string, options?: { cols?: number; rows?: number; cwd?: string; shell?: string; args?: string[] }): void {
this.vscode.postMessage({ type: 'pty:spawn', id, options });
}

Expand Down
1 change: 1 addition & 0 deletions lib/src/lib/session-save.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ function createPlatform(savedState: PersistedSession | null): PlatformAdapter {
writePty: () => {},
resizePty: () => {},
killPty: () => {},
getAvailableShells: vi.fn(async () => []),
getCwd: vi.fn(async () => '/tmp/live'),
getScrollback: vi.fn(async () => 'echo hello\n'),
onPtyData: () => {},
Expand Down
19 changes: 18 additions & 1 deletion lib/src/lib/terminal-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ interface TerminalEntry {
}

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

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

/**
* Store shell options for a terminal that will be created shortly.
* The options are consumed (deleted) by getOrCreateTerminal when the terminal mounts.
*/
export function setPendingShellOpts(id: string, opts: { shell?: string; args?: string[] }): void {
pendingShellOpts.set(id, opts);
}

/**
* Get or create a terminal for the given pane ID.
* The terminal is created once and persists across React mount/unmount cycles.
Expand All @@ -400,9 +409,17 @@ export function getOrCreateTerminal(id: string): TerminalEntry {

const entry = setupTerminalEntry(id);

// Consume any pending shell options set before panel creation
const shellOpts = pendingShellOpts.get(id);
pendingShellOpts.delete(id);

// Spawn PTY
const dims = entry.fit.proposeDimensions();
getPlatform().spawnPty(id, { cols: dims?.cols || 80, rows: dims?.rows || 30 });
getPlatform().spawnPty(id, {
cols: dims?.cols || 80,
rows: dims?.rows || 30,
...shellOpts,
});

return entry;
}
Expand Down
1 change: 1 addition & 0 deletions standalone/sidecar/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ rl.on('line', (line) => {
case 'pty:requestInit': mgr.list(); break;
case 'pty:getCwd': mgr.getCwd(data.id, data.requestId); break;
case 'pty:getScrollback': mgr.getScrollback(data.id, data.requestId); break;
case 'pty:getShells': mgr.getShells(data.requestId); break;
case 'pty:gracefulKillAll': mgr.gracefulKillAll(data.timeout); break;
default: console.error(`[sidecar] Unknown event: ${event}`);
}
Expand Down
151 changes: 145 additions & 6 deletions standalone/sidecar/pty-core.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const { execFileSync } = require('node:child_process');
const { execFileSync, execSync } = require('node:child_process');

function safeResolve(resolver) {
try {
Expand Down Expand Up @@ -55,14 +55,15 @@ function directoryExists(cwd, fsModule = fs) {
}

function resolveSpawnConfig(options, runtime = {}) {
const { cols = 80, rows = 30, cwd } = options || {};
const { cols = 80, rows = 30, cwd, shell: explicitShell, args: explicitArgs } = options || {};
const env = runtime.env || process.env;
const platform = runtime.platform || process.platform;
const osModule = runtime.osModule || os;
const fsModule = runtime.fsModule || fs;
const defaultCwd = resolveDefaultCwd(platform, env, osModule);
const missingExplicitCwd = Boolean(cwd) && !directoryExists(cwd, fsModule);
const shell = resolveDefaultShell(platform, env);
const shell = explicitShell || resolveDefaultShell(platform, env);
const shellArgs = explicitArgs || resolveLoginArg(shell, platform);

return {
cols,
Expand All @@ -71,12 +72,146 @@ function resolveSpawnConfig(options, runtime = {}) {
cwdWarning: missingExplicitCwd ? `unable to restore because directory ${cwd} was removed` : null,
env: { ...env, TERM_PROGRAM: 'MouseTerm' },
shell,
loginArg: resolveLoginArg(shell, platform),
shellArgs,
};
}

module.exports.resolveSpawnConfig = resolveSpawnConfig;

// ── Shell detection ────────────────────────────────────────────────────────

function fileExists(filePath, fsModule = fs) {
try {
return fsModule.statSync(filePath).isFile();
} catch {
return false;
}
}

function detectWindowsShells(runtime = {}) {
const env = runtime.env || process.env;
const fsModule = runtime.fsModule || fs;
const execSyncFn = runtime.execSync || execSync;
const systemRoot = env.SystemRoot || env.SYSTEMROOT || 'C:\\Windows';
const shells = [];

// Windows PowerShell (built-in)
const winPowerShell = path.win32.join(systemRoot, 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe');
if (fileExists(winPowerShell, fsModule)) {
shells.push({ name: 'Windows PowerShell', path: winPowerShell, args: [] });
}

// Command Prompt
const cmdPath = env.ComSpec || env.COMSPEC || path.win32.join(systemRoot, 'System32', 'cmd.exe');
if (fileExists(cmdPath, fsModule)) {
shells.push({ name: 'Command Prompt', path: cmdPath, args: [] });
}

// PowerShell Core (pwsh) — scan Program Files
const pwshDirs = [
path.win32.join(env.ProgramFiles || 'C:\\Program Files', 'PowerShell'),
path.win32.join(env['ProgramFiles(x86)'] || 'C:\\Program Files (x86)', 'PowerShell'),
];
for (const dir of pwshDirs) {
try {
const versions = fsModule.readdirSync(dir).sort().reverse();
for (const ver of versions) {
const pwshPath = path.win32.join(dir, ver, 'pwsh.exe');
if (fileExists(pwshPath, fsModule)) {
shells.push({ name: 'PowerShell', path: pwshPath, args: [] });
break; // only add the newest version
}
}
if (shells.some((s) => s.name === 'PowerShell')) break;
} catch { /* dir doesn't exist */ }
}

// WSL distributions
try {
const wslExe = path.win32.join(systemRoot, 'System32', 'wsl.exe');
if (fileExists(wslExe, fsModule)) {
const raw = execSyncFn(`"${wslExe}" -l -q`, {
encoding: 'utf-16le',
stdio: ['ignore', 'pipe', 'ignore'],
timeout: 5000,
});
const distros = raw.split(/\r?\n/)
.map((line) => line.replace(/\0/g, '').trim())
.filter(Boolean);
for (const distro of distros) {
shells.push({ name: distro, path: wslExe, args: ['-d', distro] });
}
}
} catch { /* WSL not installed or no distros */ }

// Git Bash
const gitBashPaths = [
path.win32.join(env.ProgramFiles || 'C:\\Program Files', 'Git', 'bin', 'bash.exe'),
path.win32.join(env['ProgramFiles(x86)'] || 'C:\\Program Files (x86)', 'Git', 'bin', 'bash.exe'),
];
for (const gbPath of gitBashPaths) {
if (fileExists(gbPath, fsModule)) {
shells.push({ name: 'Git Bash', path: gbPath, args: ['--login', '-i'] });
break;
}
}

// Visual Studio Developer shells
const vsBasePaths = [
env.ProgramFiles || 'C:\\Program Files',
env['ProgramFiles(x86)'] || 'C:\\Program Files (x86)',
];
for (const base of vsBasePaths) {
const vsRoot = path.win32.join(base, 'Microsoft Visual Studio');
let years;
try { years = fsModule.readdirSync(vsRoot); } catch { continue; }
for (const year of years.sort().reverse()) {
let editions;
try { editions = fsModule.readdirSync(path.win32.join(vsRoot, year)); } catch { continue; }
for (const edition of editions) {
const toolsDir = path.win32.join(vsRoot, year, edition, 'Common7', 'Tools');

// Developer Command Prompt
const vsDevCmd = path.win32.join(toolsDir, 'VsDevCmd.bat');
if (fileExists(vsDevCmd, fsModule) && fileExists(cmdPath, fsModule)) {
shells.push({
name: `Developer Command Prompt for VS ${year}`,
path: cmdPath,
args: ['/k', vsDevCmd],
});
}

// Developer PowerShell
const launchScript = path.win32.join(toolsDir, 'Launch-VsDevShell.ps1');
if (fileExists(launchScript, fsModule) && fileExists(winPowerShell, fsModule)) {
shells.push({
name: `Developer PowerShell for VS ${year}`,
path: winPowerShell,
args: ['-NoExit', '-Command', `& { Import-Module "${launchScript}" }`],
});
}
}
}
}

return shells;
}

function detectAvailableShells(runtime = {}) {
const platform = runtime.platform || process.platform;
if (platform === 'win32') {
return detectWindowsShells(runtime);
}

// macOS / Linux: return $SHELL or /bin/sh
const env = runtime.env || process.env;
const shellPath = env.SHELL || '/bin/sh';
const name = path.posix.basename(shellPath);
return [{ name, path: shellPath, args: [] }];
}

module.exports.detectAvailableShells = detectAvailableShells;

function parseCwdFromLsof(output, pid) {
const lines = output.split(/\r?\n/);
let inTargetProcess = false;
Expand Down Expand Up @@ -173,7 +308,7 @@ module.exports.create = function create(send, ptyModule) {

let p;
try {
p = pty.spawn(config.shell, config.loginArg, {
p = pty.spawn(config.shell, config.shellArgs, {
name: 'xterm-256color',
cols: config.cols,
rows: config.rows,
Expand Down Expand Up @@ -267,5 +402,9 @@ module.exports.create = function create(send, ptyModule) {
}, timeout);
}

return { spawn, write, resize, kill, killAll, list, getCwd, getScrollback, gracefulKillAll };
function getShells(requestId) {
send('shells', { shells: detectAvailableShells(), requestId });
}

return { spawn, write, resize, kill, killAll, list, getCwd, getScrollback, gracefulKillAll, getShells };
};
Loading
Loading