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:
Mario Zechner 2025-06-20 02:45:13 +02:00
parent aa0658acc7
commit 4a9ee48427
6 changed files with 80 additions and 320 deletions

View file

@ -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>
`;
});
}
}
/**

View file

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

View file

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

View file

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

View file

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

View file

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