mirror of
https://github.com/samsonjs/vibetunnel.git
synced 2026-04-27 15:17:38 +00:00
Add comprehensive DOM terminal API with cursor rendering and performance tracking
- Implement RAF-based operation queue for optimal batching - Add cursor position rendering with nice green color and blinking animation - Add comprehensive public API with documentation: - Buffer methods: write(), clear(), setTerminalSize() - Scroll methods: scrollToBottom(), scrollTo(), queueCallback() - Query methods: getTerminalSize(), getVisibleRows(), getBufferSize(), getScrollPosition(), getMaxScrollPosition() - Add safety checks to prevent stale data reads when operations are pending - Add detailed performance measurement for render pipeline - Update test to use proper encapsulated API instead of accessing internals 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
06750ed405
commit
7974dca8b8
2 changed files with 253 additions and 34 deletions
|
|
@ -153,6 +153,8 @@
|
||||||
setupFitToggle();
|
setupFitToggle();
|
||||||
generateMockData();
|
generateMockData();
|
||||||
|
|
||||||
|
terminal.write("Hello world")
|
||||||
|
|
||||||
function generateMockData() {
|
function generateMockData() {
|
||||||
if (!terminal) return;
|
if (!terminal) return;
|
||||||
|
|
||||||
|
|
@ -221,12 +223,11 @@
|
||||||
content += '• API Reference: https://api.example.com/docs/v1/reference\r\n\r\n';
|
content += '• API Reference: https://api.example.com/docs/v1/reference\r\n\r\n';
|
||||||
|
|
||||||
content += 'Multi-line URL that wraps across lines:\r\n';
|
content += 'Multi-line URL that wraps across lines:\r\n';
|
||||||
content += 'Very long URL: https://example.com/api/v1/users/search?query=test&filters=active,verified&\r\n';
|
content += 'Very long URL: https://example.com/api/v1/users/search?query=test&filters=active,verified&';
|
||||||
content += 'sort=created_at&order=desc&page=1&limit=50&include=profile,settings\r\n\r\n';
|
content += 'sort=created_at&order=desc&page=1&limit=50&include=profile,settings\r\n\r\n';
|
||||||
|
|
||||||
content += 'Another long URL in middle of text:\r\n';
|
content += 'Another long URL in middle of text:\r\n';
|
||||||
content += 'Check out this amazing resource at https://very-long-domain-name.example.com/path/to/\r\n';
|
content += 'Check out this amazing resource at https://very-long-domain-name.example.com/path/to/some/deeply/nested/resource/with/query?param1=value1¶m2=value2 for more details.\r\n\r\n';
|
||||||
content += 'some/deeply/nested/resource/with/query?param1=value1¶m2=value2 for more details.\r\n\r\n';
|
|
||||||
|
|
||||||
// Separator
|
// Separator
|
||||||
content += '\x1b[90m' + '═'.repeat(100) + '\x1b[0m\r\n\r\n';
|
content += '\x1b[90m' + '═'.repeat(100) + '\x1b[0m\r\n\r\n';
|
||||||
|
|
@ -387,7 +388,8 @@
|
||||||
|
|
||||||
// System info
|
// System info
|
||||||
content += '\x1b[1;32m🖥️ System Information:\x1b[0m\r\n';
|
content += '\x1b[1;32m🖥️ System Information:\x1b[0m\r\n';
|
||||||
content += `\x1b[36mTerminal Size:\x1b[0m ${terminal.cols}x${terminal.rows} characters\r\n`;
|
const terminalSize = terminal.getTerminalSize();
|
||||||
|
content += `\x1b[36mTerminal Size:\x1b[0m ${terminalSize.cols}x${terminalSize.rows} characters\r\n`;
|
||||||
content += `\x1b[36mFont Family:\x1b[0m Fira Code, monospace\r\n`;
|
content += `\x1b[36mFont Family:\x1b[0m Fira Code, monospace\r\n`;
|
||||||
content += `\x1b[36mFeatures:\x1b[0m Native text selection, smooth scrolling, touch support\r\n`;
|
content += `\x1b[36mFeatures:\x1b[0m Native text selection, smooth scrolling, touch support\r\n`;
|
||||||
content += `\x1b[36mRendering:\x1b[0m DOM-based with virtual scrolling\r\n\r\n`;
|
content += `\x1b[36mRendering:\x1b[0m DOM-based with virtual scrolling\r\n\r\n`;
|
||||||
|
|
@ -414,16 +416,14 @@
|
||||||
content += '\x1b[37m│ \x1b[32m>\x1b[0m \x1b[37m│\r\n';
|
content += '\x1b[37m│ \x1b[32m>\x1b[0m \x1b[37m│\r\n';
|
||||||
content += '\x1b[37m╰────────────────────────────────────────────────────────────────────────────────────────────────────────────╯\x1b[0m\r\n';
|
content += '\x1b[37m╰────────────────────────────────────────────────────────────────────────────────────────────────────────────╯\x1b[0m\r\n';
|
||||||
|
|
||||||
terminal.write(content);
|
// Only write first 10 lines
|
||||||
|
const lines = content.split('\r\n');
|
||||||
|
const firstTenLines = lines.slice(0, 50).join('\r\n');
|
||||||
|
terminal.write(firstTenLines);
|
||||||
|
|
||||||
// Scroll to bottom after content is written
|
// Scroll to bottom after content is written
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
if (terminal.terminal && terminal.terminal.buffer) {
|
terminal.scrollToBottom();
|
||||||
const buffer = terminal.terminal.buffer.active;
|
|
||||||
const maxScroll = Math.max(0, buffer.length - terminal.actualRows);
|
|
||||||
terminal.viewportY = maxScroll;
|
|
||||||
terminal.renderBuffer();
|
|
||||||
}
|
|
||||||
}, 100);
|
}, 100);
|
||||||
|
|
||||||
console.log('Mock data generation completed!');
|
console.log('Mock data generation completed!');
|
||||||
|
|
@ -445,8 +445,8 @@
|
||||||
buttons.forEach(b => b.classList.remove('active'));
|
buttons.forEach(b => b.classList.remove('active'));
|
||||||
button.classList.add('active');
|
button.classList.add('active');
|
||||||
|
|
||||||
// Change viewport size
|
// Change terminal size
|
||||||
terminal.setViewportSize(cols, rows);
|
terminal.setTerminalSize(cols, rows);
|
||||||
|
|
||||||
console.log(`Terminal viewport changed to ${cols}x${rows} - watch the content reflow!`);
|
console.log(`Terminal viewport changed to ${cols}x${rows} - watch the content reflow!`);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,40 @@ export class Terminal extends LitElement {
|
||||||
private touchScrollAccumulator = 0;
|
private touchScrollAccumulator = 0;
|
||||||
private isTouchActive = false;
|
private isTouchActive = false;
|
||||||
|
|
||||||
|
// Operation queue for batching buffer modifications
|
||||||
|
private operationQueue: (() => void)[] = [];
|
||||||
|
|
||||||
|
private queueOperation(operation: () => void) {
|
||||||
|
this.operationQueue.push(operation);
|
||||||
|
|
||||||
|
if (!this.renderPending) {
|
||||||
|
this.renderPending = true;
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
this.processOperationQueue();
|
||||||
|
this.renderPending = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private processOperationQueue() {
|
||||||
|
const queueStart = performance.now();
|
||||||
|
const operationCount = this.operationQueue.length;
|
||||||
|
|
||||||
|
// Process all queued operations in order
|
||||||
|
while (this.operationQueue.length > 0) {
|
||||||
|
const operation = this.operationQueue.shift()!;
|
||||||
|
operation();
|
||||||
|
}
|
||||||
|
|
||||||
|
const queueEnd = performance.now();
|
||||||
|
console.log(
|
||||||
|
`Processed ${operationCount} operations in ${(queueEnd - queueStart).toFixed(2)}ms`
|
||||||
|
);
|
||||||
|
|
||||||
|
// Render once after all operations are complete
|
||||||
|
this.renderBuffer();
|
||||||
|
}
|
||||||
|
|
||||||
connectedCallback() {
|
connectedCallback() {
|
||||||
super.connectedCallback();
|
super.connectedCallback();
|
||||||
}
|
}
|
||||||
|
|
@ -411,14 +445,22 @@ export class Terminal extends LitElement {
|
||||||
private renderBuffer() {
|
private renderBuffer() {
|
||||||
if (!this.terminal || !this.container) return;
|
if (!this.terminal || !this.container) return;
|
||||||
|
|
||||||
|
const renderStart = performance.now();
|
||||||
|
|
||||||
const buffer = this.terminal.buffer.active;
|
const buffer = this.terminal.buffer.active;
|
||||||
const bufferLength = buffer.length;
|
const bufferLength = buffer.length;
|
||||||
const startRow = Math.min(this.viewportY, Math.max(0, bufferLength - this.actualRows));
|
const startRow = Math.min(this.viewportY, Math.max(0, bufferLength - this.actualRows));
|
||||||
|
|
||||||
|
const bufferPrepStart = performance.now();
|
||||||
|
|
||||||
// Build complete innerHTML string
|
// Build complete innerHTML string
|
||||||
let html = '';
|
let html = '';
|
||||||
const cell = buffer.getNullCell();
|
const cell = buffer.getNullCell();
|
||||||
|
|
||||||
|
// Get cursor position
|
||||||
|
const cursorX = this.terminal.buffer.active.cursorX;
|
||||||
|
const cursorY = this.terminal.buffer.active.cursorY;
|
||||||
|
|
||||||
for (let i = 0; i < this.actualRows; i++) {
|
for (let i = 0; i < this.actualRows; i++) {
|
||||||
const row = startRow + i;
|
const row = startRow + i;
|
||||||
|
|
||||||
|
|
@ -433,18 +475,38 @@ export class Terminal extends LitElement {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const lineContent = this.renderLine(line, cell);
|
// Check if cursor is on this line (relative to viewport)
|
||||||
|
const isCursorLine = row === cursorY;
|
||||||
|
const lineContent = this.renderLine(line, cell, isCursorLine ? cursorX : -1);
|
||||||
html += `<div class="terminal-line">${lineContent || ''}</div>`;
|
html += `<div class="terminal-line">${lineContent || ''}</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const bufferPrepEnd = performance.now();
|
||||||
|
const domUpdateStart = performance.now();
|
||||||
|
|
||||||
// Set the complete innerHTML at once
|
// Set the complete innerHTML at once
|
||||||
this.container.innerHTML = html;
|
this.container.innerHTML = html;
|
||||||
|
|
||||||
|
const domUpdateEnd = performance.now();
|
||||||
|
const linkProcessStart = performance.now();
|
||||||
|
|
||||||
// Process links after rendering
|
// Process links after rendering
|
||||||
this.processLinks();
|
this.processLinks();
|
||||||
|
|
||||||
|
const linkProcessEnd = performance.now();
|
||||||
|
const renderEnd = performance.now();
|
||||||
|
|
||||||
|
const totalTime = renderEnd - renderStart;
|
||||||
|
const bufferPrepTime = bufferPrepEnd - bufferPrepStart;
|
||||||
|
const domUpdateTime = domUpdateEnd - domUpdateStart;
|
||||||
|
const linkProcessTime = linkProcessEnd - linkProcessStart;
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`Render performance: ${totalTime.toFixed(2)}ms total (buffer: ${bufferPrepTime.toFixed(2)}ms, DOM: ${domUpdateTime.toFixed(2)}ms, links: ${linkProcessTime.toFixed(2)}ms) - ${this.actualRows} rows`
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private renderLine(line: IBufferLine, cell: IBufferCell): string {
|
private renderLine(line: IBufferLine, cell: IBufferCell, cursorCol: number = -1): string {
|
||||||
let html = '';
|
let html = '';
|
||||||
let currentChars = '';
|
let currentChars = '';
|
||||||
let currentClasses = '';
|
let currentClasses = '';
|
||||||
|
|
@ -473,6 +535,12 @@ export class Terminal extends LitElement {
|
||||||
let classes = 'terminal-char';
|
let classes = 'terminal-char';
|
||||||
let style = '';
|
let style = '';
|
||||||
|
|
||||||
|
// Check if this is the cursor position
|
||||||
|
const isCursor = col === cursorCol;
|
||||||
|
if (isCursor) {
|
||||||
|
classes += ' cursor';
|
||||||
|
}
|
||||||
|
|
||||||
// Get foreground color
|
// Get foreground color
|
||||||
const fg = cell.getFgColor();
|
const fg = cell.getFgColor();
|
||||||
if (fg !== undefined && typeof fg === 'number' && fg >= 0) {
|
if (fg !== undefined && typeof fg === 'number' && fg >= 0) {
|
||||||
|
|
@ -485,6 +553,11 @@ export class Terminal extends LitElement {
|
||||||
style += `background-color: var(--terminal-color-${bg});`;
|
style += `background-color: var(--terminal-color-${bg});`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Override background for cursor
|
||||||
|
if (isCursor) {
|
||||||
|
style += `background-color: #23d18b;`;
|
||||||
|
}
|
||||||
|
|
||||||
// Get text attributes/flags
|
// Get text attributes/flags
|
||||||
const isBold = cell.isBold();
|
const isBold = cell.isBold();
|
||||||
const isItalic = cell.isItalic();
|
const isItalic = cell.isItalic();
|
||||||
|
|
@ -513,34 +586,165 @@ export class Terminal extends LitElement {
|
||||||
return html;
|
return html;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Public API methods
|
/**
|
||||||
|
* DOM Terminal Public API
|
||||||
|
*
|
||||||
|
* This component provides a DOM-based terminal renderer with XTerm.js backend.
|
||||||
|
* All buffer-modifying operations are queued and executed in requestAnimationFrame
|
||||||
|
* to ensure optimal batching and rendering performance.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// === BUFFER MODIFICATION METHODS (Queued) ===
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Write data to the terminal buffer.
|
||||||
|
* @param data - String data to write (supports ANSI escape sequences)
|
||||||
|
*/
|
||||||
public write(data: string) {
|
public write(data: string) {
|
||||||
if (this.terminal) {
|
if (!this.terminal) return;
|
||||||
this.terminal.write(data, () => {
|
|
||||||
this.renderBuffer();
|
this.queueOperation(() => {
|
||||||
});
|
const writeStart = performance.now();
|
||||||
}
|
this.terminal!.write(data);
|
||||||
|
const writeEnd = performance.now();
|
||||||
|
console.log(
|
||||||
|
`XTerm write took: ${(writeEnd - writeStart).toFixed(2)}ms for ${data.length} chars`
|
||||||
|
);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear the terminal buffer and reset scroll position.
|
||||||
|
*/
|
||||||
public clear() {
|
public clear() {
|
||||||
if (this.terminal) {
|
if (!this.terminal) return;
|
||||||
this.terminal.clear();
|
|
||||||
|
this.queueOperation(() => {
|
||||||
|
this.terminal!.clear();
|
||||||
this.viewportY = 0;
|
this.viewportY = 0;
|
||||||
this.renderBuffer();
|
});
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public setViewportSize(cols: number, rows: number) {
|
/**
|
||||||
|
* Resize the terminal to specified dimensions.
|
||||||
|
* @param cols - Number of columns
|
||||||
|
* @param rows - Number of rows
|
||||||
|
*/
|
||||||
|
public setTerminalSize(cols: number, rows: number) {
|
||||||
this.cols = cols;
|
this.cols = cols;
|
||||||
this.rows = rows;
|
this.rows = rows;
|
||||||
|
|
||||||
if (this.terminal) {
|
if (!this.terminal) return;
|
||||||
this.terminal.resize(cols, rows);
|
|
||||||
this.fitTerminal();
|
|
||||||
this.renderBuffer();
|
|
||||||
}
|
|
||||||
|
|
||||||
this.requestUpdate();
|
this.queueOperation(() => {
|
||||||
|
this.terminal!.resize(cols, rows);
|
||||||
|
this.fitTerminal();
|
||||||
|
this.requestUpdate();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// === SCROLL CONTROL METHODS (Queued) ===
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scroll to the bottom of the buffer.
|
||||||
|
*/
|
||||||
|
public scrollToBottom() {
|
||||||
|
if (!this.terminal) return;
|
||||||
|
|
||||||
|
this.queueOperation(() => {
|
||||||
|
const buffer = this.terminal!.buffer.active;
|
||||||
|
const maxScroll = Math.max(0, buffer.length - this.actualRows);
|
||||||
|
this.viewportY = maxScroll;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scroll to a specific position in the buffer.
|
||||||
|
* @param position - Line position (0 = top, max = bottom)
|
||||||
|
*/
|
||||||
|
public scrollTo(position: number) {
|
||||||
|
if (!this.terminal) return;
|
||||||
|
|
||||||
|
this.queueOperation(() => {
|
||||||
|
const buffer = this.terminal!.buffer.active;
|
||||||
|
const maxScroll = Math.max(0, buffer.length - this.actualRows);
|
||||||
|
this.viewportY = Math.max(0, Math.min(maxScroll, position));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Queue a custom operation to be executed after the next render is complete.
|
||||||
|
* Useful for actions that need to happen after terminal state is fully updated.
|
||||||
|
* @param callback - Function to execute after render
|
||||||
|
*/
|
||||||
|
public queueCallback(callback: () => void) {
|
||||||
|
this.queueOperation(callback);
|
||||||
|
}
|
||||||
|
|
||||||
|
// === QUERY METHODS (Immediate) ===
|
||||||
|
|
||||||
|
private checkPendingOperations() {
|
||||||
|
if (this.operationQueue.length > 0) {
|
||||||
|
throw new Error(
|
||||||
|
`Cannot read terminal state: ${this.operationQueue.length} operations pending in RAF queue. Data may be stale.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get terminal dimensions.
|
||||||
|
* @returns Object with cols and rows
|
||||||
|
* @throws Error if operations are pending in RAF queue
|
||||||
|
*/
|
||||||
|
public getTerminalSize(): { cols: number; rows: number } {
|
||||||
|
this.checkPendingOperations();
|
||||||
|
return {
|
||||||
|
cols: this.cols,
|
||||||
|
rows: this.rows,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get number of visible rows in the current viewport.
|
||||||
|
* @returns Number of rows that fit in the viewport
|
||||||
|
* @throws Error if operations are pending in RAF queue
|
||||||
|
*/
|
||||||
|
public getVisibleRows(): number {
|
||||||
|
this.checkPendingOperations();
|
||||||
|
return this.actualRows;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get total number of lines in the scrollback buffer.
|
||||||
|
* @returns Total lines in buffer
|
||||||
|
* @throws Error if operations are pending in RAF queue
|
||||||
|
*/
|
||||||
|
public getBufferSize(): number {
|
||||||
|
this.checkPendingOperations();
|
||||||
|
if (!this.terminal) return 0;
|
||||||
|
return this.terminal.buffer.active.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get current scroll position.
|
||||||
|
* @returns Current scroll position (0 = top)
|
||||||
|
* @throws Error if operations are pending in RAF queue
|
||||||
|
*/
|
||||||
|
public getScrollPosition(): number {
|
||||||
|
this.checkPendingOperations();
|
||||||
|
return this.viewportY;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get maximum possible scroll position.
|
||||||
|
* @returns Maximum scroll position
|
||||||
|
* @throws Error if operations are pending in RAF queue
|
||||||
|
*/
|
||||||
|
public getMaxScrollPosition(): number {
|
||||||
|
this.checkPendingOperations();
|
||||||
|
if (!this.terminal) return 0;
|
||||||
|
const buffer = this.terminal.buffer.active;
|
||||||
|
return Math.max(0, buffer.length - this.actualRows);
|
||||||
}
|
}
|
||||||
|
|
||||||
private processLinks() {
|
private processLinks() {
|
||||||
|
|
@ -828,6 +1032,21 @@ export class Terminal extends LitElement {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.terminal-char.cursor {
|
||||||
|
animation: cursor-blink 1s infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes cursor-blink {
|
||||||
|
0%,
|
||||||
|
50% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
51%,
|
||||||
|
100% {
|
||||||
|
opacity: 0.3;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.terminal-link {
|
.terminal-link {
|
||||||
color: #4fc3f7;
|
color: #4fc3f7;
|
||||||
text-decoration: underline;
|
text-decoration: underline;
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue