Skip to content

Commit a43b7f6

Browse files
authored
fix: resize remote terminals (#619)
1 parent 34f0b27 commit a43b7f6

File tree

3 files changed

+142
-130
lines changed

3 files changed

+142
-130
lines changed

src/runtime/SSHRuntime.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,13 @@ export class SSHRuntime implements Runtime {
8181
this.controlPath = getControlPath(config);
8282
}
8383

84+
/**
85+
* Get SSH configuration (for PTY terminal spawning)
86+
*/
87+
public getConfig(): SSHRuntimeConfig {
88+
return this.config;
89+
}
90+
8491
/**
8592
* Execute command over SSH with streaming I/O
8693
*/

src/services/ipcMain.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1535,18 +1535,18 @@ export class IpcMain {
15351535

15361536
// Handle terminal input (keyboard, etc.)
15371537
// Use handle() for both Electron and browser mode
1538-
ipcMain.handle(IPC_CHANNELS.TERMINAL_INPUT, async (_event, sessionId: string, data: string) => {
1538+
ipcMain.handle(IPC_CHANNELS.TERMINAL_INPUT, (_event, sessionId: string, data: string) => {
15391539
try {
1540-
await this.ptyService.sendInput(sessionId, data);
1540+
this.ptyService.sendInput(sessionId, data);
15411541
} catch (err) {
15421542
log.error(`Error sending input to terminal ${sessionId}:`, err);
15431543
throw err;
15441544
}
15451545
});
15461546

1547-
ipcMain.handle(IPC_CHANNELS.TERMINAL_CLOSE, async (_event, sessionId: string) => {
1547+
ipcMain.handle(IPC_CHANNELS.TERMINAL_CLOSE, (_event, sessionId: string) => {
15481548
try {
1549-
await this.ptyService.closeSession(sessionId);
1549+
this.ptyService.closeSession(sessionId);
15501550
} catch (err) {
15511551
log.error("Error closing terminal session:", err);
15521552
throw err;

src/services/ptyService.ts

Lines changed: 131 additions & 126 deletions
Original file line numberDiff line numberDiff line change
@@ -6,25 +6,70 @@
66
*/
77

88
import { log } from "@/services/log";
9-
import type { Runtime, ExecStream } from "@/runtime/Runtime";
9+
import type { Runtime } from "@/runtime/Runtime";
1010
import type { TerminalSession, TerminalCreateParams, TerminalResizeParams } from "@/types/terminal";
1111
import type { IPty } from "node-pty";
12-
import { SSHRuntime } from "@/runtime/SSHRuntime";
12+
import { SSHRuntime, type SSHRuntimeConfig } from "@/runtime/SSHRuntime";
1313
import { LocalRuntime } from "@/runtime/LocalRuntime";
1414
import { access } from "fs/promises";
1515
import { constants } from "fs";
16+
import { getControlPath } from "@/runtime/sshConnectionPool";
17+
import { expandTildeForSSH } from "@/runtime/tildeExpansion";
1618

1719
interface SessionData {
18-
pty?: IPty; // For local sessions
19-
stream?: ExecStream; // For SSH sessions
20-
stdinWriter?: WritableStreamDefaultWriter<Uint8Array>; // Persistent writer for SSH stdin
20+
pty: IPty; // Used for both local and SSH sessions
2121
workspaceId: string;
2222
workspacePath: string;
2323
runtime: Runtime;
2424
onData: (data: string) => void;
2525
onExit: (exitCode: number) => void;
2626
}
2727

28+
/**
29+
* Build SSH command arguments from config
30+
* Preserves ControlMaster connection pooling and respects ~/.ssh/config
31+
*/
32+
function buildSSHArgs(config: SSHRuntimeConfig, remotePath: string): string[] {
33+
const args: string[] = [];
34+
35+
// Add port if specified (overrides ~/.ssh/config)
36+
if (config.port) {
37+
args.push("-p", String(config.port));
38+
}
39+
40+
// Add identity file if specified (overrides ~/.ssh/config)
41+
if (config.identityFile) {
42+
args.push("-i", config.identityFile);
43+
args.push("-o", "StrictHostKeyChecking=no");
44+
args.push("-o", "UserKnownHostsFile=/dev/null");
45+
args.push("-o", "LogLevel=ERROR");
46+
}
47+
48+
// Add connection multiplexing (reuse SSHRuntime's controlPath logic)
49+
const controlPath = getControlPath(config);
50+
args.push("-o", "ControlMaster=auto");
51+
args.push("-o", `ControlPath=${controlPath}`);
52+
args.push("-o", "ControlPersist=60");
53+
54+
// Add connection timeout
55+
args.push("-o", "ConnectTimeout=15");
56+
args.push("-o", "ServerAliveInterval=5");
57+
args.push("-o", "ServerAliveCountMax=2");
58+
59+
// Force PTY allocation
60+
args.push("-t");
61+
62+
// Host (can be alias from ~/.ssh/config)
63+
args.push(config.host);
64+
65+
// Remote command: cd to workspace and start shell
66+
// expandTildeForSSH already handles quoting, so use it directly
67+
const expandedPath = expandTildeForSSH(remotePath);
68+
args.push(`cd ${expandedPath} && exec $SHELL -i`);
69+
70+
return args;
71+
}
72+
2873
/**
2974
* PTYService - Manages terminal PTY sessions for workspaces
3075
*
@@ -152,107 +197,89 @@ export class PTYService {
152197
onExit,
153198
});
154199
} else if (runtime instanceof SSHRuntime) {
155-
// SSH: Use runtime.exec with PTY allocation
156-
// Use 'script' to force a proper PTY session with the shell
157-
// Set LINES and COLUMNS before starting script so the shell knows the terminal size
158-
// -q = quiet (no start/done messages)
159-
// -c = command to run
160-
// /dev/null = don't save output to a file
161-
const command = `export LINES=${params.rows} COLUMNS=${params.cols}; script -qfc "$SHELL -i" /dev/null`;
162-
163-
log.info(`[PTY] SSH command for ${sessionId}: ${command}`);
200+
// SSH: Use node-pty to spawn SSH with local PTY (enables resize support)
201+
const sshConfig = runtime.getConfig();
202+
const sshArgs = buildSSHArgs(sshConfig, workspacePath);
203+
204+
log.info(`[PTY] SSH terminal for ${sessionId}: ssh ${sshArgs.join(" ")}`);
164205
log.info(`[PTY] SSH terminal size: ${params.cols}x${params.rows}`);
165-
log.info(`[PTY] SSH working directory: ${workspacePath}`);
166206

167-
let stream: ExecStream;
207+
// Load node-pty dynamically
208+
// eslint-disable-next-line @typescript-eslint/consistent-type-imports
209+
let pty: typeof import("node-pty");
168210
try {
169-
log.info(`[PTY] Calling runtime.exec for ${sessionId}...`);
170-
// Execute shell with PTY allocation
171-
// Use a very long timeout (24 hours) instead of Infinity
172-
stream = await runtime.exec(command, {
173-
cwd: workspacePath,
174-
timeout: 86400, // 24 hours in seconds
211+
// eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-unsafe-assignment
212+
pty = require("node-pty");
213+
} catch (err) {
214+
log.error("node-pty not available - SSH terminals will not work:", err);
215+
throw new Error(
216+
"SSH terminals are not available. node-pty failed to load (likely due to Electron ABI version mismatch)."
217+
);
218+
}
219+
220+
let ptyProcess: IPty;
221+
try {
222+
// Spawn SSH with PTY (same as local terminals)
223+
ptyProcess = pty.spawn("ssh", sshArgs, {
224+
name: "xterm-256color",
225+
cols: params.cols,
226+
rows: params.rows,
227+
cwd: process.cwd(),
175228
env: {
229+
...process.env,
176230
TERM: "xterm-256color",
231+
PATH: process.env.PATH ?? "/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin",
177232
},
178-
forcePTY: true,
179233
});
180-
log.info(`[PTY] runtime.exec returned successfully for ${sessionId}`);
181234
} catch (err) {
182-
log.error(`[PTY] Failed to create SSH stream for ${sessionId}:`, err);
183-
throw err;
235+
log.error(`[PTY] Failed to spawn SSH terminal ${sessionId}:`, err);
236+
throw new Error(
237+
`Failed to spawn SSH terminal: ${err instanceof Error ? err.message : String(err)}`
238+
);
184239
}
185240

186-
log.info(
187-
`[PTY] SSH stream created for ${sessionId}, stdin writable: ${stream.stdin.locked === false}`
188-
);
241+
// Handle data (same as local - buffer incomplete escape sequences)
242+
let buffer = "";
243+
ptyProcess.onData((data) => {
244+
buffer += data;
245+
let sendUpTo = buffer.length;
189246

190-
// Get a persistent writer for stdin to avoid locking issues
191-
const stdinWriter = stream.stdin.getWriter();
247+
// Hold back incomplete escape sequences
248+
if (buffer.endsWith("\x1b")) {
249+
sendUpTo = buffer.length - 1;
250+
} else if (buffer.endsWith("\x1b[")) {
251+
sendUpTo = buffer.length - 2;
252+
} else {
253+
// eslint-disable-next-line no-control-regex, @typescript-eslint/prefer-regexp-exec
254+
const match = buffer.match(/\x1b\[[0-9;]*$/);
255+
if (match) {
256+
sendUpTo = buffer.length - match[0].length;
257+
}
258+
}
259+
260+
if (sendUpTo > 0) {
261+
const toSend = buffer.substring(0, sendUpTo);
262+
onData(toSend);
263+
buffer = buffer.substring(sendUpTo);
264+
}
265+
});
192266

267+
// Handle exit (same as local)
268+
ptyProcess.onExit(({ exitCode }) => {
269+
log.info(`SSH terminal session ${sessionId} exited with code ${exitCode}`);
270+
this.sessions.delete(sessionId);
271+
onExit(exitCode);
272+
});
273+
274+
// Store PTY (same interface as local)
193275
this.sessions.set(sessionId, {
194-
stream,
195-
stdinWriter,
276+
pty: ptyProcess,
196277
workspaceId: params.workspaceId,
197278
workspacePath,
198279
runtime,
199280
onData,
200281
onExit,
201282
});
202-
203-
// Pipe stdout via callback
204-
const reader = stream.stdout.getReader();
205-
const decoder = new TextDecoder();
206-
207-
(async () => {
208-
try {
209-
let bytesRead = 0;
210-
while (true) {
211-
const { done, value } = await reader.read();
212-
if (done) {
213-
log.info(`[PTY] SSH stdout closed for ${sessionId} after ${bytesRead} bytes`);
214-
break;
215-
}
216-
bytesRead += value.length;
217-
const text = decoder.decode(value, { stream: true });
218-
onData(text);
219-
}
220-
} catch (err) {
221-
log.error(`[PTY] Error reading from SSH terminal ${sessionId}:`, err);
222-
}
223-
})();
224-
225-
// Pipe stderr to terminal AND logs (zsh sends prompt to stderr)
226-
const stderrReader = stream.stderr.getReader();
227-
(async () => {
228-
try {
229-
while (true) {
230-
const { done, value } = await stderrReader.read();
231-
if (done) break;
232-
const text = decoder.decode(value, { stream: true });
233-
// Send stderr to terminal (shells often write prompts to stderr)
234-
onData(text);
235-
}
236-
} catch (err) {
237-
log.error(`[PTY] Error reading stderr for ${sessionId}:`, err);
238-
}
239-
})();
240-
241-
// Handle exit
242-
stream.exitCode
243-
.then((exitCode: number) => {
244-
log.info(`[PTY] SSH terminal session ${sessionId} exited with code ${exitCode}`);
245-
log.info(
246-
`[PTY] Session was alive for ${((Date.now() - parseInt(sessionId.split("-")[1])) / 1000).toFixed(1)}s`
247-
);
248-
this.sessions.delete(sessionId);
249-
onExit(exitCode);
250-
})
251-
.catch((err: unknown) => {
252-
log.error(`[PTY] SSH terminal session ${sessionId} error:`, err);
253-
this.sessions.delete(sessionId);
254-
onExit(1);
255-
});
256283
} else {
257284
throw new Error(`Unsupported runtime type: ${runtime.constructor.name}`);
258285
}
@@ -268,53 +295,38 @@ export class PTYService {
268295
/**
269296
* Send input to a terminal session
270297
*/
271-
async sendInput(sessionId: string, data: string): Promise<void> {
298+
sendInput(sessionId: string, data: string): void {
272299
const session = this.sessions.get(sessionId);
273-
if (!session) {
274-
throw new Error(`Terminal session ${sessionId} not found`);
300+
if (!session?.pty) {
301+
log.info(`Cannot send input to session ${sessionId}: not found or no PTY`);
302+
return;
275303
}
276304

277-
if (session.pty) {
278-
// Local: Write to PTY
279-
session.pty.write(data);
280-
} else if (session.stdinWriter) {
281-
// SSH: Write to stdin using persistent writer
282-
try {
283-
await session.stdinWriter.write(new TextEncoder().encode(data));
284-
} catch (err) {
285-
log.error(`[PTY] Error writing to ${sessionId}:`, err);
286-
throw err;
287-
}
288-
}
305+
// Works for both local and SSH now
306+
session.pty.write(data);
289307
}
290308

291309
/**
292310
* Resize a terminal session
293311
*/
294312
resize(params: TerminalResizeParams): void {
295313
const session = this.sessions.get(params.sessionId);
296-
if (!session) {
297-
log.info(`Cannot resize terminal session ${params.sessionId}: not found`);
314+
if (!session?.pty) {
315+
log.info(`Cannot resize terminal session ${params.sessionId}: not found or no PTY`);
298316
return;
299317
}
300318

301-
if (session.pty) {
302-
// Local: Resize PTY
303-
session.pty.resize(params.cols, params.rows);
304-
log.debug(`Resized local terminal ${params.sessionId} to ${params.cols}x${params.rows}`);
305-
} else {
306-
// SSH: Dynamic resize not supported for SSH sessions
307-
// The terminal size is set at session creation time via LINES/COLUMNS env vars
308-
log.debug(
309-
`SSH terminal ${params.sessionId} resize requested to ${params.cols}x${params.rows} (not supported)`
310-
);
311-
}
319+
// Now works for both local AND SSH! 🎉
320+
session.pty.resize(params.cols, params.rows);
321+
log.debug(
322+
`Resized terminal ${params.sessionId} (${session.runtime instanceof SSHRuntime ? "SSH" : "local"}) to ${params.cols}x${params.rows}`
323+
);
312324
}
313325

314326
/**
315327
* Close a terminal session
316328
*/
317-
async closeSession(sessionId: string): Promise<void> {
329+
closeSession(sessionId: string): void {
318330
const session = this.sessions.get(sessionId);
319331
if (!session) {
320332
log.info(`Cannot close terminal session ${sessionId}: not found`);
@@ -324,15 +336,8 @@ export class PTYService {
324336
log.info(`Closing terminal session ${sessionId}`);
325337

326338
if (session.pty) {
327-
// Local: Kill PTY process
339+
// Works for both local and SSH
328340
session.pty.kill();
329-
} else if (session.stdinWriter) {
330-
// SSH: Close stdin writer to signal EOF
331-
try {
332-
await session.stdinWriter.close();
333-
} catch (err) {
334-
log.error(`Error closing SSH terminal ${sessionId}:`, err);
335-
}
336341
}
337342

338343
this.sessions.delete(sessionId);
@@ -341,14 +346,14 @@ export class PTYService {
341346
/**
342347
* Close all terminal sessions for a workspace
343348
*/
344-
async closeWorkspaceSessions(workspaceId: string): Promise<void> {
349+
closeWorkspaceSessions(workspaceId: string): void {
345350
const sessionIds = Array.from(this.sessions.entries())
346351
.filter(([, session]) => session.workspaceId === workspaceId)
347352
.map(([id]) => id);
348353

349354
log.info(`Closing ${sessionIds.length} terminal session(s) for workspace ${workspaceId}`);
350355

351-
await Promise.all(sessionIds.map((id) => this.closeSession(id)));
356+
sessionIds.forEach((id) => this.closeSession(id));
352357
}
353358

354359
/**

0 commit comments

Comments
 (0)