vibetunnel/web/public/renderer.js
Mario Zechner ed3927b4a6 Fix session management and terminal scaling
- Fix session killing via DELETE endpoint instead of wrong POST /kill
- Add proper session card kill animation with ASCII spinner
- Fix double key press issue with keyed directive for session-view
- Implement URL-based navigation for consistent component lifecycle
- Fix session card terminal scaling to show all content at smaller sizes
- Modify ScaleFitAddon to only scale font size for previews, not dimensions
- Add session card loading and killing states with visual feedback
- Remove duplicate event listeners and improve component cleanup

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-06-16 15:35:11 +02:00

306 lines
No EOL
11 KiB
JavaScript

// Terminal renderer for asciinema cast format using XTerm.js
// Professional-grade terminal emulation with full VT compatibility
import { Terminal } from '@xterm/xterm';
import { FitAddon } from '@xterm/addon-fit';
import { WebLinksAddon } from '@xterm/addon-web-links';
import { ScaleFitAddon } from './scale-fit-addon.js';
export class Renderer {
constructor(container, width = 80, height = 20, scrollback = 1000000, fontSize = 14, isPreview = false) {
this.eventSource = null;
Renderer.activeCount++;
console.log(`Renderer constructor called (active: ${Renderer.activeCount})`);
this.container = container;
this.isPreview = isPreview;
// Create terminal with options similar to the custom renderer
this.terminal = new Terminal({
cols: width,
rows: height,
fontFamily: 'Monaco, "Lucida Console", monospace',
fontSize: fontSize,
lineHeight: 1.2,
theme: {
background: '#1e1e1e',
foreground: '#d4d4d4',
cursor: '#ffffff',
cursorAccent: '#1e1e1e',
selectionBackground: '#264f78',
// VS Code Dark theme colors
black: '#000000',
red: '#f14c4c',
green: '#23d18b',
yellow: '#f5f543',
blue: '#3b8eea',
magenta: '#d670d6',
cyan: '#29b8db',
white: '#e5e5e5',
// Bright colors
brightBlack: '#666666',
brightRed: '#f14c4c',
brightGreen: '#23d18b',
brightYellow: '#f5f543',
brightBlue: '#3b8eea',
brightMagenta: '#d670d6',
brightCyan: '#29b8db',
brightWhite: '#ffffff'
},
allowProposedApi: true,
scrollback: scrollback, // Configurable scrollback buffer
convertEol: true,
altClickMovesCursor: false,
rightClickSelectsWord: false,
disableStdin: true, // We handle input separately
});
// Add addons
this.fitAddon = new FitAddon();
this.scaleFitAddon = new ScaleFitAddon();
this.webLinksAddon = new WebLinksAddon();
this.terminal.loadAddon(this.fitAddon);
this.terminal.loadAddon(this.scaleFitAddon);
this.terminal.loadAddon(this.webLinksAddon);
this.setupDOM();
}
setupDOM() {
// Clear container and add CSS
this.container.innerHTML = '';
// Different styling for preview vs full terminals
if (this.isPreview) {
// No padding for previews, let container control sizing
this.container.style.padding = '0';
this.container.style.backgroundColor = '#1e1e1e';
this.container.style.overflow = 'hidden';
}
else {
// Full terminals get padding
this.container.style.padding = '10px';
this.container.style.backgroundColor = '#1e1e1e';
this.container.style.overflow = 'hidden';
}
// Create terminal wrapper
const terminalWrapper = document.createElement('div');
terminalWrapper.style.width = '100%';
terminalWrapper.style.height = '100%';
this.container.appendChild(terminalWrapper);
// Open terminal in the wrapper
this.terminal.open(terminalWrapper);
// Always use ScaleFitAddon for better scaling
this.scaleFitAddon.fit();
// Handle container resize
const resizeObserver = new ResizeObserver(() => {
this.scaleFitAddon.fit();
});
resizeObserver.observe(this.container);
}
// Public API methods - maintain compatibility with custom renderer
async loadCastFile(url) {
const response = await fetch(url);
const text = await response.text();
this.parseCastFile(text);
}
parseCastFile(content) {
const lines = content.trim().split('\n');
let header = null;
// Clear terminal
this.terminal.clear();
for (const line of lines) {
if (!line.trim())
continue;
try {
const parsed = JSON.parse(line);
if (parsed.version && parsed.width && parsed.height) {
// Header
header = parsed;
this.resize(parsed.width, parsed.height);
}
else if (Array.isArray(parsed) && parsed.length >= 3) {
// Event: [timestamp, type, data]
const event = {
timestamp: parsed[0],
type: parsed[1],
data: parsed[2]
};
if (event.type === 'o') {
this.processOutput(event.data);
}
else if (event.type === 'r') {
this.processResize(event.data);
}
}
}
catch (e) {
console.warn('Failed to parse cast line:', line);
}
}
}
processOutput(data) {
// XTerm handles all ANSI escape sequences automatically
this.terminal.write(data);
}
processResize(data) {
// 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) {
if (event.type === 'o') {
this.processOutput(event.data);
}
else if (event.type === 'r') {
this.processResize(event.data);
}
}
resize(width, height) {
if (this.isPreview) {
// For previews, resize to session dimensions then apply scaling
this.terminal.resize(width, height);
}
// Always use ScaleFitAddon for consistent scaling behavior
this.scaleFitAddon.fit();
}
clear() {
this.terminal.clear();
}
// Stream support - connect to SSE endpoint
connectToStream(sessionId) {
console.log('connectToStream called for session:', sessionId);
return this.connectToUrl(`/api/sessions/${sessionId}/stream`);
}
// Connect to any SSE URL
connectToUrl(url) {
console.log('Creating new EventSource connection to:', url);
const eventSource = new EventSource(url);
// Don't clear terminal for live streams - just append new content
eventSource.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
if (data.version && data.width && data.height) {
// Header
console.log('Received header:', data);
this.resize(data.width, data.height);
}
else if (Array.isArray(data) && data.length >= 3) {
// Check if this is an exit event
if (data[0] === 'exit') {
const exitCode = data[1];
const sessionId = data[2];
console.log(`Session ${sessionId} exited with code ${exitCode}`);
// Close the SSE connection immediately
if (this.eventSource) {
console.log('Closing SSE connection due to session exit');
this.eventSource.close();
this.eventSource = null;
}
// Dispatch custom event that session-view can listen to
const exitEvent = new CustomEvent('session-exit', {
detail: { sessionId, exitCode }
});
this.container.dispatchEvent(exitEvent);
return;
}
// Regular cast event
const castEvent = {
timestamp: data[0],
type: data[1],
data: data[2]
};
// Process event without verbose logging
this.processEvent(castEvent);
}
}
catch (e) {
console.warn('Failed to parse stream event:', event.data);
}
};
eventSource.onerror = (error) => {
console.error('Stream error:', error);
// Close the connection to prevent automatic reconnection attempts
if (eventSource.readyState === EventSource.CLOSED) {
console.log('Stream closed, cleaning up...');
if (this.eventSource === eventSource) {
this.eventSource = null;
}
}
};
return eventSource;
}
// Load content from URL - pass isStream to determine how to handle it
async loadFromUrl(url, isStream) {
// Clean up existing connection
if (this.eventSource) {
console.log('Explicitly closing existing EventSource connection');
this.eventSource.close();
this.eventSource = null;
}
if (isStream) {
// It's a stream URL, connect via SSE (don't clear - append to existing content)
this.eventSource = this.connectToUrl(url);
}
else {
// It's a snapshot URL, clear first then load as cast file
this.terminal.clear();
await this.loadCastFile(url);
}
}
// Additional methods for terminal control
focus() {
this.terminal.focus();
}
blur() {
this.terminal.blur();
}
getTerminal() {
return this.terminal;
}
dispose() {
if (this.eventSource) {
console.log('Explicitly closing EventSource connection in dispose()');
this.eventSource.close();
this.eventSource = null;
}
this.terminal.dispose();
Renderer.activeCount--;
console.log(`Renderer disposed (active: ${Renderer.activeCount})`);
}
// Method to fit terminal to container (useful for responsive layouts)
fit() {
this.fitAddon.fit();
}
// Get terminal dimensions
getDimensions() {
return {
cols: this.terminal.cols,
rows: this.terminal.rows
};
}
// Write raw data to terminal (useful for testing)
write(data) {
this.terminal.write(data);
}
// Enable/disable input (though we keep it disabled by default)
setInputEnabled(enabled) {
// XTerm doesn't have a direct way to disable input, so we override onData
if (enabled) {
// Remove any existing handler first
this.terminal.onData(() => {
// Input is handled by the session component
});
}
else {
this.terminal.onData(() => {
// Do nothing - input disabled
});
}
}
// Disable all pointer events for previews so clicks pass through to parent
setPointerEventsEnabled(enabled) {
const terminalElement = this.container.querySelector('.xterm');
if (terminalElement) {
terminalElement.style.pointerEvents = enabled ? 'auto' : 'none';
}
}
}
Renderer.activeCount = 0;
//# sourceMappingURL=renderer.js.map