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:
Mario Zechner 2025-06-20 02:19:09 +02:00
parent 00602e3218
commit b041743287
12 changed files with 1581 additions and 102 deletions

281
benchmark-streaming.js Executable file
View 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
View 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
View 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);

View file

@ -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>

View 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();
}
}

View 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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
/**
* 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,
};
}
}
}

View file

@ -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;

View file

@ -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' });

View file

@ -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
View 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
View 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();

View 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);