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 { customElement, property, state } from 'lit/decorators.js';
|
||||||
import './terminal.js';
|
import './vibe-terminal-buffer.js';
|
||||||
import type { Terminal } from './terminal.js';
|
|
||||||
import { CastConverter } from '../utils/cast-converter.js';
|
|
||||||
|
|
||||||
export interface Session {
|
export interface Session {
|
||||||
id: string;
|
id: string;
|
||||||
|
|
@ -27,91 +25,16 @@ export class SessionCard extends LitElement {
|
||||||
}
|
}
|
||||||
|
|
||||||
@property({ type: Object }) session!: Session;
|
@property({ type: Object }) session!: Session;
|
||||||
@state() private terminal: Terminal | null = null;
|
|
||||||
@state() private killing = false;
|
@state() private killing = false;
|
||||||
@state() private killingFrame = 0;
|
@state() private killingFrame = 0;
|
||||||
|
|
||||||
private killingInterval: number | null = null;
|
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() {
|
disconnectedCallback() {
|
||||||
super.disconnectedCallback();
|
super.disconnectedCallback();
|
||||||
if (this.killingInterval) {
|
if (this.killingInterval) {
|
||||||
clearInterval(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() {
|
private handleCardClick() {
|
||||||
|
|
@ -284,15 +207,14 @@ export class SessionCard extends LitElement {
|
||||||
</div>
|
</div>
|
||||||
`
|
`
|
||||||
: html`
|
: html`
|
||||||
<vibe-terminal
|
<vibe-terminal-buffer
|
||||||
.sessionId=${this.session.id}
|
.sessionId=${this.session.id}
|
||||||
.cols=${80}
|
|
||||||
.rows=${24}
|
|
||||||
.fontSize=${10}
|
.fontSize=${10}
|
||||||
.fitHorizontally=${true}
|
.fitHorizontally=${true}
|
||||||
|
.pollInterval=${1000}
|
||||||
class="w-full h-full"
|
class="w-full h-full"
|
||||||
style="pointer-events: none;"
|
style="pointer-events: none;"
|
||||||
></vibe-terminal>
|
></vibe-terminal-buffer>
|
||||||
`}
|
`}
|
||||||
</div>
|
</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;
|
-moz-osx-font-smoothing: grayscale;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Terminal line styling */
|
||||||
|
.terminal-line {
|
||||||
|
white-space: pre;
|
||||||
|
overflow: hidden;
|
||||||
|
color: #d4d4d4;
|
||||||
|
}
|
||||||
|
|
||||||
/* Terminal character specific styling */
|
/* Terminal character specific styling */
|
||||||
.terminal-char {
|
.terminal-char {
|
||||||
font-variant-ligatures: none;
|
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) => {
|
app.get('/api/sessions/:sessionId/buffer', async (req, res) => {
|
||||||
const sessionId = req.params.sessionId;
|
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 lines = parseInt(req.query.lines as string) || 24;
|
||||||
|
const format = (req.query.format as string) || 'binary';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Validate session exists
|
// Validate session exists
|
||||||
|
|
@ -412,15 +444,21 @@ app.get('/api/sessions/:sessionId/buffer', async (req, res) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get buffer snapshot
|
// Get buffer snapshot
|
||||||
|
// If viewportY is not specified, get lines from bottom
|
||||||
const snapshot = await terminalManager.getBufferSnapshot(sessionId, viewportY, lines);
|
const snapshot = await terminalManager.getBufferSnapshot(sessionId, viewportY, lines);
|
||||||
|
|
||||||
// Encode to binary format
|
if (format === 'json') {
|
||||||
const binaryData = terminalManager.encodeSnapshot(snapshot);
|
// Send JSON response
|
||||||
|
res.json(snapshot);
|
||||||
|
} else {
|
||||||
|
// Encode to binary format
|
||||||
|
const binaryData = terminalManager.encodeSnapshot(snapshot);
|
||||||
|
|
||||||
// Send binary response
|
// Send binary response
|
||||||
res.setHeader('Content-Type', 'application/octet-stream');
|
res.setHeader('Content-Type', 'application/octet-stream');
|
||||||
res.setHeader('Content-Length', binaryData.length.toString());
|
res.setHeader('Content-Length', binaryData.length.toString());
|
||||||
res.send(binaryData);
|
res.send(binaryData);
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error getting session buffer:', error);
|
console.error('Error getting session buffer:', error);
|
||||||
res.status(500).json({ error: 'Failed to get session buffer' });
|
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 fs from 'fs';
|
||||||
import * as path from 'path';
|
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
|
* Get buffer snapshot for a session
|
||||||
*/
|
*/
|
||||||
async getBufferSnapshot(
|
async getBufferSnapshot(
|
||||||
sessionId: string,
|
sessionId: string,
|
||||||
viewportY: number,
|
viewportY: number | undefined,
|
||||||
lines: number
|
lines: number
|
||||||
): Promise<BufferSnapshot> {
|
): Promise<BufferSnapshot> {
|
||||||
const terminal = await this.getTerminal(sessionId);
|
const terminal = await this.getTerminal(sessionId);
|
||||||
const buffer = terminal.buffer.active;
|
const buffer = terminal.buffer.active;
|
||||||
|
|
||||||
// Calculate actual viewport bounds
|
let startLine: number;
|
||||||
const startLine = Math.max(0, viewportY);
|
let actualViewportY: number;
|
||||||
const endLine = Math.min(buffer.length, viewportY + lines);
|
|
||||||
|
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;
|
const actualLines = endLine - startLine;
|
||||||
|
|
||||||
// Get cursor position
|
// Get cursor position relative to our viewport
|
||||||
const cursorX = buffer.cursorX;
|
const cursorX = buffer.cursorX;
|
||||||
const cursorY = buffer.cursorY + buffer.viewportY - viewportY;
|
const cursorY = buffer.cursorY + buffer.viewportY - actualViewportY;
|
||||||
|
|
||||||
// Extract cells
|
// Extract cells
|
||||||
const cells: BufferCell[][] = [];
|
const cells: BufferCell[][] = [];
|
||||||
const cell: IBufferCell = {} as IBufferCell;
|
const cell = buffer.getNullCell();
|
||||||
|
|
||||||
for (let row = 0; row < actualLines; row++) {
|
for (let row = 0; row < actualLines; row++) {
|
||||||
const line = buffer.getLine(startLine + row);
|
const line = buffer.getLine(startLine + row);
|
||||||
|
|
@ -238,8 +267,9 @@ export class TerminalManager {
|
||||||
const fg = cell.getFgColor();
|
const fg = cell.getFgColor();
|
||||||
const bg = cell.getBgColor();
|
const bg = cell.getBgColor();
|
||||||
|
|
||||||
if (fg !== undefined) bufferCell.fg = fg;
|
// Handle color values - -1 means default color
|
||||||
if (bg !== undefined) bufferCell.bg = bg;
|
if (fg !== undefined && fg !== -1) bufferCell.fg = fg;
|
||||||
|
if (bg !== undefined && bg !== -1) bufferCell.bg = bg;
|
||||||
if (attributes !== 0) bufferCell.attributes = attributes;
|
if (attributes !== 0) bufferCell.attributes = attributes;
|
||||||
|
|
||||||
rowCells.push(bufferCell);
|
rowCells.push(bufferCell);
|
||||||
|
|
@ -257,7 +287,7 @@ export class TerminalManager {
|
||||||
return {
|
return {
|
||||||
cols: terminal.cols,
|
cols: terminal.cols,
|
||||||
rows: actualLines,
|
rows: actualLines,
|
||||||
viewportY,
|
viewportY: actualViewportY,
|
||||||
cursorX,
|
cursorX,
|
||||||
cursorY,
|
cursorY,
|
||||||
cells,
|
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