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:
Mario Zechner 2025-06-24 16:12:44 +02:00
parent d37f813b83
commit f339e69f9a
9 changed files with 206 additions and 11 deletions

View file

@ -28,14 +28,22 @@ const commonOptions = {
compilerOptions: {
experimentalDecorators: true,
useDefineForClassFields: false,
sourceMap: true,
inlineSourceMap: true,
inlineSources: true,
}
}
};
const devOptions = {
...commonOptions,
sourcemap: true,
sourcemap: 'inline',
sourcesContent: true,
minify: false,
define: {
...commonOptions.define,
'process.env.NODE_ENV': '"development"',
},
};
const prodOptions = {

View file

@ -222,6 +222,17 @@ export class SessionView extends LitElement {
super.disconnectedCallback();
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
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() {
// After closing mobile input, the viewport changes and the terminal
// needs to recalculate its scroll position to avoid getting stuck

View file

@ -80,6 +80,11 @@ export async function startVibeTunnelForward(args: string[]) {
logger.debug(`Control path: ${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 {
// Create a human-readable session name
const sessionName = generateSessionName(command, cwd);
@ -94,8 +99,8 @@ export async function startVibeTunnelForward(args: string[]) {
sessionId: finalSessionId,
name: sessionName,
workingDir: cwd,
cols: process.stdout.columns || 80,
rows: process.stdout.rows || 24,
cols: originalCols,
rows: originalRows,
forwardToStdout: true,
onExit: async (exitCode: number) => {
// Show exit message
@ -103,6 +108,9 @@ export async function startVibeTunnelForward(args: string[]) {
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
if (process.stdin.isTTY) {
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('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
if (process.stdin.isTTY) {
logger.debug('Setting terminal to raw mode for input forwarding');

View file

@ -8,9 +8,6 @@
import * as fs from 'fs';
import * as path from 'path';
import { AsciinemaHeader, AsciinemaEvent, PtyError } from './types.js';
import { createLogger } from '../utils/logger.js';
const logger = createLogger('AsciinemaWriter');
export class AsciinemaWriter {
private writeStream: fs.WriteStream;
@ -165,11 +162,12 @@ export class AsciinemaWriter {
// Force immediate disk write to trigger file watchers
if (this.fd !== null) {
try {
fs.fsync(this.fd, (err) => {
/*fs.fsync(this.fd, (err) => {
if (err) {
logger.error(`Failed to fsync asciinema file: ${err.message}`);
}
});
});*/
fs.fsyncSync(this.fd);
} catch (_e) {
// Ignore sync errors
}

View file

@ -18,6 +18,7 @@ import {
PtyError,
ResizeControlMessage,
KillControlMessage,
ResetSizeControlMessage,
} from './types.js';
import { AsciinemaWriter } from './asciinema-writer.js';
import { SessionManager } from './session-manager.js';
@ -575,6 +576,19 @@ export class PtyManager extends EventEmitter {
} catch (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(
sessionId: string,
message: ResizeControlMessage | KillControlMessage
message: ResizeControlMessage | KillControlMessage | ResetSizeControlMessage
): boolean {
const sessionPaths = this.sessionManager.getSessionPaths(sessionId);
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
* Returns a promise that resolves when the process is actually terminated

View file

@ -44,6 +44,10 @@ export interface KillControlMessage extends ControlMessage {
signal?: string | number;
}
export interface ResetSizeControlMessage extends ControlMessage {
cmd: 'reset-size';
}
export type AsciinemaEvent = {
time: number;
type: 'o' | 'i' | 'r' | 'm';

View file

@ -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;
}

View file

@ -12,6 +12,8 @@
"experimentalDecorators": true,
"emitDecoratorMetadata": true,
"useDefineForClassFields": false,
"typeRoots": ["./node_modules/@types"]
"typeRoots": ["./node_modules/@types"],
"inlineSourceMap": true,
"inlineSources": true
}
}

View file

@ -7,7 +7,9 @@
"rootDir": "./src",
"declaration": true,
"types": ["node"],
"composite": true
"composite": true,
"inlineSourceMap": true,
"inlineSources": true
},
"include": [
"src/server/**/*",