mirror of
https://github.com/samsonjs/vibetunnel.git
synced 2026-04-27 15:17:38 +00:00
Add vibe-terminal-buffer component for efficient session previews
Replace terminal.ts in session-card with new buffer-based component that: - Fetches terminal buffer snapshots via JSON API - Polls every second only when content changes (checks lastModified) - Automatically calculates lines needed based on container height - Reuses terminal rendering styles and logic Changes: - Create terminal-renderer.ts with shared rendering logic for both components - Add vibe-terminal-buffer component that works with buffer API - Update session-card to use vibe-terminal-buffer instead of vibe-terminal - Add terminal-line CSS for proper styling - Fix color handling in terminal-manager (-1 means default color) - Add debug logging to help diagnose rendering issues The new approach is more efficient - no cast file parsing, just direct buffer snapshots from the server. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
00602e3218
commit
b041743287
12 changed files with 1581 additions and 102 deletions
281
benchmark-streaming.js
Executable file
281
benchmark-streaming.js
Executable file
|
|
@ -0,0 +1,281 @@
|
|||
#!/usr/bin/env node
|
||||
|
||||
const fs = require('fs');
|
||||
const { spawn } = require('child_process');
|
||||
const path = require('path');
|
||||
|
||||
// Test configuration
|
||||
const TEST_FILE = '/tmp/stream-test.log';
|
||||
const TEST_LINES = 1000;
|
||||
const WRITE_DELAY = 10; // ms between writes
|
||||
|
||||
class FSWatchStreamer {
|
||||
constructor(filePath) {
|
||||
this.filePath = filePath;
|
||||
this.lastOffset = 0;
|
||||
this.watcher = null;
|
||||
this.onDataCallback = null;
|
||||
this.bytesRead = 0;
|
||||
this.linesRead = 0;
|
||||
}
|
||||
|
||||
start(onData) {
|
||||
this.onDataCallback = onData;
|
||||
|
||||
// Create file if it doesn't exist
|
||||
if (!fs.existsSync(this.filePath)) {
|
||||
fs.writeFileSync(this.filePath, '');
|
||||
}
|
||||
|
||||
// Get initial file size
|
||||
const stats = fs.statSync(this.filePath);
|
||||
this.lastOffset = stats.size;
|
||||
|
||||
// Start watching
|
||||
this.watcher = fs.watch(this.filePath, (eventType) => {
|
||||
if (eventType === 'change') {
|
||||
this.readNewData();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
readNewData() {
|
||||
try {
|
||||
const stats = fs.statSync(this.filePath);
|
||||
if (stats.size > this.lastOffset) {
|
||||
// Read only the new data since lastOffset
|
||||
const fd = fs.openSync(this.filePath, 'r');
|
||||
const buffer = Buffer.alloc(stats.size - this.lastOffset);
|
||||
fs.readSync(fd, buffer, 0, buffer.length, this.lastOffset);
|
||||
fs.closeSync(fd);
|
||||
|
||||
const newData = buffer;
|
||||
const oldOffset = this.lastOffset;
|
||||
this.lastOffset = stats.size;
|
||||
this.bytesRead += newData.length;
|
||||
const lines = newData.toString().split('\n').filter(line => line.length > 0);
|
||||
this.linesRead += lines.length;
|
||||
|
||||
// Debug logging (only first few)
|
||||
// if (this.bytesRead < 1000) {
|
||||
// console.log(`fs.watch: read ${newData.length} bytes (${oldOffset} -> ${this.lastOffset}), ${lines.length} lines`);
|
||||
// }
|
||||
|
||||
if (this.onDataCallback) {
|
||||
this.onDataCallback(newData);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// File might be temporarily locked
|
||||
console.log('fs.watch read error:', error.message);
|
||||
}
|
||||
}
|
||||
|
||||
stop() {
|
||||
if (this.watcher) {
|
||||
this.watcher.close();
|
||||
}
|
||||
}
|
||||
|
||||
getStats() {
|
||||
return {
|
||||
bytesRead: this.bytesRead,
|
||||
linesRead: this.linesRead
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
class TailStreamer {
|
||||
constructor(filePath) {
|
||||
this.filePath = filePath;
|
||||
this.tailProcess = null;
|
||||
this.bytesRead = 0;
|
||||
this.linesRead = 0;
|
||||
}
|
||||
|
||||
start(onData) {
|
||||
// Create file if it doesn't exist
|
||||
if (!fs.existsSync(this.filePath)) {
|
||||
fs.writeFileSync(this.filePath, '');
|
||||
}
|
||||
|
||||
this.tailProcess = spawn('tail', ['-f', this.filePath]);
|
||||
|
||||
this.tailProcess.stdout.on('data', (data) => {
|
||||
this.bytesRead += data.length;
|
||||
const lines = data.toString().split('\n').filter(line => line.length > 0);
|
||||
this.linesRead += lines.length;
|
||||
onData(data);
|
||||
});
|
||||
|
||||
this.tailProcess.stderr.on('data', (data) => {
|
||||
console.error('tail stderr:', data.toString());
|
||||
});
|
||||
}
|
||||
|
||||
stop() {
|
||||
if (this.tailProcess) {
|
||||
this.tailProcess.kill();
|
||||
}
|
||||
}
|
||||
|
||||
getStats() {
|
||||
return {
|
||||
bytesRead: this.bytesRead,
|
||||
linesRead: this.linesRead
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Data generator
|
||||
async function generateTestData() {
|
||||
console.log(`Generating ${TEST_LINES} lines to ${TEST_FILE}...`);
|
||||
|
||||
return new Promise((resolve) => {
|
||||
let lineCount = 0;
|
||||
const interval = setInterval(() => {
|
||||
const line = `Line ${lineCount + 1} - ${Date.now()} - some test data here\n`;
|
||||
fs.appendFileSync(TEST_FILE, line);
|
||||
lineCount++;
|
||||
|
||||
if (lineCount >= TEST_LINES) {
|
||||
clearInterval(interval);
|
||||
resolve();
|
||||
}
|
||||
}, WRITE_DELAY);
|
||||
});
|
||||
}
|
||||
|
||||
// Benchmark function
|
||||
async function benchmarkStreamer(StreamerClass, name) {
|
||||
console.log(`\n=== Testing ${name} ===`);
|
||||
|
||||
// Clear the test file before each test
|
||||
fs.writeFileSync(TEST_FILE, '');
|
||||
|
||||
const streamer = new StreamerClass(TEST_FILE);
|
||||
const startTime = process.hrtime.bigint();
|
||||
let firstDataTime = null;
|
||||
let lastDataTime = null;
|
||||
let dataChunks = 0;
|
||||
|
||||
// Start the streamer
|
||||
streamer.start((data) => {
|
||||
const now = process.hrtime.bigint();
|
||||
if (!firstDataTime) {
|
||||
firstDataTime = now;
|
||||
}
|
||||
lastDataTime = now;
|
||||
dataChunks++;
|
||||
});
|
||||
|
||||
// Wait a bit for setup
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
|
||||
// Generate test data
|
||||
await generateTestData();
|
||||
|
||||
// Wait for all data to be processed
|
||||
await new Promise(resolve => setTimeout(resolve, 500));
|
||||
|
||||
const endTime = process.hrtime.bigint();
|
||||
streamer.stop();
|
||||
|
||||
const stats = streamer.getStats();
|
||||
const totalTime = Number(endTime - startTime) / 1000000; // Convert to ms
|
||||
const firstDataLatency = firstDataTime ? Number(firstDataTime - startTime) / 1000000 : 0;
|
||||
const processingTime = lastDataTime && firstDataTime ? Number(lastDataTime - firstDataTime) / 1000000 : 0;
|
||||
|
||||
console.log(`${name} Results:`);
|
||||
console.log(` Total time: ${totalTime.toFixed(2)}ms`);
|
||||
console.log(` First data latency: ${firstDataLatency.toFixed(2)}ms`);
|
||||
console.log(` Processing time: ${processingTime.toFixed(2)}ms`);
|
||||
console.log(` Data chunks received: ${dataChunks}`);
|
||||
console.log(` Bytes read: ${stats.bytesRead}`);
|
||||
console.log(` Lines read: ${stats.linesRead}`);
|
||||
console.log(` Throughput: ${(stats.bytesRead / (totalTime / 1000)).toFixed(0)} bytes/sec`);
|
||||
|
||||
return {
|
||||
name,
|
||||
totalTime,
|
||||
firstDataLatency,
|
||||
processingTime,
|
||||
dataChunks,
|
||||
bytesRead: stats.bytesRead,
|
||||
linesRead: stats.linesRead,
|
||||
throughput: stats.bytesRead / (totalTime / 1000)
|
||||
};
|
||||
}
|
||||
|
||||
// Resource usage monitoring
|
||||
function getResourceUsage() {
|
||||
const usage = process.cpuUsage();
|
||||
const memUsage = process.memoryUsage();
|
||||
return {
|
||||
cpu: usage,
|
||||
memory: memUsage
|
||||
};
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log('🏁 Stream Performance Benchmark');
|
||||
console.log(`Test file: ${TEST_FILE}`);
|
||||
console.log(`Lines to write: ${TEST_LINES}`);
|
||||
console.log(`Write delay: ${WRITE_DELAY}ms`);
|
||||
|
||||
// Clean up any existing test file
|
||||
if (fs.existsSync(TEST_FILE)) {
|
||||
fs.unlinkSync(TEST_FILE);
|
||||
}
|
||||
|
||||
const results = [];
|
||||
|
||||
// Test fs.watch()
|
||||
const fsWatchResult = await benchmarkStreamer(FSWatchStreamer, 'fs.watch()');
|
||||
results.push(fsWatchResult);
|
||||
|
||||
// Wait between tests
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
|
||||
// Test tail -f
|
||||
const tailResult = await benchmarkStreamer(TailStreamer, 'tail -f');
|
||||
results.push(tailResult);
|
||||
|
||||
// Comparison
|
||||
console.log('\n🏆 COMPARISON:');
|
||||
console.log('=================');
|
||||
|
||||
const fsWatch = results[0];
|
||||
const tail = results[1];
|
||||
|
||||
console.log(`Setup latency:`);
|
||||
console.log(` fs.watch(): ${fsWatch.firstDataLatency.toFixed(2)}ms`);
|
||||
console.log(` tail -f: ${tail.firstDataLatency.toFixed(2)}ms`);
|
||||
console.log(` Winner: ${fsWatch.firstDataLatency < tail.firstDataLatency ? 'fs.watch()' : 'tail -f'} (${Math.abs(fsWatch.firstDataLatency - tail.firstDataLatency).toFixed(2)}ms faster)`);
|
||||
|
||||
console.log(`\nTotal time:`);
|
||||
console.log(` fs.watch(): ${fsWatch.totalTime.toFixed(2)}ms`);
|
||||
console.log(` tail -f: ${tail.totalTime.toFixed(2)}ms`);
|
||||
console.log(` Winner: ${fsWatch.totalTime < tail.totalTime ? 'fs.watch()' : 'tail -f'} (${Math.abs(fsWatch.totalTime - tail.totalTime).toFixed(2)}ms faster)`);
|
||||
|
||||
console.log(`\nThroughput:`);
|
||||
console.log(` fs.watch(): ${fsWatch.throughput.toFixed(0)} bytes/sec`);
|
||||
console.log(` tail -f: ${tail.throughput.toFixed(0)} bytes/sec`);
|
||||
console.log(` Winner: ${fsWatch.throughput > tail.throughput ? 'fs.watch()' : 'tail -f'} (${Math.abs(fsWatch.throughput - tail.throughput).toFixed(0)} bytes/sec faster)`);
|
||||
|
||||
console.log(`\nData integrity:`);
|
||||
console.log(` fs.watch(): ${fsWatch.linesRead} lines, ${fsWatch.bytesRead} bytes`);
|
||||
console.log(` tail -f: ${tail.linesRead} lines, ${tail.bytesRead} bytes`);
|
||||
console.log(` Match: ${fsWatch.bytesRead === tail.bytesRead ? '✅ Both read same amount' : '❌ Different amounts read'}`);
|
||||
|
||||
// Clean up
|
||||
if (fs.existsSync(TEST_FILE)) {
|
||||
fs.unlinkSync(TEST_FILE);
|
||||
}
|
||||
|
||||
console.log('\n✨ Benchmark complete!');
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
main().catch(console.error);
|
||||
}
|
||||
214
high-throughput-test.js
Executable file
214
high-throughput-test.js
Executable file
|
|
@ -0,0 +1,214 @@
|
|||
#!/usr/bin/env node
|
||||
|
||||
const fs = require('fs');
|
||||
const { spawn } = require('child_process');
|
||||
|
||||
const TEST_FILE = '/tmp/high-throughput-test.log';
|
||||
const DATA_SIZE = 10 * 1024 * 1024; // 10MB
|
||||
const CHUNK_SIZE = 1024; // 1KB chunks
|
||||
|
||||
class FSWatchStreamer {
|
||||
constructor(filePath) {
|
||||
this.filePath = filePath;
|
||||
this.lastOffset = 0;
|
||||
this.watcher = null;
|
||||
this.bytesReceived = 0;
|
||||
this.chunksReceived = 0;
|
||||
this.startTime = null;
|
||||
}
|
||||
|
||||
start(onData) {
|
||||
if (!fs.existsSync(this.filePath)) {
|
||||
fs.writeFileSync(this.filePath, '');
|
||||
}
|
||||
|
||||
const stats = fs.statSync(this.filePath);
|
||||
this.lastOffset = stats.size;
|
||||
this.startTime = process.hrtime.bigint();
|
||||
|
||||
this.watcher = fs.watch(this.filePath, (eventType) => {
|
||||
if (eventType === 'change') {
|
||||
this.readNewData(onData);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
readNewData(onData) {
|
||||
try {
|
||||
const stats = fs.statSync(this.filePath);
|
||||
if (stats.size > this.lastOffset) {
|
||||
const fd = fs.openSync(this.filePath, 'r');
|
||||
const buffer = Buffer.alloc(stats.size - this.lastOffset);
|
||||
fs.readSync(fd, buffer, 0, buffer.length, this.lastOffset);
|
||||
fs.closeSync(fd);
|
||||
|
||||
this.lastOffset = stats.size;
|
||||
this.bytesReceived += buffer.length;
|
||||
this.chunksReceived++;
|
||||
|
||||
onData(buffer);
|
||||
}
|
||||
} catch (error) {
|
||||
// Ignore read errors
|
||||
}
|
||||
}
|
||||
|
||||
stop() {
|
||||
if (this.watcher) {
|
||||
this.watcher.close();
|
||||
}
|
||||
|
||||
const endTime = process.hrtime.bigint();
|
||||
const durationMs = Number(endTime - this.startTime) / 1000000;
|
||||
const throughputMBps = (this.bytesReceived / (1024 * 1024)) / (durationMs / 1000);
|
||||
|
||||
return {
|
||||
bytesReceived: this.bytesReceived,
|
||||
chunksReceived: this.chunksReceived,
|
||||
durationMs,
|
||||
throughputMBps
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
class TailStreamer {
|
||||
constructor(filePath) {
|
||||
this.filePath = filePath;
|
||||
this.tailProcess = null;
|
||||
this.bytesReceived = 0;
|
||||
this.chunksReceived = 0;
|
||||
this.startTime = null;
|
||||
}
|
||||
|
||||
start(onData) {
|
||||
if (!fs.existsSync(this.filePath)) {
|
||||
fs.writeFileSync(this.filePath, '');
|
||||
}
|
||||
|
||||
this.startTime = process.hrtime.bigint();
|
||||
this.tailProcess = spawn('tail', ['-f', this.filePath]);
|
||||
|
||||
this.tailProcess.stdout.on('data', (data) => {
|
||||
this.bytesReceived += data.length;
|
||||
this.chunksReceived++;
|
||||
onData(data);
|
||||
});
|
||||
}
|
||||
|
||||
stop() {
|
||||
if (this.tailProcess) {
|
||||
this.tailProcess.kill();
|
||||
}
|
||||
|
||||
const endTime = process.hrtime.bigint();
|
||||
const durationMs = Number(endTime - this.startTime) / 1000000;
|
||||
const throughputMBps = (this.bytesReceived / (1024 * 1024)) / (durationMs / 1000);
|
||||
|
||||
return {
|
||||
bytesReceived: this.bytesReceived,
|
||||
chunksReceived: this.chunksReceived,
|
||||
durationMs,
|
||||
throughputMBps
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// High-speed data generator
|
||||
async function generateHighThroughputData() {
|
||||
console.log(`Writing ${DATA_SIZE / (1024 * 1024)}MB in ${CHUNK_SIZE} byte chunks...`);
|
||||
|
||||
const chunk = 'x'.repeat(CHUNK_SIZE - 1) + '\n'; // 1KB chunk
|
||||
const totalChunks = Math.floor(DATA_SIZE / CHUNK_SIZE);
|
||||
|
||||
const startTime = process.hrtime.bigint();
|
||||
|
||||
for (let i = 0; i < totalChunks; i++) {
|
||||
fs.appendFileSync(TEST_FILE, chunk);
|
||||
|
||||
// Small delay every 100 chunks to simulate realistic writing
|
||||
if (i % 100 === 0) {
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
}
|
||||
}
|
||||
|
||||
const endTime = process.hrtime.bigint();
|
||||
const writeTimeMs = Number(endTime - startTime) / 1000000;
|
||||
const writeThroughputMBps = (DATA_SIZE / (1024 * 1024)) / (writeTimeMs / 1000);
|
||||
|
||||
console.log(`Write completed in ${writeTimeMs.toFixed(2)}ms`);
|
||||
console.log(`Write throughput: ${writeThroughputMBps.toFixed(2)} MB/sec`);
|
||||
}
|
||||
|
||||
async function benchmarkHighThroughput(StreamerClass, name) {
|
||||
console.log(`\n=== ${name} High Throughput Test ===`);
|
||||
|
||||
// Clear test file
|
||||
fs.writeFileSync(TEST_FILE, '');
|
||||
|
||||
const streamer = new StreamerClass(TEST_FILE);
|
||||
let dataReceived = false;
|
||||
|
||||
streamer.start((data) => {
|
||||
if (!dataReceived) {
|
||||
dataReceived = true;
|
||||
console.log(`${name}: First data received`);
|
||||
}
|
||||
});
|
||||
|
||||
// Wait for setup
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
|
||||
// Generate data
|
||||
await generateHighThroughputData();
|
||||
|
||||
// Wait for processing
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
|
||||
const results = streamer.stop();
|
||||
|
||||
console.log(`${name} Results:`);
|
||||
console.log(` Bytes received: ${(results.bytesReceived / (1024 * 1024)).toFixed(2)} MB`);
|
||||
console.log(` Chunks received: ${results.chunksReceived}`);
|
||||
console.log(` Duration: ${results.durationMs.toFixed(2)}ms`);
|
||||
console.log(` Throughput: ${results.throughputMBps.toFixed(2)} MB/sec`);
|
||||
console.log(` Efficiency: ${((results.bytesReceived / DATA_SIZE) * 100).toFixed(1)}%`);
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log('🚀 High Throughput Streaming Test');
|
||||
console.log(`Target: ${DATA_SIZE / (1024 * 1024)}MB of data`);
|
||||
|
||||
// Clean up
|
||||
if (fs.existsSync(TEST_FILE)) {
|
||||
fs.unlinkSync(TEST_FILE);
|
||||
}
|
||||
|
||||
// Test fs.watch()
|
||||
const fsWatchResults = await benchmarkHighThroughput(FSWatchStreamer, 'fs.watch()');
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
|
||||
// Test tail -f
|
||||
const tailResults = await benchmarkHighThroughput(TailStreamer, 'tail -f');
|
||||
|
||||
// Comparison
|
||||
console.log('\n🏆 HIGH THROUGHPUT COMPARISON:');
|
||||
console.log('================================');
|
||||
console.log(`fs.watch(): ${fsWatchResults.throughputMBps.toFixed(2)} MB/sec`);
|
||||
console.log(`tail -f: ${tailResults.throughputMBps.toFixed(2)} MB/sec`);
|
||||
|
||||
const winner = fsWatchResults.throughputMBps > tailResults.throughputMBps ? 'fs.watch()' : 'tail -f';
|
||||
const difference = Math.abs(fsWatchResults.throughputMBps - tailResults.throughputMBps);
|
||||
console.log(`Winner: ${winner} (+${difference.toFixed(2)} MB/sec)`);
|
||||
|
||||
// Clean up
|
||||
if (fs.existsSync(TEST_FILE)) {
|
||||
fs.unlinkSync(TEST_FILE);
|
||||
}
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
main().catch(console.error);
|
||||
}
|
||||
59
simple-test.js
Normal file
59
simple-test.js
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
const fs = require('fs');
|
||||
|
||||
const TEST_FILE = '/tmp/simple-test.log';
|
||||
|
||||
// Create test file
|
||||
fs.writeFileSync(TEST_FILE, 'initial content\n');
|
||||
|
||||
console.log('File created with initial content');
|
||||
console.log('File size:', fs.statSync(TEST_FILE).size);
|
||||
|
||||
let lastOffset = fs.statSync(TEST_FILE).size;
|
||||
let totalBytes = 0;
|
||||
|
||||
const watcher = fs.watch(TEST_FILE, (eventType) => {
|
||||
if (eventType === 'change') {
|
||||
const stats = fs.statSync(TEST_FILE);
|
||||
console.log(`Change detected. File size: ${stats.size}, lastOffset: ${lastOffset}`);
|
||||
|
||||
if (stats.size > lastOffset) {
|
||||
// Read only the new data since lastOffset
|
||||
const fd = fs.openSync(TEST_FILE, 'r');
|
||||
const buffer = Buffer.alloc(stats.size - lastOffset);
|
||||
fs.readSync(fd, buffer, 0, buffer.length, lastOffset);
|
||||
fs.closeSync(fd);
|
||||
|
||||
const newData = buffer;
|
||||
|
||||
console.log(`Read ${newData.length} bytes: "${newData.toString().trim()}"`);
|
||||
totalBytes += newData.length;
|
||||
lastOffset = stats.size;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Append some data
|
||||
setTimeout(() => {
|
||||
console.log('\nAppending line 1...');
|
||||
fs.appendFileSync(TEST_FILE, 'line 1\n');
|
||||
}, 100);
|
||||
|
||||
setTimeout(() => {
|
||||
console.log('\nAppending line 2...');
|
||||
fs.appendFileSync(TEST_FILE, 'line 2\n');
|
||||
}, 200);
|
||||
|
||||
setTimeout(() => {
|
||||
console.log('\nAppending line 3...');
|
||||
fs.appendFileSync(TEST_FILE, 'line 3\n');
|
||||
}, 300);
|
||||
|
||||
setTimeout(() => {
|
||||
console.log(`\nTotal bytes read: ${totalBytes}`);
|
||||
console.log('Final file size:', fs.statSync(TEST_FILE).size);
|
||||
console.log('File contents:');
|
||||
console.log(fs.readFileSync(TEST_FILE, 'utf8'));
|
||||
|
||||
watcher.close();
|
||||
fs.unlinkSync(TEST_FILE);
|
||||
}, 500);
|
||||
|
|
@ -1,8 +1,6 @@
|
|||
import { LitElement, html, PropertyValues } from 'lit';
|
||||
import { LitElement, html } from 'lit';
|
||||
import { customElement, property, state } from 'lit/decorators.js';
|
||||
import './terminal.js';
|
||||
import type { Terminal } from './terminal.js';
|
||||
import { CastConverter } from '../utils/cast-converter.js';
|
||||
import './vibe-terminal-buffer.js';
|
||||
|
||||
export interface Session {
|
||||
id: string;
|
||||
|
|
@ -27,91 +25,16 @@ export class SessionCard extends LitElement {
|
|||
}
|
||||
|
||||
@property({ type: Object }) session!: Session;
|
||||
@state() private terminal: Terminal | null = null;
|
||||
@state() private killing = false;
|
||||
@state() private killingFrame = 0;
|
||||
|
||||
private killingInterval: number | null = null;
|
||||
|
||||
firstUpdated(changedProperties: PropertyValues) {
|
||||
super.firstUpdated(changedProperties);
|
||||
this.setupTerminal();
|
||||
}
|
||||
|
||||
updated(changedProperties: PropertyValues) {
|
||||
super.updated(changedProperties);
|
||||
|
||||
// Initialize terminal after first render when terminal element exists
|
||||
if (!this.terminal && !this.killing) {
|
||||
const terminalElement = this.querySelector('vibe-terminal') as Terminal;
|
||||
if (terminalElement) {
|
||||
this.initializeTerminal();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
if (this.killingInterval) {
|
||||
clearInterval(this.killingInterval);
|
||||
}
|
||||
// Terminal cleanup is handled by the component itself
|
||||
this.terminal = null;
|
||||
}
|
||||
|
||||
private setupTerminal() {
|
||||
// Terminal element will be created in render()
|
||||
// We'll initialize it in updated() after first render
|
||||
}
|
||||
|
||||
private async initializeTerminal() {
|
||||
const terminalElement = this.querySelector('vibe-terminal') as Terminal;
|
||||
if (!terminalElement) return;
|
||||
|
||||
this.terminal = terminalElement;
|
||||
|
||||
// Configure terminal for card display
|
||||
this.terminal.cols = 80;
|
||||
this.terminal.rows = 24;
|
||||
this.terminal.fontSize = 10; // Smaller font for card display
|
||||
this.terminal.fitHorizontally = true; // Fit to card width
|
||||
|
||||
// Load snapshot data
|
||||
const url = `/api/sessions/${this.session.id}/snapshot`;
|
||||
|
||||
// Wait a moment for freshly created sessions before connecting
|
||||
const sessionAge = Date.now() - new Date(this.session.startedAt).getTime();
|
||||
const delay = sessionAge < 5000 ? 2000 : 0; // 2 second delay if session is less than 5 seconds old
|
||||
|
||||
setTimeout(async () => {
|
||||
await this.loadSnapshot(url);
|
||||
}, delay);
|
||||
}
|
||||
|
||||
private async loadSnapshot(url: string) {
|
||||
if (!this.terminal) return;
|
||||
|
||||
try {
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) throw new Error(`Failed to fetch snapshot: ${response.status}`);
|
||||
|
||||
const castContent = await response.text();
|
||||
|
||||
// Clear terminal first
|
||||
this.terminal.clear();
|
||||
|
||||
// Use the new super-fast dump method that handles everything in one operation
|
||||
await CastConverter.dumpToTerminal(this.terminal, castContent);
|
||||
|
||||
// Scroll to bottom after loading
|
||||
this.terminal.queueCallback(() => {
|
||||
if (this.terminal) {
|
||||
this.terminal.scrollToBottom();
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to load session snapshot:', error);
|
||||
}
|
||||
}
|
||||
|
||||
private handleCardClick() {
|
||||
|
|
@ -284,15 +207,14 @@ export class SessionCard extends LitElement {
|
|||
</div>
|
||||
`
|
||||
: html`
|
||||
<vibe-terminal
|
||||
<vibe-terminal-buffer
|
||||
.sessionId=${this.session.id}
|
||||
.cols=${80}
|
||||
.rows=${24}
|
||||
.fontSize=${10}
|
||||
.fitHorizontally=${true}
|
||||
.pollInterval=${1000}
|
||||
class="w-full h-full"
|
||||
style="pointer-events: none;"
|
||||
></vibe-terminal>
|
||||
></vibe-terminal-buffer>
|
||||
`}
|
||||
</div>
|
||||
|
||||
|
|
|
|||
233
web/src/client/components/vibe-terminal-buffer.ts
Normal file
233
web/src/client/components/vibe-terminal-buffer.ts
Normal file
|
|
@ -0,0 +1,233 @@
|
|||
import { LitElement, html } from 'lit';
|
||||
import { customElement, property, state } from 'lit/decorators.js';
|
||||
import { TerminalRenderer, type BufferCell } from '../utils/terminal-renderer.js';
|
||||
|
||||
interface BufferSnapshot {
|
||||
cols: number;
|
||||
rows: number;
|
||||
viewportY: number;
|
||||
cursorX: number;
|
||||
cursorY: number;
|
||||
cells: BufferCell[][];
|
||||
}
|
||||
|
||||
@customElement('vibe-terminal-buffer')
|
||||
export class VibeTerminalBuffer extends LitElement {
|
||||
// Disable shadow DOM for Tailwind compatibility
|
||||
createRenderRoot() {
|
||||
return this as unknown as HTMLElement;
|
||||
}
|
||||
|
||||
@property({ type: String }) sessionId = '';
|
||||
@property({ type: Number }) fontSize = 14;
|
||||
@property({ type: Boolean }) fitHorizontally = false;
|
||||
@property({ type: Number }) pollInterval = 1000; // Poll interval in ms
|
||||
|
||||
@state() private buffer: BufferSnapshot | null = null;
|
||||
@state() private error: string | null = null;
|
||||
@state() private loading = false;
|
||||
@state() private actualRows = 0;
|
||||
@state() private displayedFontSize = 14;
|
||||
|
||||
private container: HTMLElement | null = null;
|
||||
private pollTimer: NodeJS.Timeout | null = null;
|
||||
private resizeObserver: ResizeObserver | null = null;
|
||||
private lastModified: string | null = null;
|
||||
|
||||
connectedCallback() {
|
||||
super.connectedCallback();
|
||||
this.startPolling();
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
this.stopPolling();
|
||||
if (this.resizeObserver) {
|
||||
this.resizeObserver.disconnect();
|
||||
this.resizeObserver = null;
|
||||
}
|
||||
super.disconnectedCallback();
|
||||
}
|
||||
|
||||
firstUpdated() {
|
||||
this.container = this.querySelector('#buffer-container') as HTMLElement;
|
||||
if (this.container) {
|
||||
this.setupResize();
|
||||
this.fetchBuffer();
|
||||
}
|
||||
}
|
||||
|
||||
updated(changedProperties: Map<string, unknown>) {
|
||||
if (changedProperties.has('sessionId')) {
|
||||
this.buffer = null;
|
||||
this.error = null;
|
||||
this.lastModified = null;
|
||||
if (this.sessionId) {
|
||||
this.fetchBuffer();
|
||||
}
|
||||
}
|
||||
if (changedProperties.has('pollInterval')) {
|
||||
this.stopPolling();
|
||||
this.startPolling();
|
||||
}
|
||||
if (changedProperties.has('fontSize') || changedProperties.has('fitHorizontally')) {
|
||||
this.calculateDimensions();
|
||||
}
|
||||
}
|
||||
|
||||
private setupResize() {
|
||||
if (!this.container) return;
|
||||
|
||||
this.resizeObserver = new ResizeObserver(() => {
|
||||
this.calculateDimensions();
|
||||
});
|
||||
this.resizeObserver.observe(this.container);
|
||||
}
|
||||
|
||||
private calculateDimensions() {
|
||||
if (!this.container) return;
|
||||
|
||||
const containerHeight = this.container.clientHeight;
|
||||
const lineHeight = this.fontSize * 1.2;
|
||||
const newActualRows = Math.floor(containerHeight / lineHeight);
|
||||
|
||||
if (this.fitHorizontally && this.buffer) {
|
||||
// Calculate font size to fit terminal width
|
||||
const containerWidth = this.container.clientWidth;
|
||||
const charWidth = this.fontSize * 0.6; // Approximate char width
|
||||
const requiredWidth = this.buffer.cols * charWidth;
|
||||
|
||||
if (requiredWidth > containerWidth) {
|
||||
const scale = containerWidth / requiredWidth;
|
||||
this.displayedFontSize = Math.floor(this.fontSize * scale);
|
||||
} else {
|
||||
this.displayedFontSize = this.fontSize;
|
||||
}
|
||||
} else {
|
||||
this.displayedFontSize = this.fontSize;
|
||||
}
|
||||
|
||||
if (newActualRows !== this.actualRows) {
|
||||
this.actualRows = newActualRows;
|
||||
this.fetchBuffer();
|
||||
}
|
||||
}
|
||||
|
||||
private startPolling() {
|
||||
if (this.pollInterval > 0) {
|
||||
this.pollTimer = setInterval(() => {
|
||||
if (!this.loading) {
|
||||
this.fetchBuffer();
|
||||
}
|
||||
}, this.pollInterval);
|
||||
}
|
||||
}
|
||||
|
||||
private stopPolling() {
|
||||
if (this.pollTimer) {
|
||||
clearInterval(this.pollTimer);
|
||||
this.pollTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
private async fetchBuffer() {
|
||||
if (!this.sessionId || this.actualRows === 0) return;
|
||||
|
||||
try {
|
||||
this.loading = true;
|
||||
|
||||
// First fetch stats to check if we need to update
|
||||
const statsResponse = await fetch(`/api/sessions/${this.sessionId}/buffer/stats`);
|
||||
if (!statsResponse.ok) {
|
||||
throw new Error(`Failed to fetch buffer stats: ${statsResponse.statusText}`);
|
||||
}
|
||||
const stats = await statsResponse.json();
|
||||
|
||||
// Check if buffer changed
|
||||
if (this.lastModified && this.lastModified === stats.lastModified) {
|
||||
this.loading = false;
|
||||
return; // No changes
|
||||
}
|
||||
|
||||
// Fetch buffer data - request lines from bottom
|
||||
const lines = Math.min(this.actualRows, stats.totalRows);
|
||||
const response = await fetch(
|
||||
`/api/sessions/${this.sessionId}/buffer?lines=${lines}&format=json`
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch buffer: ${response.statusText}`);
|
||||
}
|
||||
|
||||
this.buffer = await response.json();
|
||||
this.lastModified = stats.lastModified;
|
||||
this.error = null;
|
||||
|
||||
// Debug logging
|
||||
console.log(`Buffer loaded for ${this.sessionId}:`, {
|
||||
cols: this.buffer.cols,
|
||||
rows: this.buffer.rows,
|
||||
cellCount: this.buffer.cells.length,
|
||||
firstLineSample: this.buffer.cells[0]?.slice(0, 10),
|
||||
});
|
||||
|
||||
this.requestUpdate();
|
||||
} catch (error) {
|
||||
console.error('Error fetching buffer:', error);
|
||||
this.error = error instanceof Error ? error.message : 'Failed to fetch buffer';
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
return html`
|
||||
<div class="relative w-full h-full overflow-hidden bg-[#1e1e1e]">
|
||||
${this.error
|
||||
? html`
|
||||
<div class="absolute inset-0 flex items-center justify-center">
|
||||
<div class="text-red-500 text-sm">${this.error}</div>
|
||||
</div>
|
||||
`
|
||||
: html`
|
||||
<div
|
||||
id="buffer-container"
|
||||
class="terminal-container w-full h-full overflow-x-auto overflow-y-hidden font-mono antialiased"
|
||||
style="font-size: ${this.displayedFontSize}px; line-height: 1.2;"
|
||||
>
|
||||
${this.renderBuffer()}
|
||||
</div>
|
||||
`}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderBuffer() {
|
||||
if (!this.buffer) {
|
||||
return html`<div class="terminal-line"></div>`;
|
||||
}
|
||||
|
||||
const lineHeight = this.displayedFontSize * 1.2;
|
||||
|
||||
// Render lines
|
||||
return this.buffer.cells.map((row, index) => {
|
||||
const isCursorLine = index === this.buffer.cursorY;
|
||||
const cursorCol = isCursorLine ? this.buffer.cursorX : -1;
|
||||
const lineContent = TerminalRenderer.renderLineFromCells(row, cursorCol);
|
||||
|
||||
return html`
|
||||
<div
|
||||
class="terminal-line"
|
||||
style="height: ${lineHeight}px; line-height: ${lineHeight}px;"
|
||||
.innerHTML=${lineContent}
|
||||
></div>
|
||||
`;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Public method to fetch buffer on demand
|
||||
*/
|
||||
async refresh() {
|
||||
await this.fetchBuffer();
|
||||
}
|
||||
}
|
||||
421
web/src/client/utils/terminal-renderer.ts
Normal file
421
web/src/client/utils/terminal-renderer.ts
Normal file
|
|
@ -0,0 +1,421 @@
|
|||
import { IBufferCell } from '@xterm/headless';
|
||||
|
||||
export interface BufferCell {
|
||||
char: string;
|
||||
width: number;
|
||||
fg?: number;
|
||||
bg?: number;
|
||||
attributes?: number;
|
||||
}
|
||||
|
||||
// Attribute bit flags
|
||||
const ATTR_BOLD = 0x01;
|
||||
const ATTR_ITALIC = 0x02;
|
||||
const ATTR_UNDERLINE = 0x04;
|
||||
const ATTR_DIM = 0x08;
|
||||
const ATTR_INVERSE = 0x10;
|
||||
const ATTR_INVISIBLE = 0x20;
|
||||
const ATTR_STRIKETHROUGH = 0x40;
|
||||
|
||||
export class TerminalRenderer {
|
||||
private static escapeHtml(text: string): string {
|
||||
return text
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a line from IBufferCell array (from xterm.js)
|
||||
*/
|
||||
static renderLineFromBuffer(
|
||||
line: { getCell: (col: number, cell: IBufferCell) => void; length: number },
|
||||
cell: IBufferCell,
|
||||
cursorCol: number = -1
|
||||
): string {
|
||||
let html = '';
|
||||
let currentChars = '';
|
||||
let currentClasses = '';
|
||||
let currentStyle = '';
|
||||
|
||||
const flushGroup = () => {
|
||||
if (currentChars) {
|
||||
const escapedChars = this.escapeHtml(currentChars);
|
||||
html += `<span class="${currentClasses}"${currentStyle ? ` style="${currentStyle}"` : ''}>${escapedChars}</span>`;
|
||||
currentChars = '';
|
||||
}
|
||||
};
|
||||
|
||||
// Process each cell in the line
|
||||
for (let col = 0; col < line.length; col++) {
|
||||
line.getCell(col, cell);
|
||||
if (!cell) continue;
|
||||
|
||||
const char = cell.getChars() || ' ';
|
||||
const width = cell.getWidth();
|
||||
|
||||
// Skip zero-width cells (part of wide characters)
|
||||
if (width === 0) continue;
|
||||
|
||||
// Get styling
|
||||
const { classes, style } = this.getCellStyling(cell, col === cursorCol);
|
||||
|
||||
// Check if styling changed
|
||||
if (classes !== currentClasses || style !== currentStyle) {
|
||||
flushGroup();
|
||||
currentClasses = classes;
|
||||
currentStyle = style;
|
||||
}
|
||||
|
||||
currentChars += char;
|
||||
}
|
||||
|
||||
// Flush remaining chars
|
||||
flushGroup();
|
||||
|
||||
return html;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a line from BufferCell array (from JSON/binary buffer)
|
||||
*/
|
||||
static renderLineFromCells(cells: BufferCell[], cursorCol: number = -1): string {
|
||||
let html = '';
|
||||
let currentChars = '';
|
||||
let currentClasses = '';
|
||||
let currentStyle = '';
|
||||
|
||||
const flushGroup = () => {
|
||||
if (currentChars) {
|
||||
const escapedChars = this.escapeHtml(currentChars);
|
||||
html += `<span class="${currentClasses}"${currentStyle ? ` style="${currentStyle}"` : ''}>${escapedChars}</span>`;
|
||||
currentChars = '';
|
||||
}
|
||||
};
|
||||
|
||||
// Process each cell
|
||||
let col = 0;
|
||||
for (const cell of cells) {
|
||||
// Skip zero-width cells
|
||||
if (cell.width === 0) continue;
|
||||
|
||||
// Get styling
|
||||
const { classes, style } = this.getCellStylingFromBuffer(cell, col === cursorCol);
|
||||
|
||||
// Check if styling changed
|
||||
if (classes !== currentClasses || style !== currentStyle) {
|
||||
flushGroup();
|
||||
currentClasses = classes;
|
||||
currentStyle = style;
|
||||
}
|
||||
|
||||
currentChars += cell.char;
|
||||
col += cell.width;
|
||||
}
|
||||
|
||||
// Flush remaining chars
|
||||
flushGroup();
|
||||
|
||||
return html;
|
||||
}
|
||||
|
||||
private static getCellStyling(
|
||||
cell: IBufferCell,
|
||||
isCursor: boolean
|
||||
): { classes: string; style: string } {
|
||||
let classes = 'terminal-char';
|
||||
let style = '';
|
||||
|
||||
if (isCursor) {
|
||||
classes += ' cursor';
|
||||
}
|
||||
|
||||
// Get foreground color
|
||||
const fg = cell.getFgColor();
|
||||
if (fg !== undefined) {
|
||||
if (typeof fg === 'number' && fg >= 0 && fg <= 255) {
|
||||
style += `color: var(--terminal-color-${fg});`;
|
||||
} else if (typeof fg === 'number' && fg > 255) {
|
||||
const r = (fg >> 16) & 0xff;
|
||||
const g = (fg >> 8) & 0xff;
|
||||
const b = fg & 0xff;
|
||||
style += `color: rgb(${r}, ${g}, ${b});`;
|
||||
}
|
||||
}
|
||||
|
||||
// Get background color
|
||||
const bg = cell.getBgColor();
|
||||
if (bg !== undefined) {
|
||||
if (typeof bg === 'number' && bg >= 0 && bg <= 255) {
|
||||
style += `background-color: var(--terminal-color-${bg});`;
|
||||
} else if (typeof bg === 'number' && bg > 255) {
|
||||
const r = (bg >> 16) & 0xff;
|
||||
const g = (bg >> 8) & 0xff;
|
||||
const b = bg & 0xff;
|
||||
style += `background-color: rgb(${r}, ${g}, ${b});`;
|
||||
}
|
||||
}
|
||||
|
||||
// Override background for cursor
|
||||
if (isCursor) {
|
||||
style += `background-color: #23d18b;`;
|
||||
}
|
||||
|
||||
// Get text attributes
|
||||
if (cell.isBold()) classes += ' bold';
|
||||
if (cell.isItalic()) classes += ' italic';
|
||||
if (cell.isUnderline()) classes += ' underline';
|
||||
if (cell.isDim()) classes += ' dim';
|
||||
if (cell.isStrikethrough()) classes += ' strikethrough';
|
||||
|
||||
// Handle inverse colors
|
||||
if (cell.isInverse()) {
|
||||
const tempFg = style.match(/color: ([^;]+);/)?.[1];
|
||||
const tempBg = style.match(/background-color: ([^;]+);/)?.[1];
|
||||
if (tempFg && tempBg) {
|
||||
style = style.replace(/color: [^;]+;/, `color: ${tempBg};`);
|
||||
style = style.replace(/background-color: [^;]+;/, `background-color: ${tempFg};`);
|
||||
} else if (tempFg) {
|
||||
style = style.replace(/color: [^;]+;/, 'color: #1e1e1e;');
|
||||
style += `background-color: ${tempFg};`;
|
||||
} else {
|
||||
style += 'color: #1e1e1e; background-color: #d4d4d4;';
|
||||
}
|
||||
}
|
||||
|
||||
// Handle invisible text
|
||||
if (cell.isInvisible()) {
|
||||
style += 'opacity: 0;';
|
||||
}
|
||||
|
||||
return { classes, style };
|
||||
}
|
||||
|
||||
private static getCellStylingFromBuffer(
|
||||
cell: BufferCell,
|
||||
isCursor: boolean
|
||||
): { classes: string; style: string } {
|
||||
let classes = 'terminal-char';
|
||||
let style = '';
|
||||
|
||||
if (isCursor) {
|
||||
classes += ' cursor';
|
||||
}
|
||||
|
||||
// Get foreground color
|
||||
if (cell.fg !== undefined) {
|
||||
if (cell.fg >= 0 && cell.fg <= 255) {
|
||||
style += `color: var(--terminal-color-${cell.fg});`;
|
||||
} else {
|
||||
const r = (cell.fg >> 16) & 0xff;
|
||||
const g = (cell.fg >> 8) & 0xff;
|
||||
const b = cell.fg & 0xff;
|
||||
style += `color: rgb(${r}, ${g}, ${b});`;
|
||||
}
|
||||
}
|
||||
|
||||
// Get background color
|
||||
if (cell.bg !== undefined) {
|
||||
if (cell.bg >= 0 && cell.bg <= 255) {
|
||||
style += `background-color: var(--terminal-color-${cell.bg});`;
|
||||
} else {
|
||||
const r = (cell.bg >> 16) & 0xff;
|
||||
const g = (cell.bg >> 8) & 0xff;
|
||||
const b = cell.bg & 0xff;
|
||||
style += `background-color: rgb(${r}, ${g}, ${b});`;
|
||||
}
|
||||
}
|
||||
|
||||
// Override background for cursor
|
||||
if (isCursor) {
|
||||
style += `background-color: #23d18b;`;
|
||||
}
|
||||
|
||||
// Get text attributes from bit flags
|
||||
const attrs = cell.attributes || 0;
|
||||
if (attrs & ATTR_BOLD) classes += ' bold';
|
||||
if (attrs & ATTR_ITALIC) classes += ' italic';
|
||||
if (attrs & ATTR_UNDERLINE) classes += ' underline';
|
||||
if (attrs & ATTR_DIM) classes += ' dim';
|
||||
if (attrs & ATTR_STRIKETHROUGH) classes += ' strikethrough';
|
||||
|
||||
// Handle inverse colors
|
||||
if (attrs & ATTR_INVERSE) {
|
||||
const tempFg = style.match(/color: ([^;]+);/)?.[1];
|
||||
const tempBg = style.match(/background-color: ([^;]+);/)?.[1];
|
||||
if (tempFg && tempBg) {
|
||||
style = style.replace(/color: [^;]+;/, `color: ${tempBg};`);
|
||||
style = style.replace(/background-color: [^;]+;/, `background-color: ${tempFg};`);
|
||||
} else if (tempFg) {
|
||||
style = style.replace(/color: [^;]+;/, 'color: #1e1e1e;');
|
||||
style += `background-color: ${tempFg};`;
|
||||
} else {
|
||||
style += 'color: #1e1e1e; background-color: #d4d4d4;';
|
||||
}
|
||||
}
|
||||
|
||||
// Handle invisible text
|
||||
if (attrs & ATTR_INVISIBLE) {
|
||||
style += 'opacity: 0;';
|
||||
}
|
||||
|
||||
return { classes, style };
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode binary buffer format
|
||||
*/
|
||||
static decodeBinaryBuffer(buffer: ArrayBuffer): {
|
||||
cols: number;
|
||||
rows: number;
|
||||
viewportY: number;
|
||||
cursorX: number;
|
||||
cursorY: number;
|
||||
cells: BufferCell[][];
|
||||
} {
|
||||
const view = new DataView(buffer);
|
||||
let offset = 0;
|
||||
|
||||
// Read header
|
||||
const magic = view.getUint16(offset, true);
|
||||
offset += 2;
|
||||
if (magic !== 0x5654) {
|
||||
throw new Error('Invalid buffer format');
|
||||
}
|
||||
|
||||
const version = view.getUint8(offset++);
|
||||
if (version !== 0x01) {
|
||||
throw new Error('Unsupported buffer version');
|
||||
}
|
||||
|
||||
const _flags = view.getUint8(offset++);
|
||||
const cols = view.getUint16(offset, true);
|
||||
offset += 2;
|
||||
const rows = view.getUint16(offset, true);
|
||||
offset += 2;
|
||||
const viewportY = view.getUint16(offset, true);
|
||||
offset += 2;
|
||||
const cursorX = view.getUint16(offset, true);
|
||||
offset += 2;
|
||||
const cursorY = view.getUint16(offset, true);
|
||||
offset += 2;
|
||||
offset += 2; // Skip reserved
|
||||
|
||||
// Decode cells
|
||||
const cells: BufferCell[][] = [];
|
||||
const uint8 = new Uint8Array(buffer);
|
||||
|
||||
for (let row = 0; row < rows; row++) {
|
||||
const rowCells: BufferCell[] = [];
|
||||
|
||||
for (let col = 0; col < cols; ) {
|
||||
if (offset >= uint8.length) break;
|
||||
|
||||
// Check for special markers
|
||||
const firstByte = uint8[offset];
|
||||
|
||||
if (firstByte === 0xff) {
|
||||
// Run-length encoding
|
||||
offset++;
|
||||
const count = uint8[offset++];
|
||||
const cell = this.decodeCell(uint8, offset);
|
||||
offset = cell.offset;
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
rowCells.push(cell.cell);
|
||||
col++;
|
||||
}
|
||||
} else if (firstByte === 0xfe) {
|
||||
// Empty line marker
|
||||
offset++;
|
||||
const count = uint8[offset++];
|
||||
for (let i = 0; i < count && row < rows; i++) {
|
||||
const emptyRow: BufferCell[] = [];
|
||||
for (let j = 0; j < cols; j++) {
|
||||
emptyRow.push({ char: ' ', width: 1 });
|
||||
}
|
||||
cells.push(emptyRow);
|
||||
row++;
|
||||
}
|
||||
row--; // Adjust for outer loop increment
|
||||
break;
|
||||
} else {
|
||||
// Regular cell
|
||||
const result = this.decodeCell(uint8, offset);
|
||||
offset = result.offset;
|
||||
rowCells.push(result.cell);
|
||||
col++;
|
||||
}
|
||||
}
|
||||
|
||||
if (rowCells.length > 0) {
|
||||
cells.push(rowCells);
|
||||
}
|
||||
}
|
||||
|
||||
return { cols, rows, viewportY, cursorX, cursorY, cells };
|
||||
}
|
||||
|
||||
private static decodeCell(
|
||||
uint8: Uint8Array,
|
||||
offset: number
|
||||
): { cell: BufferCell; offset: number } {
|
||||
const firstByte = uint8[offset];
|
||||
|
||||
if (firstByte & 0x80) {
|
||||
// Extended cell
|
||||
const header = uint8[offset++];
|
||||
const attributes = uint8[offset++] & 0x7f; // Remove extended bit
|
||||
const charLen = ((header >> 6) & 0x03) + 1;
|
||||
const hasRgbFg = !!(header & 0x20);
|
||||
const hasRgbBg = !!(header & 0x10);
|
||||
|
||||
// Read character
|
||||
const charBytes = uint8.slice(offset, offset + charLen);
|
||||
const char = new TextDecoder().decode(charBytes);
|
||||
offset += charLen;
|
||||
|
||||
// Read colors
|
||||
let fg: number | undefined;
|
||||
let bg: number | undefined;
|
||||
|
||||
if (hasRgbFg) {
|
||||
fg = (uint8[offset] << 16) | (uint8[offset + 1] << 8) | uint8[offset + 2];
|
||||
offset += 3;
|
||||
} else {
|
||||
fg = uint8[offset++];
|
||||
}
|
||||
|
||||
if (hasRgbBg) {
|
||||
bg = (uint8[offset] << 16) | (uint8[offset + 1] << 8) | uint8[offset + 2];
|
||||
offset += 3;
|
||||
} else {
|
||||
bg = uint8[offset++];
|
||||
}
|
||||
|
||||
return {
|
||||
cell: { char, width: 1, fg, bg, attributes },
|
||||
offset,
|
||||
};
|
||||
} else {
|
||||
// Basic cell
|
||||
const char = String.fromCharCode(uint8[offset++]);
|
||||
const attributes = uint8[offset++];
|
||||
const fg = uint8[offset++];
|
||||
const bg = uint8[offset++];
|
||||
|
||||
return {
|
||||
cell: {
|
||||
char,
|
||||
width: 1,
|
||||
fg: fg === 7 ? undefined : fg,
|
||||
bg: bg === 0 ? undefined : bg,
|
||||
attributes: attributes === 0 ? undefined : attributes,
|
||||
},
|
||||
offset,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -110,6 +110,13 @@ body {
|
|||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
/* Terminal line styling */
|
||||
.terminal-line {
|
||||
white-space: pre;
|
||||
overflow: hidden;
|
||||
color: #d4d4d4;
|
||||
}
|
||||
|
||||
/* Terminal character specific styling */
|
||||
.terminal-char {
|
||||
font-variant-ligatures: none;
|
||||
|
|
|
|||
|
|
@ -392,11 +392,43 @@ app.get('/api/sessions/:sessionId/snapshot', (req, res) => {
|
|||
}
|
||||
});
|
||||
|
||||
// Get session buffer in binary format
|
||||
// Get session buffer stats
|
||||
app.get('/api/sessions/:sessionId/buffer/stats', async (req, res) => {
|
||||
const sessionId = req.params.sessionId;
|
||||
|
||||
try {
|
||||
// Validate session exists
|
||||
const session = ptyService.getSession(sessionId);
|
||||
if (!session) {
|
||||
return res.status(404).json({ error: 'Session not found' });
|
||||
}
|
||||
|
||||
// Check if stream file exists
|
||||
const streamOutPath = path.join(TTY_FWD_CONTROL_DIR, sessionId, 'stream-out');
|
||||
if (!fs.existsSync(streamOutPath)) {
|
||||
return res.status(404).json({ error: 'Session stream not found' });
|
||||
}
|
||||
|
||||
// Get terminal stats
|
||||
const stats = await terminalManager.getBufferStats(sessionId);
|
||||
|
||||
// Add last modified time from stream file
|
||||
const fileStats = fs.statSync(streamOutPath);
|
||||
stats.lastModified = fileStats.mtime.toISOString();
|
||||
|
||||
res.json(stats);
|
||||
} catch (error) {
|
||||
console.error('Error getting session buffer stats:', error);
|
||||
res.status(500).json({ error: 'Failed to get session buffer stats' });
|
||||
}
|
||||
});
|
||||
|
||||
// Get session buffer in binary or JSON format
|
||||
app.get('/api/sessions/:sessionId/buffer', async (req, res) => {
|
||||
const sessionId = req.params.sessionId;
|
||||
const viewportY = parseInt(req.query.viewportY as string) || 0;
|
||||
const viewportY = req.query.viewportY ? parseInt(req.query.viewportY as string) : undefined;
|
||||
const lines = parseInt(req.query.lines as string) || 24;
|
||||
const format = (req.query.format as string) || 'binary';
|
||||
|
||||
try {
|
||||
// Validate session exists
|
||||
|
|
@ -412,15 +444,21 @@ app.get('/api/sessions/:sessionId/buffer', async (req, res) => {
|
|||
}
|
||||
|
||||
// Get buffer snapshot
|
||||
// If viewportY is not specified, get lines from bottom
|
||||
const snapshot = await terminalManager.getBufferSnapshot(sessionId, viewportY, lines);
|
||||
|
||||
// Encode to binary format
|
||||
const binaryData = terminalManager.encodeSnapshot(snapshot);
|
||||
if (format === 'json') {
|
||||
// Send JSON response
|
||||
res.json(snapshot);
|
||||
} else {
|
||||
// Encode to binary format
|
||||
const binaryData = terminalManager.encodeSnapshot(snapshot);
|
||||
|
||||
// Send binary response
|
||||
res.setHeader('Content-Type', 'application/octet-stream');
|
||||
res.setHeader('Content-Length', binaryData.length.toString());
|
||||
res.send(binaryData);
|
||||
// Send binary response
|
||||
res.setHeader('Content-Type', 'application/octet-stream');
|
||||
res.setHeader('Content-Length', binaryData.length.toString());
|
||||
res.send(binaryData);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error getting session buffer:', error);
|
||||
res.status(500).json({ error: 'Failed to get session buffer' });
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { Terminal as XtermTerminal, IBufferCell } from '@xterm/headless';
|
||||
import { Terminal as XtermTerminal } from '@xterm/headless';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
|
|
@ -181,29 +181,58 @@ export class TerminalManager {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get buffer stats for a session
|
||||
*/
|
||||
async getBufferStats(sessionId: string) {
|
||||
const terminal = await this.getTerminal(sessionId);
|
||||
const buffer = terminal.buffer.active;
|
||||
|
||||
return {
|
||||
totalRows: buffer.length,
|
||||
cols: terminal.cols,
|
||||
rows: terminal.rows,
|
||||
viewportY: buffer.viewportY,
|
||||
cursorX: buffer.cursorX,
|
||||
cursorY: buffer.cursorY,
|
||||
scrollback: terminal.options.scrollback || 0,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get buffer snapshot for a session
|
||||
*/
|
||||
async getBufferSnapshot(
|
||||
sessionId: string,
|
||||
viewportY: number,
|
||||
viewportY: number | undefined,
|
||||
lines: number
|
||||
): Promise<BufferSnapshot> {
|
||||
const terminal = await this.getTerminal(sessionId);
|
||||
const buffer = terminal.buffer.active;
|
||||
|
||||
// Calculate actual viewport bounds
|
||||
const startLine = Math.max(0, viewportY);
|
||||
const endLine = Math.min(buffer.length, viewportY + lines);
|
||||
let startLine: number;
|
||||
let actualViewportY: number;
|
||||
|
||||
if (viewportY === undefined) {
|
||||
// Get lines from bottom - calculate start position
|
||||
startLine = Math.max(0, buffer.length - lines);
|
||||
actualViewportY = startLine;
|
||||
} else {
|
||||
// Use specified viewport position
|
||||
startLine = Math.max(0, viewportY);
|
||||
actualViewportY = viewportY;
|
||||
}
|
||||
|
||||
const endLine = Math.min(buffer.length, startLine + lines);
|
||||
const actualLines = endLine - startLine;
|
||||
|
||||
// Get cursor position
|
||||
// Get cursor position relative to our viewport
|
||||
const cursorX = buffer.cursorX;
|
||||
const cursorY = buffer.cursorY + buffer.viewportY - viewportY;
|
||||
const cursorY = buffer.cursorY + buffer.viewportY - actualViewportY;
|
||||
|
||||
// Extract cells
|
||||
const cells: BufferCell[][] = [];
|
||||
const cell: IBufferCell = {} as IBufferCell;
|
||||
const cell = buffer.getNullCell();
|
||||
|
||||
for (let row = 0; row < actualLines; row++) {
|
||||
const line = buffer.getLine(startLine + row);
|
||||
|
|
@ -238,8 +267,9 @@ export class TerminalManager {
|
|||
const fg = cell.getFgColor();
|
||||
const bg = cell.getBgColor();
|
||||
|
||||
if (fg !== undefined) bufferCell.fg = fg;
|
||||
if (bg !== undefined) bufferCell.bg = bg;
|
||||
// Handle color values - -1 means default color
|
||||
if (fg !== undefined && fg !== -1) bufferCell.fg = fg;
|
||||
if (bg !== undefined && bg !== -1) bufferCell.bg = bg;
|
||||
if (attributes !== 0) bufferCell.attributes = attributes;
|
||||
|
||||
rowCells.push(bufferCell);
|
||||
|
|
@ -257,7 +287,7 @@ export class TerminalManager {
|
|||
return {
|
||||
cols: terminal.cols,
|
||||
rows: actualLines,
|
||||
viewportY,
|
||||
viewportY: actualViewportY,
|
||||
cursorX,
|
||||
cursorY,
|
||||
cells,
|
||||
|
|
|
|||
109
web/test-buffer-component.js
Executable file
109
web/test-buffer-component.js
Executable file
|
|
@ -0,0 +1,109 @@
|
|||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Test script for the new buffer component
|
||||
*/
|
||||
|
||||
const http = require('http');
|
||||
|
||||
const BASE_URL = 'http://localhost:3000';
|
||||
|
||||
function httpGet(url) {
|
||||
return new Promise((resolve, reject) => {
|
||||
http.get(url, (res) => {
|
||||
let data = '';
|
||||
res.on('data', (chunk) => {
|
||||
data += chunk;
|
||||
});
|
||||
res.on('end', () => {
|
||||
if (res.statusCode === 200) {
|
||||
try {
|
||||
resolve(JSON.parse(data));
|
||||
} catch (e) {
|
||||
resolve(data);
|
||||
}
|
||||
} else {
|
||||
reject(new Error(`HTTP ${res.statusCode}: ${data}`));
|
||||
}
|
||||
});
|
||||
}).on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
async function testBufferEndpoints(sessionId) {
|
||||
console.log(`\n=== Testing buffer endpoints for session ${sessionId} ===`);
|
||||
|
||||
// Test stats endpoint
|
||||
console.log('\n1. Testing /buffer/stats endpoint:');
|
||||
try {
|
||||
const stats = await httpGet(`${BASE_URL}/api/sessions/${sessionId}/buffer/stats`);
|
||||
console.log(' ✅ Stats:', JSON.stringify(stats, null, 2));
|
||||
} catch (error) {
|
||||
console.error(' ❌ Error:', error.message);
|
||||
return;
|
||||
}
|
||||
|
||||
// Test JSON buffer endpoint
|
||||
console.log('\n2. Testing /buffer endpoint with JSON format:');
|
||||
try {
|
||||
const buffer = await httpGet(`${BASE_URL}/api/sessions/${sessionId}/buffer?lines=10&format=json`);
|
||||
console.log(' ✅ Buffer dimensions:', `${buffer.cols}x${buffer.rows}`);
|
||||
console.log(' ✅ Viewport Y:', buffer.viewportY);
|
||||
console.log(' ✅ Cursor position:', `(${buffer.cursorX}, ${buffer.cursorY})`);
|
||||
console.log(' ✅ Cell count:', buffer.cells.length);
|
||||
|
||||
// Show sample of first line
|
||||
if (buffer.cells.length > 0) {
|
||||
const firstLine = buffer.cells[0];
|
||||
const text = firstLine.map(cell => cell.char).join('');
|
||||
console.log(' ✅ First line preview:', text.substring(0, 40) + '...');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(' ❌ Error:', error.message);
|
||||
return;
|
||||
}
|
||||
|
||||
// Test bottom-up lines (without viewportY)
|
||||
console.log('\n3. Testing bottom-up lines (no viewportY):');
|
||||
try {
|
||||
const buffer = await httpGet(`${BASE_URL}/api/sessions/${sessionId}/buffer?lines=5&format=json`);
|
||||
console.log(' ✅ Got', buffer.rows, 'lines from bottom');
|
||||
console.log(' ✅ ViewportY:', buffer.viewportY);
|
||||
} catch (error) {
|
||||
console.error(' ❌ Error:', error.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log('Buffer Component Test Script');
|
||||
console.log('============================');
|
||||
|
||||
// First get list of sessions
|
||||
console.log('\nFetching sessions...');
|
||||
try {
|
||||
const sessions = await httpGet(`${BASE_URL}/api/sessions`);
|
||||
console.log(`Found ${sessions.length} sessions`);
|
||||
|
||||
if (sessions.length === 0) {
|
||||
console.log('\nNo sessions found. Please create a session first.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Test first running session
|
||||
const runningSessions = sessions.filter(s => s.status === 'running');
|
||||
if (runningSessions.length === 0) {
|
||||
console.log('\nNo running sessions found. Testing with first session...');
|
||||
await testBufferEndpoints(sessions[0].id);
|
||||
} else {
|
||||
console.log(`\nTesting with running session: ${runningSessions[0].command}`);
|
||||
await testBufferEndpoints(runningSessions[0].id);
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error:', error.message);
|
||||
}
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
main().catch(console.error);
|
||||
}
|
||||
94
web/test-buffer-endpoint.js
Executable file
94
web/test-buffer-endpoint.js
Executable file
|
|
@ -0,0 +1,94 @@
|
|||
#!/usr/bin/env node
|
||||
|
||||
// Test script for the new buffer endpoint
|
||||
|
||||
const BASE_URL = 'http://localhost:3000';
|
||||
|
||||
async function testBufferEndpoint() {
|
||||
try {
|
||||
// First, get list of sessions
|
||||
const sessionsRes = await fetch(`${BASE_URL}/api/sessions`);
|
||||
const sessions = await sessionsRes.json();
|
||||
|
||||
if (sessions.length === 0) {
|
||||
console.log('No sessions available. Create a session first.');
|
||||
return;
|
||||
}
|
||||
|
||||
const sessionId = sessions[0].id;
|
||||
console.log(`Testing with session: ${sessionId}`);
|
||||
|
||||
// Test buffer endpoint
|
||||
const bufferRes = await fetch(`${BASE_URL}/api/sessions/${sessionId}/buffer?viewportY=0&lines=24`);
|
||||
|
||||
if (!bufferRes.ok) {
|
||||
console.error('Buffer request failed:', bufferRes.status, await bufferRes.text());
|
||||
return;
|
||||
}
|
||||
|
||||
const buffer = await bufferRes.arrayBuffer();
|
||||
const bytes = new Uint8Array(buffer);
|
||||
|
||||
console.log(`Received ${bytes.length} bytes`);
|
||||
|
||||
// Parse header
|
||||
if (bytes.length < 16) {
|
||||
console.error('Buffer too small for header');
|
||||
return;
|
||||
}
|
||||
|
||||
const magic = (bytes[1] << 8) | bytes[0];
|
||||
const version = bytes[2];
|
||||
const flags = bytes[3];
|
||||
const cols = (bytes[5] << 8) | bytes[4];
|
||||
const rows = (bytes[7] << 8) | bytes[6];
|
||||
const viewportY = (bytes[9] << 8) | bytes[8];
|
||||
const cursorX = (bytes[11] << 8) | bytes[10];
|
||||
const cursorY = (bytes[13] << 8) | bytes[12];
|
||||
|
||||
console.log('\nHeader:');
|
||||
console.log(` Magic: 0x${magic.toString(16)} (${magic === 0x5654 ? 'Valid' : 'Invalid'})`);
|
||||
console.log(` Version: ${version}`);
|
||||
console.log(` Flags: ${flags}`);
|
||||
console.log(` Terminal: ${cols}x${rows}`);
|
||||
console.log(` ViewportY: ${viewportY}`);
|
||||
console.log(` Cursor: (${cursorX}, ${cursorY})`);
|
||||
|
||||
// Sample first few cells
|
||||
console.log('\nFirst few cells:');
|
||||
let offset = 16;
|
||||
for (let i = 0; i < Math.min(10, bytes.length - 16); i++) {
|
||||
if (offset >= bytes.length) break;
|
||||
|
||||
const byte = bytes[offset];
|
||||
if (byte === 0xFF) {
|
||||
// RLE marker
|
||||
const count = bytes[offset + 1];
|
||||
console.log(` RLE: ${count} repeated cells`);
|
||||
offset += 2;
|
||||
} else if (byte === 0xFE) {
|
||||
// Empty line marker
|
||||
const count = bytes[offset + 1];
|
||||
console.log(` Empty lines: ${count}`);
|
||||
offset += 2;
|
||||
} else if (byte & 0x80) {
|
||||
// Extended cell
|
||||
console.log(` Extended cell at offset ${offset}`);
|
||||
offset += 4; // Skip for now
|
||||
} else {
|
||||
// Basic cell
|
||||
const char = String.fromCharCode(byte);
|
||||
const attrs = bytes[offset + 1];
|
||||
const fg = bytes[offset + 2];
|
||||
const bg = bytes[offset + 3];
|
||||
console.log(` Cell: '${char}' fg=${fg} bg=${bg} attrs=${attrs}`);
|
||||
offset += 4;
|
||||
}
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('Test failed:', error);
|
||||
}
|
||||
}
|
||||
|
||||
testBufferEndpoint();
|
||||
71
web/test-terminal-manager.js
Normal file
71
web/test-terminal-manager.js
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
#!/usr/bin/env node
|
||||
|
||||
const path = require('path');
|
||||
|
||||
// Test the terminal manager directly
|
||||
async function testTerminalManager() {
|
||||
console.log('Testing Terminal Manager...\n');
|
||||
|
||||
const { TerminalManager } = require('./dist/terminal-manager.js');
|
||||
const controlDir = path.join(process.env.HOME, '.vibetunnel/control');
|
||||
|
||||
const tm = new TerminalManager(controlDir);
|
||||
|
||||
// Get a specific session
|
||||
const sessionId = process.argv[2] || '725f848c-c6d7-4bd4-8030-b83b20b1ee45';
|
||||
|
||||
console.log(`Testing session: ${sessionId}`);
|
||||
|
||||
try {
|
||||
// Get terminal
|
||||
const terminal = await tm.getTerminal(sessionId);
|
||||
console.log('Terminal created successfully');
|
||||
|
||||
// Wait for content to load
|
||||
console.log('Waiting for content to load...');
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
|
||||
// Get buffer stats
|
||||
const stats = await tm.getBufferStats(sessionId);
|
||||
console.log('\nBuffer stats:', stats);
|
||||
|
||||
// Get buffer snapshot
|
||||
const snapshot = await tm.getBufferSnapshot(sessionId, undefined, 10);
|
||||
console.log('\nBuffer snapshot:');
|
||||
console.log('- Dimensions:', `${snapshot.cols}x${snapshot.rows}`);
|
||||
console.log('- ViewportY:', snapshot.viewportY);
|
||||
console.log('- Cursor:', `(${snapshot.cursorX}, ${snapshot.cursorY})`);
|
||||
|
||||
// Check content
|
||||
if (snapshot.cells.length > 0) {
|
||||
console.log('\nFirst line content:');
|
||||
const firstLine = snapshot.cells[0];
|
||||
const text = firstLine.map(cell => cell.char).join('');
|
||||
console.log(`"${text}"`);
|
||||
|
||||
// Check for non-space content
|
||||
let hasContent = false;
|
||||
for (const row of snapshot.cells) {
|
||||
const rowText = row.map(cell => cell.char).join('').trim();
|
||||
if (rowText.length > 0) {
|
||||
hasContent = true;
|
||||
console.log(`\nFound content: "${rowText}"`);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!hasContent) {
|
||||
console.log('\n⚠️ Warning: All lines appear to be empty!');
|
||||
}
|
||||
}
|
||||
|
||||
// Close terminal
|
||||
tm.closeTerminal(sessionId);
|
||||
console.log('\nTerminal closed');
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error:', error);
|
||||
}
|
||||
}
|
||||
|
||||
testTerminalManager().catch(console.error);
|
||||
Loading…
Reference in a new issue