diff --git a/tty-fwd/src/main.rs b/tty-fwd/src/main.rs index cd6636b8..14b0cac4 100644 --- a/tty-fwd/src/main.rs +++ b/tty-fwd/src/main.rs @@ -96,6 +96,8 @@ fn send_key_to_session( "arrow_left" => b"\x1b[D", "escape" => b"\x1b", "enter" => b"\r", + "ctrl_enter" => b"\x0d", // Just CR like normal enter for now - let's test this first + "shift_enter" => b"\x1b\x0d", // ESC + Enter - simpler approach _ => return Err(anyhow!("Unknown key: {}", key)), }; @@ -302,7 +304,7 @@ fn main() -> Result<(), anyhow::Error> { println!(" --list-sessions List all sessions"); println!(" --session Operate on this session"); println!(" --send-key Send key input to session"); - println!(" Keys: arrow_up, arrow_down, arrow_left, arrow_right, escape, enter"); + println!(" Keys: arrow_up, arrow_down, arrow_left, arrow_right, escape, enter, ctrl_enter, shift_enter"); println!(" --send-text Send text input to session"); println!(" --signal Send signal number to session PID"); println!(" --stop Send SIGTERM to session (equivalent to --signal 15)"); diff --git a/web/public/index.html b/web/public/index.html index a749074c..6ba16836 100644 --- a/web/public/index.html +++ b/web/public/index.html @@ -10,7 +10,7 @@ - + @@ -25,7 +25,6 @@ - diff --git a/web/public/tests/test-renderer.html b/web/public/tests/test-renderer.html index 49a1b6c2..8bd78d25 100644 --- a/web/public/tests/test-renderer.html +++ b/web/public/tests/test-renderer.html @@ -192,7 +192,7 @@

Usage Instructions:

    -
  • Cast File Test: Load asciinema cast files with full compatibility
  • +
  • Cast File Test: Load terminal cast files with full compatibility
  • Stream Test: Connect to live terminal sessions with real-time rendering
  • Manual Test: Test specific ANSI sequences directly
  • Comparison: Compare custom renderer vs XTerm.js side by side
  • diff --git a/web/src/client/components/session-list.ts b/web/src/client/components/session-list.ts index fb7f0892..154f3ae8 100644 --- a/web/src/client/components/session-list.ts +++ b/web/src/client/components/session-list.ts @@ -1,6 +1,7 @@ import { LitElement, html } from 'lit'; import { customElement, property, state } from 'lit/decorators.js'; import './session-create-form.js'; +import { Renderer } from '../renderer.js'; export interface Session { id: string; @@ -30,6 +31,16 @@ export class SessionList extends LitElement { @state() private loadingSnapshots = new Set(); @state() private cleaningExited = false; @state() private newSessionIds = new Set(); + @state() private renderers = new Map(); + + disconnectedCallback() { + super.disconnectedCallback(); + // Clean up all renderers + this.renderers.forEach(renderer => { + renderer.dispose(); + }); + this.renderers.clear(); + } private handleRefresh() { this.dispatchEvent(new CustomEvent('refresh')); @@ -48,8 +59,8 @@ export class SessionList extends LitElement { this.loadedSnapshots.set(sessionId, sessionId); this.requestUpdate(); - // Create asciinema player after the element is rendered - setTimeout(() => this.createPlayer(sessionId), 10); + // Create renderer after the element is rendered + requestAnimationFrame(() => this.createRenderer(sessionId)); } catch (error) { console.error('Error loading snapshot:', error); } finally { @@ -85,71 +96,71 @@ export class SessionList extends LitElement { // Load new sessions after a delay to let them generate some output if (newSessionIdsList.length > 0) { - setTimeout(() => { + // Use a shorter delay for better responsiveness + requestAnimationFrame(() => { newSessionIdsList.forEach(sessionId => { this.newSessionIds.delete(sessionId); // Remove from new sessions set this.loadSnapshot(sessionId); }); this.requestUpdate(); // Update UI to show the players - }, 500); // Wait 500ms for new sessions + }); } } // If hideExited changed, recreate players for newly visible sessions if (changedProperties.has('hideExited')) { - // Use a slight delay to avoid blocking the checkbox click - setTimeout(() => { - requestAnimationFrame(() => { - this.filteredSessions.forEach(session => { - const playerElement = this.querySelector(`#player-${session.id}`); - if (playerElement && this.loadedSnapshots.has(session.id)) { - // Player element exists but might not have a player instance - // Check if it's empty and recreate if needed - if (!playerElement.hasChildNodes() || playerElement.children.length === 0) { - this.createPlayer(session.id); - } + // Use requestAnimationFrame to avoid blocking the checkbox click + requestAnimationFrame(() => { + this.filteredSessions.forEach(session => { + const playerElement = this.querySelector(`#player-${session.id}`); + if (playerElement && this.loadedSnapshots.has(session.id)) { + // Player element exists but might not have a renderer instance + // Check if it's empty and recreate if needed + if (!playerElement.hasChildNodes() || playerElement.children.length === 0) { + this.createRenderer(session.id); } - }); + } }); - }, 10); + }); } } - private createPlayer(sessionId: string) { + private async createRenderer(sessionId: string) { const playerElement = this.querySelector(`#player-${sessionId}`) as HTMLElement; if (!playerElement) { // Element not ready yet, retry on next frame - requestAnimationFrame(() => this.createPlayer(sessionId)); + requestAnimationFrame(() => this.createRenderer(sessionId)); return; } - if ((window as any).AsciinemaPlayer) { - try { - // Find the session to check its status - const session = this.sessions.find(s => s.id === sessionId); - - // For ended sessions, use snapshot instead of stream to avoid reloading - const url = session?.status === 'exited' - ? `/api/sessions/${sessionId}/snapshot` - : `/api/sessions/${sessionId}/stream`; - - const config = session?.status === 'exited' - ? { url } // Static snapshot - : { driver: "eventsource", url }; // Live stream - - (window as any).AsciinemaPlayer.create(config, playerElement, { - autoPlay: true, - loop: false, - controls: false, - fit: 'width', - terminalFontSize: '8px', - idleTimeLimit: 0.5, - preload: true, - poster: 'npt:999999' - }); - } catch (error) { - console.error('Error creating asciinema player:', error); + try { + // Clean up existing renderer if it exists + const existingRenderer = this.renderers.get(sessionId); + if (existingRenderer) { + existingRenderer.dispose(); + this.renderers.delete(sessionId); } + + // Find the session to check its status + const session = this.sessions.find(s => s.id === sessionId); + if (!session) return; + + // Create renderer with smaller dimensions and font for preview + const renderer = new Renderer(playerElement, 40, 12, 10000, 8); // 40x12 chars, 8px font + this.renderers.set(sessionId, renderer); + + // Terminal is already configured with disableStdin: true in renderer constructor + + // Determine URL and stream type + const isStream = session.status !== 'exited'; + const url = isStream + ? `/api/sessions/${sessionId}/stream` + : `/api/sessions/${sessionId}/snapshot`; + + // Let the renderer handle the URL + await renderer.loadFromUrl(url, isStream); + } catch (error) { + console.error('Error creating renderer:', error); } } @@ -175,6 +186,13 @@ export class SessionList extends LitElement { }); if (response.ok) { + // Clean up renderer for this session + const renderer = this.renderers.get(sessionId); + if (renderer) { + renderer.dispose(); + this.renderers.delete(sessionId); + } + this.dispatchEvent(new CustomEvent('session-killed', { detail: { sessionId } })); @@ -215,6 +233,13 @@ export class SessionList extends LitElement { }); if (response.ok) { + // Clean up renderer for this session + const renderer = this.renderers.get(sessionId); + if (renderer) { + renderer.dispose(); + this.renderers.delete(sessionId); + } + this.dispatchEvent(new CustomEvent('session-killed', { detail: { sessionId } })); @@ -395,7 +420,7 @@ export class SessionList extends LitElement { ` : ''} - +
    ${this.loadedSnapshots.has(session.id) ? html`
    diff --git a/web/src/client/components/session-view.ts b/web/src/client/components/session-view.ts index 60d1166b..fb362c45 100644 --- a/web/src/client/components/session-view.ts +++ b/web/src/client/components/session-view.ts @@ -1,6 +1,7 @@ import { LitElement, html } from 'lit'; import { customElement, property, state } from 'lit/decorators.js'; import type { Session } from './session-list.js'; +import { Renderer } from '../renderer.js'; @customElement('session-view') export class SessionView extends LitElement { @@ -11,7 +12,7 @@ export class SessionView extends LitElement { @property({ type: Object }) session: Session | null = null; @state() private connected = false; - @state() private player: any = null; + @state() private renderer: Renderer | null = null; @state() private sessionStatusInterval: number | null = null; @state() private showMobileInput = false; @state() private mobileInputText = ''; @@ -94,9 +95,10 @@ export class SessionView extends LitElement { // Stop polling session status this.stopSessionStatusPolling(); - // Cleanup player if exists - if (this.player) { - this.player = null; + // Cleanup renderer if it exists + if (this.renderer) { + this.renderer.dispose(); + this.renderer = null; } } @@ -104,10 +106,7 @@ export class SessionView extends LitElement { super.updated(changedProperties); if (changedProperties.has('session') && this.session) { - // Use setTimeout to ensure DOM is rendered first - setTimeout(() => { - this.createInteractiveTerminal(); - }, 10); + this.createInteractiveTerminal(); } } @@ -115,48 +114,28 @@ export class SessionView extends LitElement { if (!this.session) return; const terminalElement = this.querySelector('#interactive-terminal') as HTMLElement; - if (terminalElement && (window as any).AsciinemaPlayer) { - try { - // For ended sessions, use snapshot instead of stream to avoid reloading - const url = this.session.status === 'exited' - ? `/api/sessions/${this.session.id}/snapshot` - : `/api/sessions/${this.session.id}/stream`; - - const config = this.session.status === 'exited' - ? { url } // Static snapshot - : { driver: "eventsource", url }; // Live stream - - this.player = (window as any).AsciinemaPlayer.create(config, terminalElement, { - autoPlay: true, - loop: false, - controls: false, - fit: 'both', - terminalFontSize: '12px', - idleTimeLimit: 0.5, - preload: true, - poster: 'npt:999999' - }); + if (!terminalElement) return; - // Disable focus outline and fullscreen functionality - if (this.player && this.player.el) { - // Remove focus outline - this.player.el.style.outline = 'none'; - this.player.el.style.border = 'none'; - - // Disable fullscreen hotkey by removing tabindex and preventing focus - this.player.el.removeAttribute('tabindex'); - this.player.el.style.pointerEvents = 'none'; - - // Find the terminal element and make it non-focusable - const terminal = this.player.el.querySelector('.ap-terminal, .ap-screen, pre'); - if (terminal) { - terminal.removeAttribute('tabindex'); - terminal.style.outline = 'none'; - } - } - } catch (error) { - console.error('Error creating interactive terminal:', error); + try { + // Clean up existing renderer + if (this.renderer) { + this.renderer.dispose(); + this.renderer = null; } + + // Create new renderer using default parameters (EXACTLY like the test) + this.renderer = new Renderer(terminalElement); + + if (this.session.status === 'exited') { + // For ended sessions, load snapshot (EXACTLY like the test) + this.renderer.loadCastFile(`/api/sessions/${this.session.id}/snapshot`); + } else { + // For running sessions, connect to live stream (EXACTLY like the test) + this.renderer.clear(); + this.renderer.connectToStream(this.session.id); + } + } catch (error) { + console.error('Error creating interactive terminal:', error); } } @@ -168,7 +147,16 @@ export class SessionView extends LitElement { // Handle special keys switch (e.key) { case 'Enter': - inputText = 'enter'; + if (e.ctrlKey) { + // Ctrl+Enter - send to tty-fwd for proper handling + inputText = 'ctrl_enter'; + } else if (e.shiftKey) { + // Shift+Enter - send to tty-fwd for proper handling + inputText = 'shift_enter'; + } else { + // Regular Enter + inputText = 'enter'; + } break; case 'Escape': inputText = 'escape'; @@ -208,8 +196,8 @@ export class SessionView extends LitElement { break; } - // Handle Ctrl combinations - if (e.ctrlKey && e.key.length === 1) { + // Handle Ctrl combinations (but not if we already handled Ctrl+Enter above) + if (e.ctrlKey && e.key.length === 1 && e.key !== 'Enter') { const charCode = e.key.toLowerCase().charCodeAt(0); if (charCode >= 97 && charCode <= 122) { // a-z inputText = String.fromCharCode(charCode - 96); // Ctrl+A = \x01, etc. @@ -450,19 +438,11 @@ export class SessionView extends LitElement { this.requestUpdate(); // If session ended, switch from stream to snapshot to prevent restarts - if (currentSession.status === 'exited' && this.player && this.session.status === 'running') { + if (currentSession.status === 'exited' && this.session.status === 'running') { console.log('Session ended, switching to snapshot view'); try { - // Dispose the streaming player - if (this.player.dispose) { - this.player.dispose(); - } - this.player = null; - // Recreate with snapshot - setTimeout(() => { - this.createInteractiveTerminal(); - }, 100); + this.createInteractiveTerminal(); } catch (error) { console.error('Error switching to snapshot:', error); } diff --git a/web/src/client/renderer.ts b/web/src/client/renderer.ts index e2b971f3..059ed4de 100644 --- a/web/src/client/renderer.ts +++ b/web/src/client/renderer.ts @@ -15,7 +15,7 @@ interface CastHeader { interface CastEvent { timestamp: number; - type: 'o' | 'i'; // output or input + type: 'o' | 'i' | 'r'; // output, input, or resize data: string; } @@ -25,7 +25,7 @@ export class Renderer { private fitAddon: FitAddon; private webLinksAddon: WebLinksAddon; - constructor(container: HTMLElement, width: number = 80, height: number = 20, scrollback: number = 1000000) { + constructor(container: HTMLElement, width: number = 80, height: number = 20, scrollback: number = 1000000, fontSize: number = 14) { this.container = container; // Create terminal with options similar to the custom renderer @@ -33,7 +33,7 @@ export class Renderer { cols: width, rows: height, fontFamily: 'Monaco, "Lucida Console", monospace', - fontSize: 14, + fontSize: fontSize, lineHeight: 1.2, theme: { background: '#000000', @@ -139,6 +139,8 @@ export class Renderer { if (event.type === 'o') { this.processOutput(event.data); + } else if (event.type === 'r') { + this.processResize(event.data); } } } catch (e) { @@ -152,9 +154,21 @@ export class Renderer { this.terminal.write(data); } + processResize(data: string): void { + // Parse resize data in format "WIDTHxHEIGHT" (e.g., "80x24") + const match = data.match(/^(\d+)x(\d+)$/); + if (match) { + const width = parseInt(match[1], 10); + const height = parseInt(match[2], 10); + this.resize(width, height); + } + } + processEvent(event: CastEvent): void { if (event.type === 'o') { this.processOutput(event.data); + } else if (event.type === 'r') { + this.processResize(event.data); } } @@ -172,7 +186,12 @@ export class Renderer { // Stream support - connect to SSE endpoint connectToStream(sessionId: string): EventSource { - const eventSource = new EventSource(`/api/sessions/${sessionId}/stream`); + return this.connectToUrl(`/api/sessions/${sessionId}/stream`); + } + + // Connect to any SSE URL + connectToUrl(url: string): EventSource { + const eventSource = new EventSource(url); // Clear terminal when starting stream this.terminal.clear(); @@ -205,6 +224,28 @@ export class Renderer { return eventSource; } + private eventSource: EventSource | null = null; + + // Load content from URL - pass isStream to determine how to handle it + async loadFromUrl(url: string, isStream: boolean): Promise { + // Clear terminal first + this.terminal.clear(); + + // Clean up existing connection + if (this.eventSource) { + this.eventSource.close(); + this.eventSource = null; + } + + if (isStream) { + // It's a stream URL, connect via SSE + this.eventSource = this.connectToUrl(url); + } else { + // It's a snapshot URL, load as cast file + await this.loadCastFile(url); + } + } + // Additional methods for terminal control focus(): void { @@ -220,6 +261,10 @@ export class Renderer { } dispose(): void { + if (this.eventSource) { + this.eventSource.close(); + this.eventSource = null; + } this.terminal.dispose(); } diff --git a/web/src/input.css b/web/src/input.css index a8bff2be..7bd0aaa2 100644 --- a/web/src/input.css +++ b/web/src/input.css @@ -2,23 +2,25 @@ @tailwind components; @tailwind utilities; -/* Fix asciinema player dimensions */ -.ap-player { - min-width: unset !important; - min-height: unset !important; - width: auto !important; - height: auto !important; +/* XTerm terminal styling */ +.xterm { + padding: 0 !important; } -.asciinema-player-theme-asciinema { - min-width: unset !important; - min-height: unset !important; - width: auto !important; - height: auto !important; +.xterm .xterm-viewport { + background-color: transparent !important; } -/* Ensure terminal player container has proper size */ -#terminal-player { +/* Hide XTerm input textarea in session views (we handle input separately) */ +session-view .xterm-helper-textarea { + display: none !important; + opacity: 0 !important; + pointer-events: none !important; +} + +/* Ensure terminal container has proper size */ +#terminal-player, +#interactive-terminal { min-height: 480px; min-width: 640px; width: 100%; @@ -44,9 +46,8 @@ z-index: 1000; } -/* Force asciinema player to fit within session card bounds */ -.session-preview .asciinema-player, -.session-preview .ap-player { +/* Force XTerm terminal to fit within session card bounds */ +.session-preview .xterm { min-width: unset !important; min-height: unset !important; max-width: 100% !important; @@ -54,20 +55,22 @@ width: 100% !important; height: 100% !important; overflow: hidden !important; - z-index: 0 !important; - object-fit: contain !important; } -.session-preview .asciinema-player .ap-screen, -.session-preview .ap-player .ap-screen { - transform-origin: center center !important; +.session-preview .xterm .xterm-screen { max-width: 100% !important; max-height: 100% !important; - z-index: 1 !important; - object-fit: contain !important; + transform: scale(0.8); /* Scale down the content to fit better */ + transform-origin: top left; } -.session-preview .asciinema-player *, -.session-preview .ap-player * { - z-index: 1 !important; +.session-preview .xterm .xterm-viewport { + overflow: hidden !important; +} + +/* Hide the helper textarea in session previews too */ +.session-preview .xterm-helper-textarea { + display: none !important; + opacity: 0 !important; + pointer-events: none !important; } \ No newline at end of file diff --git a/web/src/server.ts b/web/src/server.ts index 199f5db8..5d4f73e4 100644 --- a/web/src/server.ts +++ b/web/src/server.ts @@ -319,7 +319,7 @@ const activeStreams = new Map(); -// Live streaming cast file for asciinema player +// Live streaming cast file for XTerm renderer app.get('/api/sessions/:sessionId/stream', async (req, res) => { const sessionId = req.params.sessionId; const streamOutPath = path.join(TTY_FWD_CONTROL_DIR, sessionId, 'stream-out'); @@ -489,7 +489,7 @@ app.get('/api/sessions/:sessionId/stream', async (req, res) => { req.on('aborted', cleanup); }); -// Get session snapshot (asciinema cast with adjusted timestamps for immediate playback) +// Get session snapshot (cast with adjusted timestamps for immediate playback) app.get('/api/sessions/:sessionId/snapshot', (req, res) => { const sessionId = req.params.sessionId; const streamOutPath = path.join(TTY_FWD_CONTROL_DIR, sessionId, 'stream-out'); @@ -528,7 +528,7 @@ app.get('/api/sessions/:sessionId/snapshot', (req, res) => { } } - // Build the complete asciinema cast + // Build the complete cast const cast = []; // Add header if found, otherwise use default @@ -606,7 +606,7 @@ app.post('/api/sessions/:sessionId/input', async (req, res) => { } // Check if this is a special key that should use --send-key - const specialKeys = ['arrow_up', 'arrow_down', 'arrow_left', 'arrow_right', 'escape', 'enter']; + const specialKeys = ['arrow_up', 'arrow_down', 'arrow_left', 'arrow_right', 'escape', 'enter', 'ctrl_enter', 'shift_enter']; const isSpecialKey = specialKeys.includes(text); const startTime = Date.now();