diff --git a/web/scripts/esbuild-config.js b/web/scripts/esbuild-config.js index 041ba164..d57252a1 100644 --- a/web/scripts/esbuild-config.js +++ b/web/scripts/esbuild-config.js @@ -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 = { diff --git a/web/src/client/components/session-view.ts b/web/src/client/components/session-view.ts index fad9df9f..3848a606 100644 --- a/web/src/client/components/session-view.ts +++ b/web/src/client/components/session-view.ts @@ -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 diff --git a/web/src/server/fwd.ts b/web/src/server/fwd.ts index 190ffbb0..6a67bc8b 100755 --- a/web/src/server/fwd.ts +++ b/web/src/server/fwd.ts @@ -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'); diff --git a/web/src/server/pty/asciinema-writer.ts b/web/src/server/pty/asciinema-writer.ts index 904e85ea..c6e32431 100644 --- a/web/src/server/pty/asciinema-writer.ts +++ b/web/src/server/pty/asciinema-writer.ts @@ -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 } diff --git a/web/src/server/pty/pty-manager.ts b/web/src/server/pty/pty-manager.ts index 8e3aef9a..829efb91 100644 --- a/web/src/server/pty/pty-manager.ts +++ b/web/src/server/pty/pty-manager.ts @@ -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 diff --git a/web/src/server/pty/types.ts b/web/src/server/pty/types.ts index d4bd2dfd..9433f9c2 100644 --- a/web/src/server/pty/types.ts +++ b/web/src/server/pty/types.ts @@ -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'; diff --git a/web/src/server/routes/sessions.ts b/web/src/server/routes/sessions.ts index aa0b7ce4..47a76954 100644 --- a/web/src/server/routes/sessions.ts +++ b/web/src/server/routes/sessions.ts @@ -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; } diff --git a/web/tsconfig.base.json b/web/tsconfig.base.json index 6df2c4d9..c85a9717 100644 --- a/web/tsconfig.base.json +++ b/web/tsconfig.base.json @@ -12,6 +12,8 @@ "experimentalDecorators": true, "emitDecoratorMetadata": true, "useDefineForClassFields": false, - "typeRoots": ["./node_modules/@types"] + "typeRoots": ["./node_modules/@types"], + "inlineSourceMap": true, + "inlineSources": true } } \ No newline at end of file diff --git a/web/tsconfig.server.json b/web/tsconfig.server.json index 262ece39..7c4cac71 100644 --- a/web/tsconfig.server.json +++ b/web/tsconfig.server.json @@ -7,7 +7,9 @@ "rootDir": "./src", "declaration": true, "types": ["node"], - "composite": true + "composite": true, + "inlineSourceMap": true, + "inlineSources": true }, "include": [ "src/server/**/*",