vibetunnel/web/src/client/components/vibe-terminal-buffer.ts
Mario Zechner 18a28992df Add comprehensive frontend component documentation
- Document all frontend components with JSDoc headers describing their purpose and events
- Add @fires and @listens tags for all component events with detailed parameter descriptions
- Update spec.md with complete Component Event Architecture section
- Add shared terminal-text-formatter.ts for consistent text formatting between client/server
- Implement event-driven activity detection replacing polling-based approach
- Add content-changed event to vibe-terminal-buffer for activity monitoring
- Remove ESC prompt detection in favor of general activity detection
- Add plain text endpoint with optional style formatting (/api/sessions/:id/text?styles)

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-06-21 23:14:30 +02:00

267 lines
7.8 KiB
TypeScript

/**
* VibeTunnel Terminal Buffer Component
*
* Displays a read-only terminal buffer snapshot with automatic resizing.
* Subscribes to buffer updates via WebSocket and renders the terminal content.
* Detects content changes and emits events when the terminal content updates.
*
* @fires content-changed - When terminal content changes (no detail)
*/
import { LitElement, html } from 'lit';
import { customElement, property, state } from 'lit/decorators.js';
import { TerminalRenderer, type BufferCell } from '../utils/terminal-renderer.js';
import { bufferSubscriptionService } from '../services/buffer-subscription-service.js';
import { cellsToText } from '../../shared/terminal-text-formatter.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 = '';
@state() private buffer: BufferSnapshot | null = null;
@state() private error: string | null = null;
@state() private displayedFontSize = 14;
@state() private visibleRows = 0;
private container: HTMLElement | null = null;
private resizeObserver: ResizeObserver | null = null;
private unsubscribe: (() => void) | null = null;
private lastTextSnapshot: string | null = null;
// Moved to render() method above
disconnectedCallback() {
this.unsubscribeFromBuffer();
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();
if (this.sessionId) {
this.subscribeToBuffer();
}
}
}
updated(changedProperties: Map<string, unknown>) {
super.updated(changedProperties);
if (changedProperties.has('sessionId')) {
this.buffer = null;
this.error = null;
this.unsubscribeFromBuffer();
if (this.sessionId) {
this.subscribeToBuffer();
}
}
// Update buffer content after any render
if (this.container && this.buffer) {
this.updateBufferContent();
}
}
private setupResize() {
if (!this.container) return;
this.resizeObserver = new ResizeObserver(() => {
this.calculateDimensions();
});
this.resizeObserver.observe(this.container);
}
private calculateDimensions() {
if (!this.container) return;
const containerWidth = this.container.clientWidth;
const containerHeight = this.container.clientHeight;
// Step 1: Calculate font size to fit horizontally
const cols = this.buffer?.cols || 80;
// Measure actual character width at 14px font size
const testElement = document.createElement('div');
testElement.className = 'terminal-line';
testElement.style.position = 'absolute';
testElement.style.visibility = 'hidden';
testElement.style.fontSize = '14px';
testElement.textContent = '0'.repeat(cols);
document.body.appendChild(testElement);
const totalWidth = testElement.getBoundingClientRect().width;
document.body.removeChild(testElement);
// Calculate the exact font size needed to fit the container width
const calculatedFontSize = (containerWidth / totalWidth) * 14;
// Don't floor - keep the decimal for exact fit
this.displayedFontSize = Math.min(32, calculatedFontSize);
// Step 2: Calculate how many lines fit vertically with this font size
const lineHeight = this.displayedFontSize * 1.2;
this.visibleRows = Math.floor(containerHeight / lineHeight);
// Always update when dimensions change
if (this.buffer) {
this.requestUpdate();
}
}
private subscribeToBuffer() {
if (!this.sessionId) return;
// Subscribe to buffer updates
this.unsubscribe = bufferSubscriptionService.subscribe(this.sessionId, (snapshot) => {
this.buffer = snapshot;
this.error = null;
// Check for content changes
this.checkForContentChange();
// Recalculate dimensions now that we have the actual cols
this.calculateDimensions();
// Request update which will trigger updated() lifecycle
this.requestUpdate();
});
}
private checkForContentChange() {
if (!this.buffer) return;
// Get current text with styles to detect any visual changes
const currentSnapshot = this.getTextWithStyles(true);
// Skip the first check
if (this.lastTextSnapshot === null) {
this.lastTextSnapshot = currentSnapshot;
return;
}
// Compare with last snapshot
if (currentSnapshot !== this.lastTextSnapshot) {
this.lastTextSnapshot = currentSnapshot;
// Dispatch content changed event
this.dispatchEvent(
new CustomEvent('content-changed', {
bubbles: true,
composed: true,
})
);
}
}
private unsubscribeFromBuffer() {
if (this.unsubscribe) {
this.unsubscribe();
this.unsubscribe = null;
}
}
connectedCallback() {
super.connectedCallback();
// Subscription happens in firstUpdated or when sessionId changes
}
render() {
const lineHeight = this.displayedFontSize * 1.2;
return html`
<style>
/* Dynamic terminal sizing for this instance */
vibe-terminal-buffer .terminal-container {
font-size: ${this.displayedFontSize}px;
line-height: ${lineHeight}px;
}
vibe-terminal-buffer .terminal-line {
height: ${lineHeight}px;
line-height: ${lineHeight}px;
}
</style>
<div
class="relative w-full h-full overflow-hidden bg-black"
style="view-transition-name: terminal-${this.sessionId}"
>
${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"
></div>
`}
</div>
`;
}
private updateBufferContent() {
if (!this.container || !this.buffer || this.visibleRows === 0) return;
const lineHeight = this.displayedFontSize * 1.2;
let html = '';
// Step 3: Show bottom N lines that fit
let startIndex = 0;
if (this.buffer.cells.length > this.visibleRows) {
// More content than visible rows - show bottom portion
startIndex = this.buffer.cells.length - this.visibleRows;
}
// Render only the visible rows
for (let i = startIndex; i < this.buffer.cells.length; i++) {
const row = this.buffer.cells[i];
// Check if cursor is on this line
const isCursorLine = i === this.buffer.cursorY;
const cursorCol = isCursorLine ? this.buffer.cursorX : -1;
const lineContent = TerminalRenderer.renderLineFromCells(row, cursorCol);
html += `<div class="terminal-line" style="height: ${lineHeight}px; line-height: ${lineHeight}px;">${lineContent}</div>`;
}
// Set innerHTML directly like terminal.ts does
this.container.innerHTML = html;
}
/**
* Public method to refresh buffer display
*/
refresh() {
if (this.buffer) {
this.requestUpdate();
}
}
/**
* Get the current buffer text with optional style markup
* Returns the text in the same format as the /api/sessions/:id/text?styles endpoint
*/
getTextWithStyles(includeStyles = true): string {
if (!this.buffer) return '';
return cellsToText(this.buffer.cells, includeStyles);
}
}