mirror of
https://github.com/samsonjs/vibetunnel.git
synced 2026-04-27 15:17:38 +00:00
- 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>
310 lines
No EOL
12 KiB
JavaScript
310 lines
No EOL
12 KiB
JavaScript
"use strict";
|
|
// Terminal renderer for asciinema cast format using XTerm.js
|
|
// Professional-grade terminal emulation with full VT compatibility
|
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
exports.Renderer = void 0;
|
|
const xterm_1 = require("@xterm/xterm");
|
|
const addon_fit_1 = require("@xterm/addon-fit");
|
|
const addon_web_links_1 = require("@xterm/addon-web-links");
|
|
const scale_fit_addon_js_1 = require("./scale-fit-addon.js");
|
|
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 xterm_1.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 addon_fit_1.FitAddon();
|
|
this.scaleFitAddon = new scale_fit_addon_js_1.ScaleFitAddon();
|
|
this.webLinksAddon = new addon_web_links_1.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';
|
|
}
|
|
}
|
|
}
|
|
exports.Renderer = Renderer;
|
|
Renderer.activeCount = 0;
|
|
//# sourceMappingURL=renderer.js.map
|