mirror of
https://github.com/samsonjs/vibetunnel.git
synced 2026-04-03 10:55:54 +00:00
feat: implement fitHorizontally mode and buffer trimming
- Add fitHorizontally mode to vibe-terminal-buffer component - Scale font size to fit entire terminal width when enabled - Trim blank lines from bottom of buffer to reduce data transfer - Always show content from top down (not centered on cursor) - Match behavior of terminal.ts fitTerminal implementation 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
aa0658acc7
commit
4a9ee48427
6 changed files with 80 additions and 320 deletions
|
|
@ -1,5 +1,6 @@
|
|||
import { LitElement, html } from 'lit';
|
||||
import { customElement, property, state } from 'lit/decorators.js';
|
||||
import { unsafeHTML } from 'lit/directives/unsafe-html.js';
|
||||
import { TerminalRenderer, type BufferCell } from '../utils/terminal-renderer.js';
|
||||
|
||||
interface BufferSnapshot {
|
||||
|
|
@ -28,6 +29,7 @@ export class VibeTerminalBuffer extends LitElement {
|
|||
@state() private loading = false;
|
||||
@state() private actualRows = 0;
|
||||
@state() private displayedFontSize = 14;
|
||||
@state() private containerCols = 80; // Calculated columns that fit
|
||||
|
||||
private container: HTMLElement | null = null;
|
||||
private pollTimer: NodeJS.Timeout | null = null;
|
||||
|
|
@ -86,29 +88,38 @@ export class VibeTerminalBuffer extends LitElement {
|
|||
private calculateDimensions() {
|
||||
if (!this.container) return;
|
||||
|
||||
const containerWidth = this.container.clientWidth;
|
||||
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;
|
||||
// Horizontal fitting: calculate fontSize to fit buffer.cols characters in container width
|
||||
const targetCharWidth = containerWidth / this.buffer.cols;
|
||||
|
||||
if (requiredWidth > containerWidth) {
|
||||
const scale = containerWidth / requiredWidth;
|
||||
this.displayedFontSize = Math.floor(this.fontSize * scale);
|
||||
} else {
|
||||
this.displayedFontSize = this.fontSize;
|
||||
// Estimate font size needed (assuming monospace font with ~0.6 char/font ratio)
|
||||
const calculatedFontSize = targetCharWidth / 0.6;
|
||||
this.displayedFontSize = Math.max(4, Math.min(32, Math.floor(calculatedFontSize)));
|
||||
|
||||
// Calculate actual rows with new font size
|
||||
const lineHeight = this.displayedFontSize * 1.2;
|
||||
const newActualRows = Math.max(1, Math.floor(containerHeight / lineHeight));
|
||||
|
||||
if (newActualRows !== this.actualRows) {
|
||||
this.actualRows = newActualRows;
|
||||
this.fetchBuffer();
|
||||
}
|
||||
} else {
|
||||
// Normal mode: use original font size and calculate cols that fit
|
||||
this.displayedFontSize = this.fontSize;
|
||||
}
|
||||
const lineHeight = this.fontSize * 1.2;
|
||||
const charWidth = this.fontSize * 0.6;
|
||||
|
||||
if (newActualRows !== this.actualRows) {
|
||||
this.actualRows = newActualRows;
|
||||
this.fetchBuffer();
|
||||
const newActualRows = Math.max(1, Math.floor(containerHeight / lineHeight));
|
||||
this.containerCols = Math.max(20, Math.floor(containerWidth / charWidth));
|
||||
|
||||
if (newActualRows !== this.actualRows) {
|
||||
this.actualRows = newActualRows;
|
||||
this.fetchBuffer();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -148,12 +159,9 @@ export class VibeTerminalBuffer extends LitElement {
|
|||
return; // No changes
|
||||
}
|
||||
|
||||
// Fetch buffer data - request at least one full terminal screen worth of lines
|
||||
// This ensures we get the complete visible terminal state
|
||||
const linesToFetch = Math.max(this.actualRows, stats.rows);
|
||||
const lines = Math.min(linesToFetch, stats.totalRows);
|
||||
// Always fetch the entire buffer to show all content
|
||||
const response = await fetch(
|
||||
`/api/sessions/${this.sessionId}/buffer?lines=${lines}&format=json`
|
||||
`/api/sessions/${this.sessionId}/buffer?viewportY=0&lines=${stats.totalRows}&format=json`
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
|
|
@ -164,14 +172,6 @@ export class VibeTerminalBuffer extends LitElement {
|
|||
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);
|
||||
|
|
@ -210,24 +210,37 @@ export class VibeTerminalBuffer extends LitElement {
|
|||
|
||||
const lineHeight = this.displayedFontSize * 1.2;
|
||||
|
||||
// Render lines - if we have more lines than can fit, show the bottom portion
|
||||
const startIndex = Math.max(0, this.buffer.cells.length - this.actualRows);
|
||||
const visibleCells = this.buffer.cells.slice(startIndex);
|
||||
// In fitHorizontally mode, we show all content scaled to fit
|
||||
// Otherwise, we show from the top and let it overflow
|
||||
if (this.fitHorizontally) {
|
||||
// Render all lines - the buffer is already trimmed of blank lines from the bottom
|
||||
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 visibleCells.map((row, index) => {
|
||||
const actualIndex = startIndex + index;
|
||||
const isCursorLine = actualIndex === 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;">
|
||||
${unsafeHTML(lineContent)}
|
||||
</div>
|
||||
`;
|
||||
});
|
||||
} else {
|
||||
// Show only what fits in the viewport
|
||||
const visibleCells = this.buffer.cells.slice(0, this.actualRows);
|
||||
|
||||
return html`
|
||||
<div
|
||||
class="terminal-line"
|
||||
style="height: ${lineHeight}px; line-height: ${lineHeight}px;"
|
||||
.innerHTML=${lineContent}
|
||||
></div>
|
||||
`;
|
||||
});
|
||||
return visibleCells.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;">
|
||||
${unsafeHTML(lineContent)}
|
||||
</div>
|
||||
`;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -214,6 +214,9 @@ export class TerminalRenderer {
|
|||
const b = cell.fg & 0xff;
|
||||
style += `color: rgb(${r}, ${g}, ${b});`;
|
||||
}
|
||||
} else {
|
||||
// Default foreground color if not specified
|
||||
style += `color: #d4d4d4;`;
|
||||
}
|
||||
|
||||
// Get background color
|
||||
|
|
|
|||
|
|
@ -284,13 +284,31 @@ export class TerminalManager {
|
|||
cells.push(rowCells);
|
||||
}
|
||||
|
||||
// Trim blank lines from the bottom
|
||||
let lastNonBlankRow = cells.length - 1;
|
||||
while (lastNonBlankRow >= 0) {
|
||||
const row = cells[lastNonBlankRow];
|
||||
const hasContent = row.some(
|
||||
(cell) =>
|
||||
cell.char !== ' ' ||
|
||||
cell.fg !== undefined ||
|
||||
cell.bg !== undefined ||
|
||||
cell.attributes !== undefined
|
||||
);
|
||||
if (hasContent) break;
|
||||
lastNonBlankRow--;
|
||||
}
|
||||
|
||||
// Keep at least one row
|
||||
const trimmedCells = cells.slice(0, Math.max(1, lastNonBlankRow + 1));
|
||||
|
||||
return {
|
||||
cols: terminal.cols,
|
||||
rows: actualLines,
|
||||
rows: trimmedCells.length,
|
||||
viewportY: actualViewportY,
|
||||
cursorX,
|
||||
cursorY,
|
||||
cells,
|
||||
cells: trimmedCells,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,109 +0,0 @@
|
|||
#!/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);
|
||||
}
|
||||
|
|
@ -1,94 +0,0 @@
|
|||
#!/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();
|
||||
|
|
@ -1,71 +0,0 @@
|
|||
#!/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