mirror of
https://github.com/samsonjs/vibetunnel.git
synced 2026-04-27 15:17:38 +00:00
Add terminal size reset feature and fix source maps (#72)
- Add POST /api/sessions/:sessionId/reset-size endpoint to reset terminal size when clients disconnect - Implement reset-size control pipe command in PTY manager - Update session-view component to call reset-size on unmount - Add terminal resize event listener in fwd.ts to track terminal size changes - Fix source maps configuration for development mode: - Set inline source maps with embedded sources in esbuild config - Add source map settings to TypeScript configs - Set NODE_ENV to development for dev builds This ensures external terminals resize back to their actual size when the last web client disconnects. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
d37f813b83
commit
f339e69f9a
9 changed files with 206 additions and 11 deletions
|
|
@ -28,14 +28,22 @@ const commonOptions = {
|
||||||
compilerOptions: {
|
compilerOptions: {
|
||||||
experimentalDecorators: true,
|
experimentalDecorators: true,
|
||||||
useDefineForClassFields: false,
|
useDefineForClassFields: false,
|
||||||
|
sourceMap: true,
|
||||||
|
inlineSourceMap: true,
|
||||||
|
inlineSources: true,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const devOptions = {
|
const devOptions = {
|
||||||
...commonOptions,
|
...commonOptions,
|
||||||
sourcemap: true,
|
sourcemap: 'inline',
|
||||||
|
sourcesContent: true,
|
||||||
minify: false,
|
minify: false,
|
||||||
|
define: {
|
||||||
|
...commonOptions.define,
|
||||||
|
'process.env.NODE_ENV': '"development"',
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const prodOptions = {
|
const prodOptions = {
|
||||||
|
|
|
||||||
|
|
@ -222,6 +222,17 @@ export class SessionView extends LitElement {
|
||||||
super.disconnectedCallback();
|
super.disconnectedCallback();
|
||||||
this.connected = false;
|
this.connected = false;
|
||||||
|
|
||||||
|
logger.log('SessionView disconnectedCallback called', {
|
||||||
|
sessionId: this.session?.id,
|
||||||
|
sessionStatus: this.session?.status,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Reset terminal size for external terminals when leaving session view
|
||||||
|
if (this.session && this.session.status !== 'exited') {
|
||||||
|
logger.log('Calling resetTerminalSize for session', this.session.id);
|
||||||
|
this.resetTerminalSize();
|
||||||
|
}
|
||||||
|
|
||||||
// Remove click outside handler
|
// Remove click outside handler
|
||||||
document.removeEventListener('click', this.handleClickOutside);
|
document.removeEventListener('click', this.handleClickOutside);
|
||||||
|
|
||||||
|
|
@ -1012,6 +1023,39 @@ export class SessionView extends LitElement {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async resetTerminalSize() {
|
||||||
|
if (!this.session) {
|
||||||
|
logger.warn('resetTerminalSize called but no session available');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.log('Sending reset-size request for session', this.session.id);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/sessions/${this.session.id}/reset-size`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
...this.authClient.getAuthHeader(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
logger.error('failed to reset terminal size', {
|
||||||
|
status: response.status,
|
||||||
|
sessionId: this.session.id,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
logger.log('terminal size reset successfully for session', this.session.id);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('error resetting terminal size', {
|
||||||
|
error,
|
||||||
|
sessionId: this.session.id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private refreshTerminalAfterMobileInput() {
|
private refreshTerminalAfterMobileInput() {
|
||||||
// After closing mobile input, the viewport changes and the terminal
|
// After closing mobile input, the viewport changes and the terminal
|
||||||
// needs to recalculate its scroll position to avoid getting stuck
|
// needs to recalculate its scroll position to avoid getting stuck
|
||||||
|
|
|
||||||
|
|
@ -80,6 +80,11 @@ export async function startVibeTunnelForward(args: string[]) {
|
||||||
logger.debug(`Control path: ${controlPath}`);
|
logger.debug(`Control path: ${controlPath}`);
|
||||||
const ptyManager = new PtyManager(controlPath);
|
const ptyManager = new PtyManager(controlPath);
|
||||||
|
|
||||||
|
// Store original terminal dimensions
|
||||||
|
const originalCols = process.stdout.columns || 80;
|
||||||
|
const originalRows = process.stdout.rows || 24;
|
||||||
|
logger.debug(`Original terminal size: ${originalCols}x${originalRows}`);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Create a human-readable session name
|
// Create a human-readable session name
|
||||||
const sessionName = generateSessionName(command, cwd);
|
const sessionName = generateSessionName(command, cwd);
|
||||||
|
|
@ -94,8 +99,8 @@ export async function startVibeTunnelForward(args: string[]) {
|
||||||
sessionId: finalSessionId,
|
sessionId: finalSessionId,
|
||||||
name: sessionName,
|
name: sessionName,
|
||||||
workingDir: cwd,
|
workingDir: cwd,
|
||||||
cols: process.stdout.columns || 80,
|
cols: originalCols,
|
||||||
rows: process.stdout.rows || 24,
|
rows: originalRows,
|
||||||
forwardToStdout: true,
|
forwardToStdout: true,
|
||||||
onExit: async (exitCode: number) => {
|
onExit: async (exitCode: number) => {
|
||||||
// Show exit message
|
// Show exit message
|
||||||
|
|
@ -103,6 +108,9 @@ export async function startVibeTunnelForward(args: string[]) {
|
||||||
chalk.yellow(`\n✓ VibeTunnel session ended`) + chalk.gray(` (exit code: ${exitCode})`)
|
chalk.yellow(`\n✓ VibeTunnel session ended`) + chalk.gray(` (exit code: ${exitCode})`)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Remove resize listener
|
||||||
|
process.stdout.removeListener('resize', resizeHandler);
|
||||||
|
|
||||||
// Restore terminal settings and clean up stdin
|
// Restore terminal settings and clean up stdin
|
||||||
if (process.stdin.isTTY) {
|
if (process.stdin.isTTY) {
|
||||||
logger.debug('Restoring terminal to normal mode');
|
logger.debug('Restoring terminal to normal mode');
|
||||||
|
|
@ -137,6 +145,23 @@ export async function startVibeTunnelForward(args: string[]) {
|
||||||
logger.log(chalk.gray('Control directory:'), path.join(controlPath, result.sessionId));
|
logger.log(chalk.gray('Control directory:'), path.join(controlPath, result.sessionId));
|
||||||
logger.log(chalk.gray('Build:'), `${BUILD_DATE} | Commit: ${GIT_COMMIT}`);
|
logger.log(chalk.gray('Build:'), `${BUILD_DATE} | Commit: ${GIT_COMMIT}`);
|
||||||
|
|
||||||
|
// Set up terminal resize handler
|
||||||
|
const resizeHandler = () => {
|
||||||
|
const cols = process.stdout.columns || 80;
|
||||||
|
const rows = process.stdout.rows || 24;
|
||||||
|
logger.debug(`Terminal resized to ${cols}x${rows}`);
|
||||||
|
|
||||||
|
// Send resize command through PTY manager
|
||||||
|
try {
|
||||||
|
ptyManager.resizeSession(result.sessionId, cols, rows);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to resize session:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Listen for terminal resize events
|
||||||
|
process.stdout.on('resize', resizeHandler);
|
||||||
|
|
||||||
// Set up raw mode for terminal input
|
// Set up raw mode for terminal input
|
||||||
if (process.stdin.isTTY) {
|
if (process.stdin.isTTY) {
|
||||||
logger.debug('Setting terminal to raw mode for input forwarding');
|
logger.debug('Setting terminal to raw mode for input forwarding');
|
||||||
|
|
|
||||||
|
|
@ -8,9 +8,6 @@
|
||||||
import * as fs from 'fs';
|
import * as fs from 'fs';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
import { AsciinemaHeader, AsciinemaEvent, PtyError } from './types.js';
|
import { AsciinemaHeader, AsciinemaEvent, PtyError } from './types.js';
|
||||||
import { createLogger } from '../utils/logger.js';
|
|
||||||
|
|
||||||
const logger = createLogger('AsciinemaWriter');
|
|
||||||
|
|
||||||
export class AsciinemaWriter {
|
export class AsciinemaWriter {
|
||||||
private writeStream: fs.WriteStream;
|
private writeStream: fs.WriteStream;
|
||||||
|
|
@ -165,11 +162,12 @@ export class AsciinemaWriter {
|
||||||
// Force immediate disk write to trigger file watchers
|
// Force immediate disk write to trigger file watchers
|
||||||
if (this.fd !== null) {
|
if (this.fd !== null) {
|
||||||
try {
|
try {
|
||||||
fs.fsync(this.fd, (err) => {
|
/*fs.fsync(this.fd, (err) => {
|
||||||
if (err) {
|
if (err) {
|
||||||
logger.error(`Failed to fsync asciinema file: ${err.message}`);
|
logger.error(`Failed to fsync asciinema file: ${err.message}`);
|
||||||
}
|
}
|
||||||
});
|
});*/
|
||||||
|
fs.fsyncSync(this.fd);
|
||||||
} catch (_e) {
|
} catch (_e) {
|
||||||
// Ignore sync errors
|
// Ignore sync errors
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ import {
|
||||||
PtyError,
|
PtyError,
|
||||||
ResizeControlMessage,
|
ResizeControlMessage,
|
||||||
KillControlMessage,
|
KillControlMessage,
|
||||||
|
ResetSizeControlMessage,
|
||||||
} from './types.js';
|
} from './types.js';
|
||||||
import { AsciinemaWriter } from './asciinema-writer.js';
|
import { AsciinemaWriter } from './asciinema-writer.js';
|
||||||
import { SessionManager } from './session-manager.js';
|
import { SessionManager } from './session-manager.js';
|
||||||
|
|
@ -575,6 +576,19 @@ export class PtyManager extends EventEmitter {
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.warn(`Failed to kill session ${session.id} with signal ${signal}:`, error);
|
logger.warn(`Failed to kill session ${session.id} with signal ${signal}:`, error);
|
||||||
}
|
}
|
||||||
|
} else if (message.cmd === 'reset-size') {
|
||||||
|
try {
|
||||||
|
if (session.ptyProcess) {
|
||||||
|
// Get current terminal size from process.stdout
|
||||||
|
const cols = process.stdout.columns || 80;
|
||||||
|
const rows = process.stdout.rows || 24;
|
||||||
|
session.ptyProcess.resize(cols, rows);
|
||||||
|
session.asciinemaWriter?.writeResize(cols, rows);
|
||||||
|
logger.debug(`Reset session ${session.id} size to terminal size: ${cols}x${rows}`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn(`Failed to reset session ${session.id} size to terminal size:`, error);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -661,7 +675,7 @@ export class PtyManager extends EventEmitter {
|
||||||
*/
|
*/
|
||||||
private sendControlMessage(
|
private sendControlMessage(
|
||||||
sessionId: string,
|
sessionId: string,
|
||||||
message: ResizeControlMessage | KillControlMessage
|
message: ResizeControlMessage | KillControlMessage | ResetSizeControlMessage
|
||||||
): boolean {
|
): boolean {
|
||||||
const sessionPaths = this.sessionManager.getSessionPaths(sessionId);
|
const sessionPaths = this.sessionManager.getSessionPaths(sessionId);
|
||||||
if (!sessionPaths) {
|
if (!sessionPaths) {
|
||||||
|
|
@ -749,6 +763,46 @@ export class PtyManager extends EventEmitter {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reset session size to terminal size (for external terminals)
|
||||||
|
*/
|
||||||
|
resetSessionSize(sessionId: string): void {
|
||||||
|
const memorySession = this.sessions.get(sessionId);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// For in-memory sessions, we can't reset to terminal size since we don't know it
|
||||||
|
if (memorySession?.ptyProcess) {
|
||||||
|
throw new PtyError(
|
||||||
|
`Cannot reset size for in-memory session ${sessionId}`,
|
||||||
|
'INVALID_OPERATION',
|
||||||
|
sessionId
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// For external sessions, send reset-size command via control pipe
|
||||||
|
const resetSizeMessage: ResetSizeControlMessage = {
|
||||||
|
cmd: 'reset-size',
|
||||||
|
};
|
||||||
|
|
||||||
|
const sent = this.sendControlMessage(sessionId, resetSizeMessage);
|
||||||
|
if (!sent) {
|
||||||
|
throw new PtyError(
|
||||||
|
`Failed to send reset-size command to session ${sessionId}`,
|
||||||
|
'CONTROL_MESSAGE_FAILED',
|
||||||
|
sessionId
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.debug(`Sent reset-size command to session ${sessionId}`);
|
||||||
|
} catch (error) {
|
||||||
|
throw new PtyError(
|
||||||
|
`Failed to reset session size for ${sessionId}: ${error instanceof Error ? error.message : String(error)}`,
|
||||||
|
'RESET_SIZE_FAILED',
|
||||||
|
sessionId
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Kill a session with proper SIGTERM -> SIGKILL escalation
|
* Kill a session with proper SIGTERM -> SIGKILL escalation
|
||||||
* Returns a promise that resolves when the process is actually terminated
|
* Returns a promise that resolves when the process is actually terminated
|
||||||
|
|
|
||||||
|
|
@ -44,6 +44,10 @@ export interface KillControlMessage extends ControlMessage {
|
||||||
signal?: string | number;
|
signal?: string | number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ResetSizeControlMessage extends ControlMessage {
|
||||||
|
cmd: 'reset-size';
|
||||||
|
}
|
||||||
|
|
||||||
export type AsciinemaEvent = {
|
export type AsciinemaEvent = {
|
||||||
time: number;
|
time: number;
|
||||||
type: 'o' | 'i' | 'r' | 'm';
|
type: 'o' | 'i' | 'r' | 'm';
|
||||||
|
|
|
||||||
|
|
@ -1015,6 +1015,64 @@ export function createSessionRoutes(config: SessionRoutesConfig): Router {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Reset terminal size (for external terminals)
|
||||||
|
router.post('/sessions/:sessionId/reset-size', async (req, res) => {
|
||||||
|
const { sessionId } = req.params;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// In HQ mode, forward to remote if session belongs to one
|
||||||
|
if (remoteRegistry) {
|
||||||
|
const remote = remoteRegistry.getRemoteBySessionId(sessionId);
|
||||||
|
if (remote) {
|
||||||
|
logger.debug(`forwarding reset-size to remote ${remote.id}`);
|
||||||
|
const response = await fetch(`${remote.url}/api/sessions/${sessionId}/reset-size`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Authorization: `Bearer ${remote.token}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.json();
|
||||||
|
return res.status(response.status).json(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
return res.json(result);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.log(chalk.cyan(`resetting terminal size for session ${sessionId}`));
|
||||||
|
|
||||||
|
// Check if session exists
|
||||||
|
const session = ptyManager.getSession(sessionId);
|
||||||
|
if (!session) {
|
||||||
|
logger.error(`session ${sessionId} not found for reset-size`);
|
||||||
|
return res.status(404).json({ error: 'Session not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if session is running
|
||||||
|
if (session.status !== 'running') {
|
||||||
|
logger.error(`session ${sessionId} is not running (status: ${session.status})`);
|
||||||
|
return res.status(400).json({ error: 'Session is not running' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset the session size
|
||||||
|
ptyManager.resetSessionSize(sessionId);
|
||||||
|
logger.log(chalk.green(`session ${sessionId} size reset to terminal size`));
|
||||||
|
|
||||||
|
res.json({ success: true });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('error resetting session size via PTY service:', error);
|
||||||
|
if (error instanceof PtyError) {
|
||||||
|
res.status(500).json({ error: 'Failed to reset session size', details: error.message });
|
||||||
|
} else {
|
||||||
|
res.status(500).json({ error: 'Failed to reset session size' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
return router;
|
return router;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,8 @@
|
||||||
"experimentalDecorators": true,
|
"experimentalDecorators": true,
|
||||||
"emitDecoratorMetadata": true,
|
"emitDecoratorMetadata": true,
|
||||||
"useDefineForClassFields": false,
|
"useDefineForClassFields": false,
|
||||||
"typeRoots": ["./node_modules/@types"]
|
"typeRoots": ["./node_modules/@types"],
|
||||||
|
"inlineSourceMap": true,
|
||||||
|
"inlineSources": true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -7,7 +7,9 @@
|
||||||
"rootDir": "./src",
|
"rootDir": "./src",
|
||||||
"declaration": true,
|
"declaration": true,
|
||||||
"types": ["node"],
|
"types": ["node"],
|
||||||
"composite": true
|
"composite": true,
|
||||||
|
"inlineSourceMap": true,
|
||||||
|
"inlineSources": true
|
||||||
},
|
},
|
||||||
"include": [
|
"include": [
|
||||||
"src/server/**/*",
|
"src/server/**/*",
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue