mirror of
https://github.com/samsonjs/vibetunnel.git
synced 2026-06-29 05:39:31 +00:00
Implement optimized binary format for terminal buffer transmission
- Switch from JSON to binary format for buffer data transfer - Optimize encoding with run-length encoding for empty rows - Reduce data size with efficient cell encoding (1 byte for spaces, variable for complex cells) - Support both palette and RGB colors with minimal overhead - Pre-calculate exact buffer sizes to avoid allocations 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
35755d8376
commit
deb3935172
3 changed files with 283 additions and 176 deletions
|
|
@ -150,15 +150,15 @@ export class VibeTerminalBuffer extends LitElement {
|
|||
|
||||
// Fetch buffer data - request enough lines for display
|
||||
const lines = Math.max(this.actualRows, stats.rows);
|
||||
const response = await fetch(
|
||||
`/api/sessions/${this.sessionId}/buffer?lines=${lines}&format=json`
|
||||
);
|
||||
const response = await fetch(`/api/sessions/${this.sessionId}/buffer?lines=${lines}`);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch buffer: ${response.statusText}`);
|
||||
}
|
||||
|
||||
this.buffer = await response.json();
|
||||
// Decode binary buffer
|
||||
const arrayBuffer = await response.arrayBuffer();
|
||||
this.buffer = TerminalRenderer.decodeBinaryBuffer(arrayBuffer);
|
||||
this.lastModified = stats.lastModified;
|
||||
this.error = null;
|
||||
|
||||
|
|
|
|||
|
|
@ -295,7 +295,7 @@ export class TerminalRenderer {
|
|||
}
|
||||
|
||||
const version = view.getUint8(offset++);
|
||||
if (version !== 0x02) {
|
||||
if (version !== 0x01) {
|
||||
throw new Error(`Unsupported buffer version: ${version}`);
|
||||
}
|
||||
|
||||
|
|
@ -316,50 +316,27 @@ export class TerminalRenderer {
|
|||
const cells: BufferCell[][] = [];
|
||||
const uint8 = new Uint8Array(buffer);
|
||||
|
||||
for (let row = 0; row < rows; row++) {
|
||||
const rowCells: BufferCell[] = [];
|
||||
// Optimized format
|
||||
while (offset < uint8.length) {
|
||||
const marker = uint8[offset++];
|
||||
|
||||
for (let col = 0; col < cols; ) {
|
||||
if (offset >= uint8.length) break;
|
||||
if (marker === 0xfe) {
|
||||
// Empty row(s)
|
||||
const count = uint8[offset++];
|
||||
for (let i = 0; i < count; i++) {
|
||||
cells.push([{ char: ' ', width: 1 }]);
|
||||
}
|
||||
} else if (marker === 0xfd) {
|
||||
// Row with content
|
||||
const cellCount = view.getUint16(offset, true);
|
||||
offset += 2;
|
||||
|
||||
// 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 rowCells: BufferCell[] = [];
|
||||
for (let i = 0; i < cellCount; i++) {
|
||||
const result = this.decodeCell(uint8, offset);
|
||||
offset = result.offset;
|
||||
rowCells.push(result.cell);
|
||||
col++;
|
||||
}
|
||||
}
|
||||
|
||||
if (rowCells.length > 0) {
|
||||
cells.push(rowCells);
|
||||
}
|
||||
}
|
||||
|
|
@ -371,60 +348,78 @@ export class TerminalRenderer {
|
|||
uint8: Uint8Array,
|
||||
offset: number
|
||||
): { cell: BufferCell; offset: number } {
|
||||
const firstByte = uint8[offset];
|
||||
const typeByte = 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);
|
||||
// Type byte format:
|
||||
// Bit 7: Has extended data (attrs/colors)
|
||||
// Bit 6: Is Unicode (vs ASCII)
|
||||
// Bit 5: Has foreground color
|
||||
// Bit 4: Has background color
|
||||
// Bit 3: Is RGB foreground (vs palette)
|
||||
// Bit 2: Is RGB background (vs palette)
|
||||
// Bits 1-0: Character type (00=space, 01=ASCII, 10=Unicode)
|
||||
|
||||
// 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++];
|
||||
}
|
||||
const hasExtended = !!(typeByte & 0x80);
|
||||
const isUnicode = !!(typeByte & 0x40);
|
||||
const hasFg = !!(typeByte & 0x20);
|
||||
const hasBg = !!(typeByte & 0x10);
|
||||
const isRgbFg = !!(typeByte & 0x08);
|
||||
const isRgbBg = !!(typeByte & 0x04);
|
||||
const charType = typeByte & 0x03;
|
||||
|
||||
// Simple space
|
||||
if (typeByte === 0x00) {
|
||||
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,
|
||||
},
|
||||
cell: { char: ' ', width: 1 },
|
||||
offset,
|
||||
};
|
||||
}
|
||||
|
||||
// Read character
|
||||
let char: string;
|
||||
if (charType === 0x00) {
|
||||
char = ' ';
|
||||
} else if (isUnicode) {
|
||||
const charLen = uint8[offset++];
|
||||
const charBytes = uint8.slice(offset, offset + charLen);
|
||||
char = new TextDecoder().decode(charBytes);
|
||||
offset += charLen;
|
||||
} else {
|
||||
char = String.fromCharCode(uint8[offset++]);
|
||||
}
|
||||
|
||||
// Default cell
|
||||
const cell: BufferCell = { char, width: 1 };
|
||||
|
||||
// Read extended data if present
|
||||
if (hasExtended) {
|
||||
// Attributes
|
||||
const attributes = uint8[offset++];
|
||||
if (attributes !== 0) {
|
||||
cell.attributes = attributes;
|
||||
}
|
||||
|
||||
// Foreground color
|
||||
if (hasFg) {
|
||||
if (isRgbFg) {
|
||||
cell.fg = (uint8[offset] << 16) | (uint8[offset + 1] << 8) | uint8[offset + 2];
|
||||
offset += 3;
|
||||
} else {
|
||||
cell.fg = uint8[offset++];
|
||||
}
|
||||
}
|
||||
|
||||
// Background color
|
||||
if (hasBg) {
|
||||
if (isRgbBg) {
|
||||
cell.bg = (uint8[offset] << 16) | (uint8[offset + 1] << 8) | uint8[offset + 2];
|
||||
offset += 3;
|
||||
} else {
|
||||
cell.bg = uint8[offset++];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { cell, offset };
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -331,20 +331,44 @@ export class TerminalManager {
|
|||
}
|
||||
|
||||
/**
|
||||
* Encode buffer snapshot to binary format
|
||||
* Encode buffer snapshot to binary format - optimized for minimal data transmission
|
||||
*/
|
||||
encodeSnapshot(snapshot: BufferSnapshot): Buffer {
|
||||
const { cols, rows, viewportY, cursorX, cursorY, cells } = snapshot;
|
||||
|
||||
// Calculate buffer size (rough estimate)
|
||||
const estimatedSize = 32 + rows * cols * 4; // Increased header size
|
||||
const buffer = Buffer.allocUnsafe(estimatedSize);
|
||||
// Pre-calculate actual data size for efficiency
|
||||
let dataSize = 32; // Header size
|
||||
|
||||
// First pass: calculate exact size needed
|
||||
for (let row = 0; row < cells.length; row++) {
|
||||
const rowCells = cells[row];
|
||||
if (
|
||||
rowCells.length === 0 ||
|
||||
(rowCells.length === 1 &&
|
||||
rowCells[0].char === ' ' &&
|
||||
!rowCells[0].fg &&
|
||||
!rowCells[0].bg &&
|
||||
!rowCells[0].attributes)
|
||||
) {
|
||||
// Empty row marker: 2 bytes
|
||||
dataSize += 2;
|
||||
} else {
|
||||
// Row header: 3 bytes (marker + length)
|
||||
dataSize += 3;
|
||||
|
||||
for (const cell of rowCells) {
|
||||
dataSize += this.calculateCellSize(cell);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const buffer = Buffer.allocUnsafe(dataSize);
|
||||
let offset = 0;
|
||||
|
||||
// Write header (32 bytes)
|
||||
buffer.writeUInt16LE(0x5654, offset);
|
||||
offset += 2; // Magic "VT"
|
||||
buffer.writeUInt8(0x02, offset); // Version 2 with 32-bit values
|
||||
buffer.writeUInt8(0x01, offset); // Version 1 - our only format
|
||||
offset += 1; // Version
|
||||
buffer.writeUInt8(0x00, offset);
|
||||
offset += 1; // Flags
|
||||
|
|
@ -361,95 +385,183 @@ export class TerminalManager {
|
|||
buffer.writeUInt32LE(0, offset);
|
||||
offset += 4; // Reserved
|
||||
|
||||
// Write cells with run-length encoding
|
||||
let lastCell: BufferCell | null = null;
|
||||
let runCount = 0;
|
||||
// Write cells with new optimized format
|
||||
for (let row = 0; row < cells.length; row++) {
|
||||
const rowCells = cells[row];
|
||||
|
||||
const flushRun = () => {
|
||||
if (lastCell && runCount > 0) {
|
||||
if (runCount > 1) {
|
||||
// Use RLE for repeated cells
|
||||
buffer.writeUInt8(0xff, offset++);
|
||||
buffer.writeUInt8(runCount, offset++);
|
||||
}
|
||||
// Check if this is an empty row
|
||||
if (
|
||||
rowCells.length === 0 ||
|
||||
(rowCells.length === 1 &&
|
||||
rowCells[0].char === ' ' &&
|
||||
!rowCells[0].fg &&
|
||||
!rowCells[0].bg &&
|
||||
!rowCells[0].attributes)
|
||||
) {
|
||||
// Empty row marker
|
||||
buffer.writeUInt8(0xfe, offset++); // Empty row marker
|
||||
buffer.writeUInt8(1, offset++); // Count of empty rows (for now just 1)
|
||||
} else {
|
||||
// Row with content
|
||||
buffer.writeUInt8(0xfd, offset++); // Row marker
|
||||
buffer.writeUInt16LE(rowCells.length, offset); // Number of cells in row
|
||||
offset += 2;
|
||||
|
||||
// Write cell
|
||||
const charCode = lastCell.char.charCodeAt(0);
|
||||
const isExtended =
|
||||
charCode > 127 ||
|
||||
(lastCell.fg !== undefined && lastCell.fg > 255) ||
|
||||
(lastCell.bg !== undefined && lastCell.bg > 255);
|
||||
|
||||
if (!isExtended) {
|
||||
// Basic cell (4 bytes)
|
||||
buffer.writeUInt8(charCode, offset++);
|
||||
buffer.writeUInt8(lastCell.attributes || 0, offset++);
|
||||
buffer.writeUInt8(lastCell.fg ?? 7, offset++); // Default white on black
|
||||
buffer.writeUInt8(lastCell.bg ?? 0, offset++);
|
||||
} else {
|
||||
// Extended cell
|
||||
const charBytes = Buffer.from(lastCell.char, 'utf8');
|
||||
const hasRgbFg = lastCell.fg !== undefined && lastCell.fg > 255;
|
||||
const hasRgbBg = lastCell.bg !== undefined && lastCell.bg > 255;
|
||||
|
||||
// Header byte
|
||||
const header =
|
||||
((charBytes.length - 1) << 6) | (hasRgbFg ? 0x20 : 0) | (hasRgbBg ? 0x10 : 0) | 0x80; // Extended flag
|
||||
|
||||
buffer.writeUInt8(header, offset++);
|
||||
buffer.writeUInt8((lastCell.attributes || 0) | 0x80, offset++);
|
||||
|
||||
// Character
|
||||
charBytes.copy(buffer, offset);
|
||||
offset += charBytes.length;
|
||||
|
||||
// Colors
|
||||
if (hasRgbFg && lastCell.fg !== undefined) {
|
||||
buffer.writeUInt8((lastCell.fg >> 16) & 0xff, offset++);
|
||||
buffer.writeUInt8((lastCell.fg >> 8) & 0xff, offset++);
|
||||
buffer.writeUInt8(lastCell.fg & 0xff, offset++);
|
||||
} else {
|
||||
buffer.writeUInt8(lastCell.fg ?? 7, offset++);
|
||||
}
|
||||
|
||||
if (hasRgbBg && lastCell.bg !== undefined) {
|
||||
buffer.writeUInt8((lastCell.bg >> 16) & 0xff, offset++);
|
||||
buffer.writeUInt8((lastCell.bg >> 8) & 0xff, offset++);
|
||||
buffer.writeUInt8(lastCell.bg & 0xff, offset++);
|
||||
} else {
|
||||
buffer.writeUInt8(lastCell.bg ?? 0, offset++);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Process cells
|
||||
for (const row of cells) {
|
||||
for (const cell of row) {
|
||||
if (
|
||||
lastCell &&
|
||||
cell.char === lastCell.char &&
|
||||
cell.fg === lastCell.fg &&
|
||||
cell.bg === lastCell.bg &&
|
||||
cell.attributes === lastCell.attributes &&
|
||||
runCount < 255
|
||||
) {
|
||||
runCount++;
|
||||
} else {
|
||||
flushRun();
|
||||
lastCell = cell;
|
||||
runCount = 1;
|
||||
// Write each cell
|
||||
for (const cell of rowCells) {
|
||||
offset = this.encodeCell(buffer, offset, cell);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Flush final run
|
||||
flushRun();
|
||||
|
||||
// Return trimmed buffer
|
||||
// Return exact size buffer
|
||||
return buffer.subarray(0, offset);
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate the size needed to encode a cell
|
||||
*/
|
||||
private calculateCellSize(cell: BufferCell): number {
|
||||
// Optimized encoding:
|
||||
// - Simple space with default colors: 1 byte
|
||||
// - ASCII char with default colors: 2 bytes
|
||||
// - ASCII char with colors/attrs: 2-8 bytes
|
||||
// - Unicode char: variable
|
||||
|
||||
const isSpace = cell.char === ' ';
|
||||
const hasAttrs = cell.attributes && cell.attributes !== 0;
|
||||
const hasFg = cell.fg !== undefined;
|
||||
const hasBg = cell.bg !== undefined;
|
||||
const isAscii = cell.char.charCodeAt(0) <= 127;
|
||||
|
||||
if (isSpace && !hasAttrs && !hasFg && !hasBg) {
|
||||
return 1; // Just a space marker
|
||||
}
|
||||
|
||||
let size = 1; // Type byte
|
||||
|
||||
if (isAscii) {
|
||||
size += 1; // ASCII character
|
||||
} else {
|
||||
const charBytes = Buffer.byteLength(cell.char, 'utf8');
|
||||
size += 1 + charBytes; // Length byte + UTF-8 bytes
|
||||
}
|
||||
|
||||
// Attributes/colors byte
|
||||
if (hasAttrs || hasFg || hasBg) {
|
||||
size += 1; // Flags byte
|
||||
|
||||
if (hasFg && cell.fg !== undefined) {
|
||||
size += cell.fg > 255 ? 3 : 1; // RGB or palette
|
||||
}
|
||||
|
||||
if (hasBg && cell.bg !== undefined) {
|
||||
size += cell.bg > 255 ? 3 : 1; // RGB or palette
|
||||
}
|
||||
}
|
||||
|
||||
return size;
|
||||
}
|
||||
|
||||
/**
|
||||
* Encode a single cell into the buffer
|
||||
*/
|
||||
private encodeCell(buffer: Buffer, offset: number, cell: BufferCell): number {
|
||||
const isSpace = cell.char === ' ';
|
||||
const hasAttrs = cell.attributes && cell.attributes !== 0;
|
||||
const hasFg = cell.fg !== undefined;
|
||||
const hasBg = cell.bg !== undefined;
|
||||
const isAscii = cell.char.charCodeAt(0) <= 127;
|
||||
|
||||
// Type byte format:
|
||||
// Bit 7: Has extended data (attrs/colors)
|
||||
// Bit 6: Is Unicode (vs ASCII)
|
||||
// Bit 5: Has foreground color
|
||||
// Bit 4: Has background color
|
||||
// Bit 3: Is RGB foreground (vs palette)
|
||||
// Bit 2: Is RGB background (vs palette)
|
||||
// Bits 1-0: Character type (00=space, 01=ASCII, 10=Unicode)
|
||||
|
||||
if (isSpace && !hasAttrs && !hasFg && !hasBg) {
|
||||
// Simple space - 1 byte
|
||||
buffer.writeUInt8(0x00, offset++); // Type: space, no extended data
|
||||
return offset;
|
||||
}
|
||||
|
||||
let typeByte = 0;
|
||||
|
||||
if (hasAttrs || hasFg || hasBg) {
|
||||
typeByte |= 0x80; // Has extended data
|
||||
}
|
||||
|
||||
if (!isAscii) {
|
||||
typeByte |= 0x40; // Is Unicode
|
||||
typeByte |= 0x02; // Character type: Unicode
|
||||
} else if (!isSpace) {
|
||||
typeByte |= 0x01; // Character type: ASCII
|
||||
}
|
||||
|
||||
if (hasFg && cell.fg !== undefined) {
|
||||
typeByte |= 0x20; // Has foreground
|
||||
if (cell.fg > 255) typeByte |= 0x08; // Is RGB
|
||||
}
|
||||
|
||||
if (hasBg && cell.bg !== undefined) {
|
||||
typeByte |= 0x10; // Has background
|
||||
if (cell.bg > 255) typeByte |= 0x04; // Is RGB
|
||||
}
|
||||
|
||||
buffer.writeUInt8(typeByte, offset++);
|
||||
|
||||
// Write character
|
||||
if (!isAscii) {
|
||||
const charBytes = Buffer.from(cell.char, 'utf8');
|
||||
buffer.writeUInt8(charBytes.length, offset++);
|
||||
charBytes.copy(buffer, offset);
|
||||
offset += charBytes.length;
|
||||
} else if (!isSpace) {
|
||||
buffer.writeUInt8(cell.char.charCodeAt(0), offset++);
|
||||
}
|
||||
|
||||
// Write extended data if present
|
||||
if (typeByte & 0x80) {
|
||||
// Attributes byte (if any)
|
||||
if (hasAttrs && cell.attributes !== undefined) {
|
||||
buffer.writeUInt8(cell.attributes, offset++);
|
||||
} else if (hasFg || hasBg) {
|
||||
buffer.writeUInt8(0, offset++); // No attributes but need the byte
|
||||
}
|
||||
|
||||
// Foreground color
|
||||
if (hasFg && cell.fg !== undefined) {
|
||||
if (cell.fg > 255) {
|
||||
// RGB
|
||||
buffer.writeUInt8((cell.fg >> 16) & 0xff, offset++);
|
||||
buffer.writeUInt8((cell.fg >> 8) & 0xff, offset++);
|
||||
buffer.writeUInt8(cell.fg & 0xff, offset++);
|
||||
} else {
|
||||
// Palette
|
||||
buffer.writeUInt8(cell.fg, offset++);
|
||||
}
|
||||
}
|
||||
|
||||
// Background color
|
||||
if (hasBg && cell.bg !== undefined) {
|
||||
if (cell.bg > 255) {
|
||||
// RGB
|
||||
buffer.writeUInt8((cell.bg >> 16) & 0xff, offset++);
|
||||
buffer.writeUInt8((cell.bg >> 8) & 0xff, offset++);
|
||||
buffer.writeUInt8(cell.bg & 0xff, offset++);
|
||||
} else {
|
||||
// Palette
|
||||
buffer.writeUInt8(cell.bg, offset++);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return offset;
|
||||
}
|
||||
|
||||
/**
|
||||
* Close a terminal session
|
||||
*/
|
||||
|
|
|
|||
Loading…
Reference in a new issue