Something went wrong. Try again.
Ghostty for the web with xterm.js API compatibility
Something went wrong. Try again.
24 kB · 840 lines
TypeScript
at task2
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841/** * Screen Buffer Implementation * * This implements a 2D grid that holds terminal content with cursor management, * scrollback, and text styling. Designed to be xterm.js-compatible. */
// ============================================================================// Type Definitions// ============================================================================
/** Color types matching Ghostty's CellColor format */export type CellColor = | { type: 'default' } | { type: 'palette'; index: number } | { type: 'rgb'; r: number; g: number; b: number };
/** Cell represents a single character position in the terminal */export interface Cell { char: string; // The character (may be multi-byte UTF-8) width: number; // 1 for normal, 2 for wide (CJK/emoji), 0 for combining fg: CellColor; bg: CellColor; bold: boolean; italic: boolean; underline: boolean; inverse: boolean; invisible: boolean; strikethrough: boolean; faint: boolean; blink: boolean;}
/** Cursor position and state */export interface Cursor { x: number; // 0 to cols-1 y: number; // 0 to rows-1 visible: boolean; // For cursor hide/show}
/** Style attributes (everything except char and width) */export type CellStyle = Omit<Cell, 'char' | 'width'>;
/** Scroll region for DECSTBM support */export interface ScrollRegion { top: number; // 0-indexed bottom: number; // 0-indexed, inclusive}
// ============================================================================// ScreenBuffer Class// ============================================================================
export class ScreenBuffer { // Core state private lines: Cell[][]; private cursor: Cursor; private savedCursor: Cursor | null = null; private currentStyle: CellStyle; // Scrollback private scrollback: Cell[][] = []; private maxScrollback: number; // Dimensions private cols: number; private rows: number; // Scroll region (for DECSTBM) private scrollRegion: ScrollRegion | null = null; // Modes private originMode: boolean = false; // DECOM - cursor relative to scroll region private insertMode: boolean = false; // IRM - insert vs replace private autoWrap: boolean = true; // DECAWM - auto wrap at right margin // Dirty tracking for renderer private dirtyLines: Set<number> = new Set();
constructor(cols: number, rows: number, scrollback: number = 1000) { this.cols = cols; this.rows = rows; this.maxScrollback = scrollback; // Initialize cursor this.cursor = { x: 0, y: 0, visible: true }; // Initialize default style this.currentStyle = this.createDefaultStyle(); // Initialize buffer with blank lines this.lines = []; for (let i = 0; i < rows; i++) { this.lines.push(this.createBlankLine()); } }
// ============================================================================ // Private Helper Methods // ============================================================================
private createDefaultStyle(): CellStyle { return { fg: { type: 'default' }, bg: { type: 'default' }, bold: false, italic: false, underline: false, inverse: false, invisible: false, strikethrough: false, faint: false, blink: false, }; }
private createEmptyCell(): Cell { return { char: ' ', width: 1, ...this.currentStyle, }; }
private createBlankLine(): Cell[] { const line: Cell[] = []; for (let i = 0; i < this.cols; i++) { line.push(this.createEmptyCell()); } return line; }
private getCharWidth(char: string): number { // Handle empty string if (!char || char.length === 0) return 1; const code = char.codePointAt(0) || 0; // Combining characters (zero-width) if (code >= 0x0300 && code <= 0x036F) return 0; if (code >= 0x1AB0 && code <= 0x1AFF) return 0; if (code >= 0x1DC0 && code <= 0x1DFF) return 0; if (code >= 0x20D0 && code <= 0x20FF) return 0; if (code >= 0xFE20 && code <= 0xFE2F) return 0; // CJK Unified Ideographs if (code >= 0x4E00 && code <= 0x9FFF) return 2; // CJK Extension A if (code >= 0x3400 && code <= 0x4DBF) return 2; // Hiragana if (code >= 0x3040 && code <= 0x309F) return 2; // Katakana if (code >= 0x30A0 && code <= 0x30FF) return 2; // Fullwidth Forms if (code >= 0xFF00 && code <= 0xFFEF) return 2; // Halfwidth and Fullwidth Forms (hangul) if (code >= 0xFFA0 && code <= 0xFFDC) return 2; // Emoji if (code >= 0x1F300 && code <= 0x1F9FF) return 2; if (code >= 0x1F600 && code <= 0x1F64F) return 2; if (code >= 0x1F680 && code <= 0x1F6FF) return 2; if (code >= 0x2600 && code <= 0x26FF) return 2; if (code >= 0x2700 && code <= 0x27BF) return 2; // Hangul Syllables if (code >= 0xAC00 && code <= 0xD7AF) return 2; return 1; }
private getEffectiveTop(): number { return this.scrollRegion?.top ?? 0; }
private getEffectiveBottom(): number { return this.scrollRegion ? this.scrollRegion.bottom : this.rows - 1; }
private clampCursor(): void { this.cursor.x = Math.max(0, Math.min(this.cols - 1, this.cursor.x)); this.cursor.y = Math.max(0, Math.min(this.rows - 1, this.cursor.y)); }
// ============================================================================ // Character Writing // ============================================================================
writeChar(char: string): void { // Handle empty or whitespace-only strings if (!char || char.length === 0) { char = ' '; } const width = this.getCharWidth(char); // Ensure cursor is in bounds if (this.cursor.y < 0 || this.cursor.y >= this.rows) { return; } // Handle combining characters (width 0) if (width === 0 && this.cursor.x > 0) { // Append to previous character (normalize the combination) const prevCell = this.lines[this.cursor.y][this.cursor.x - 1]; prevCell.char = (prevCell.char + char).normalize('NFC'); this.dirtyLines.add(this.cursor.y); return; } // Insert mode: shift characters right BEFORE checking wrap if (this.insertMode && this.cursor.x < this.cols) { this.insertChars(1); } // Check if we need to wrap before writing if (this.cursor.x >= this.cols) { if (this.autoWrap) { this.cursor.x = 0; this.cursor.y++; // Check if we've gone past the bottom const bottom = this.getEffectiveBottom(); if (this.cursor.y > bottom) { this.scrollUp(1); this.cursor.y = bottom; } } else { // Don't wrap - replace the last character instead this.cursor.x = this.cols - 1; } } if (width === 2) { // Wide character spans 2 cells if (this.cursor.x + 1 >= this.cols) { // Not enough space for wide char, wrap to next line if (this.autoWrap) { this.cursor.x = 0; this.cursor.y++; const bottom = this.getEffectiveBottom(); if (this.cursor.y > bottom) { this.scrollUp(1); this.cursor.y = bottom; } } else { // Can't write wide char, write space instead this.lines[this.cursor.y][this.cursor.x] = { char: ' ', width: 1, ...this.currentStyle, }; this.dirtyLines.add(this.cursor.y); // Don't advance cursor when autowrap is off and at edge return; } } this.lines[this.cursor.y][this.cursor.x] = { char, width: 2, ...this.currentStyle, }; this.lines[this.cursor.y][this.cursor.x + 1] = { char: '', // Padding cell width: 0, ...this.currentStyle, }; this.dirtyLines.add(this.cursor.y); this.cursor.x += 2; } else { // Normal character (width 1) this.lines[this.cursor.y][this.cursor.x] = { char, width: 1, ...this.currentStyle, }; this.dirtyLines.add(this.cursor.y); // Only advance cursor if we're not at the edge without autowrap if (this.autoWrap || this.cursor.x < this.cols - 1) { this.cursor.x++; } else { // At edge with autowrap off - stay at last column this.cursor.x = this.cols - 1; } } }
writeString(str: string): void { for (const char of str) { this.writeChar(char); } }
// ============================================================================ // Cursor Movement (Absolute) // ============================================================================
moveCursorTo(x: number, y: number): void { if (this.originMode && this.scrollRegion) { // Cursor positioning is relative to scroll region const top = this.scrollRegion.top; const bottom = this.scrollRegion.bottom; this.cursor.x = Math.max(0, Math.min(this.cols - 1, x)); this.cursor.y = Math.max(top, Math.min(bottom, top + y)); } else { // Absolute positioning this.cursor.x = Math.max(0, Math.min(this.cols - 1, x)); this.cursor.y = Math.max(0, Math.min(this.rows - 1, y)); } }
setCursorX(x: number): void { this.cursor.x = Math.max(0, Math.min(this.cols - 1, x)); }
setCursorY(y: number): void { if (this.originMode && this.scrollRegion) { const top = this.scrollRegion.top; const bottom = this.scrollRegion.bottom; this.cursor.y = Math.max(top, Math.min(bottom, top + y)); } else { this.cursor.y = Math.max(0, Math.min(this.rows - 1, y)); } }
// ============================================================================ // Cursor Movement (Relative) // ============================================================================
moveCursorUp(n: number): void { if (n <= 0) return; const top = this.originMode && this.scrollRegion ? this.scrollRegion.top : 0; this.cursor.y = Math.max(top, this.cursor.y - n); }
moveCursorDown(n: number): void { if (n <= 0) return; const bottom = this.originMode && this.scrollRegion ? this.scrollRegion.bottom : this.rows - 1; this.cursor.y = Math.min(bottom, this.cursor.y + n); }
moveCursorForward(n: number): void { if (n <= 0) return; this.cursor.x = Math.min(this.cols - 1, this.cursor.x + n); }
moveCursorBackward(n: number): void { if (n <= 0) return; this.cursor.x = Math.max(0, this.cursor.x - n); }
// ============================================================================ // Scrolling // ============================================================================
scrollUp(n: number): void { if (n <= 0) return; const top = this.getEffectiveTop(); const bottom = this.getEffectiveBottom(); for (let i = 0; i < n; i++) { // Save top line to scrollback (only if it's the actual top of screen) if (top === 0) { this.scrollback.push(this.lines[top]); if (this.scrollback.length > this.maxScrollback) { this.scrollback.shift(); } } // Shift lines up within scroll region for (let y = top; y < bottom; y++) { this.lines[y] = this.lines[y + 1]; this.dirtyLines.add(y); } // Add blank line at bottom this.lines[bottom] = this.createBlankLine(); this.dirtyLines.add(bottom); } }
scrollDown(n: number): void { if (n <= 0) return; const top = this.getEffectiveTop(); const bottom = this.getEffectiveBottom(); for (let i = 0; i < n; i++) { // Shift lines down within scroll region for (let y = bottom; y > top; y--) { this.lines[y] = this.lines[y - 1]; this.dirtyLines.add(y); } // Add blank line at top this.lines[top] = this.createBlankLine(); this.dirtyLines.add(top); } }
index(): void { // IND - Move cursor down, scroll if at bottom const bottom = this.getEffectiveBottom(); if (this.cursor.y === bottom) { this.scrollUp(1); } else { this.cursor.y++; } }
reverseIndex(): void { // RI - Move cursor up, scroll if at top const top = this.getEffectiveTop(); if (this.cursor.y === top) { this.scrollDown(1); } else { this.cursor.y--; } }
// ============================================================================ // Erasing // ============================================================================
eraseInLine(mode: 0 | 1 | 2): void { const y = this.cursor.y; if (y < 0 || y >= this.rows) return; const emptyCell = this.createEmptyCell(); switch (mode) { case 0: // Erase from cursor to end of line for (let x = this.cursor.x; x < this.cols; x++) { this.lines[y][x] = { ...emptyCell }; } break; case 1: // Erase from start of line to cursor for (let x = 0; x <= this.cursor.x; x++) { this.lines[y][x] = { ...emptyCell }; } break; case 2: // Erase entire line for (let x = 0; x < this.cols; x++) { this.lines[y][x] = { ...emptyCell }; } break; } this.dirtyLines.add(y); }
eraseInDisplay(mode: 0 | 1 | 2): void { const emptyCell = this.createEmptyCell(); switch (mode) { case 0: // Erase from cursor to end of display // Erase rest of current line for (let x = this.cursor.x; x < this.cols; x++) { this.lines[this.cursor.y][x] = { ...emptyCell }; } this.dirtyLines.add(this.cursor.y); // Erase all lines below for (let y = this.cursor.y + 1; y < this.rows; y++) { for (let x = 0; x < this.cols; x++) { this.lines[y][x] = { ...emptyCell }; } this.dirtyLines.add(y); } break; case 1: // Erase from start of display to cursor // Erase all lines above for (let y = 0; y < this.cursor.y; y++) { for (let x = 0; x < this.cols; x++) { this.lines[y][x] = { ...emptyCell }; } this.dirtyLines.add(y); } // Erase start of current line to cursor for (let x = 0; x <= this.cursor.x; x++) { this.lines[this.cursor.y][x] = { ...emptyCell }; } this.dirtyLines.add(this.cursor.y); break; case 2: // Erase entire display for (let y = 0; y < this.rows; y++) { for (let x = 0; x < this.cols; x++) { this.lines[y][x] = { ...emptyCell }; } this.dirtyLines.add(y); } break; } }
eraseChars(n: number): void { if (n <= 0) return; const y = this.cursor.y; const emptyCell = this.createEmptyCell(); for (let i = 0; i < n && this.cursor.x + i < this.cols; i++) { this.lines[y][this.cursor.x + i] = { ...emptyCell }; } this.dirtyLines.add(y); }
// ============================================================================ // Line Operations // ============================================================================
insertLines(n: number): void { if (n <= 0) return; const top = this.getEffectiveTop(); const bottom = this.getEffectiveBottom(); // Only insert if cursor is within scroll region if (this.cursor.y < top || this.cursor.y > bottom) return; for (let i = 0; i < n; i++) { // Remove bottom line of scroll region this.lines.splice(bottom, 1); // Insert blank line at cursor position this.lines.splice(this.cursor.y, 0, this.createBlankLine()); } // Mark all lines from cursor to bottom as dirty for (let y = this.cursor.y; y <= bottom; y++) { this.dirtyLines.add(y); } }
deleteLines(n: number): void { if (n <= 0) return; const top = this.getEffectiveTop(); const bottom = this.getEffectiveBottom(); // Only delete if cursor is within scroll region if (this.cursor.y < top || this.cursor.y > bottom) return; for (let i = 0; i < n; i++) { // Remove line at cursor position this.lines.splice(this.cursor.y, 1); // Add blank line at bottom of scroll region this.lines.splice(bottom, 0, this.createBlankLine()); } // Mark all lines from cursor to bottom as dirty for (let y = this.cursor.y; y <= bottom; y++) { this.dirtyLines.add(y); } }
// ============================================================================ // Character Operations // ============================================================================
insertChars(n: number): void { if (n <= 0) return; const y = this.cursor.y; const line = this.lines[y]; const emptyCell = this.createEmptyCell(); // Shift characters to the right for (let i = this.cols - 1; i >= this.cursor.x + n; i--) { if (i - n >= this.cursor.x) { line[i] = line[i - n]; } } // Insert blank characters for (let i = 0; i < n && this.cursor.x + i < this.cols; i++) { line[this.cursor.x + i] = { ...emptyCell }; } this.dirtyLines.add(y); }
deleteChars(n: number): void { if (n <= 0) return; const y = this.cursor.y; const line = this.lines[y]; const emptyCell = this.createEmptyCell(); // Shift characters to the left for (let i = this.cursor.x; i < this.cols - n; i++) { line[i] = line[i + n]; } // Fill end with blank characters for (let i = Math.max(this.cursor.x, this.cols - n); i < this.cols; i++) { line[i] = { ...emptyCell }; } this.dirtyLines.add(y); }
// ============================================================================ // Cursor State // ============================================================================
saveCursor(): void { this.savedCursor = { ...this.cursor }; }
restoreCursor(): void { if (this.savedCursor) { this.cursor = { ...this.savedCursor }; this.clampCursor(); } }
// ============================================================================ // Style Management // ============================================================================
setStyle(style: Partial<CellStyle>): void { this.currentStyle = { ...this.currentStyle, ...style }; }
resetStyle(): void { this.currentStyle = this.createDefaultStyle(); }
// ============================================================================ // Scroll Region (DECSTBM) // ============================================================================
setScrollRegion(top?: number, bottom?: number): void { if (top === undefined && bottom === undefined) { this.scrollRegion = null; } else { const t = top ?? 0; const b = bottom ?? this.rows - 1; // Validate bounds if (t >= 0 && b < this.rows && t < b) { this.scrollRegion = { top: t, bottom: b }; } } // DECSTBM moves cursor to origin if (this.originMode && this.scrollRegion) { this.cursor.x = 0; this.cursor.y = this.scrollRegion.top; } else { this.cursor.x = 0; this.cursor.y = 0; } }
clearScrollRegion(): void { this.scrollRegion = null; }
getScrollRegion(): ScrollRegion | null { return this.scrollRegion ? { ...this.scrollRegion } : null; }
// ============================================================================ // Modes // ============================================================================
setOriginMode(enabled: boolean): void { this.originMode = enabled; // Reset cursor position when changing origin mode if (enabled && this.scrollRegion) { this.cursor.x = 0; this.cursor.y = this.scrollRegion.top; } else { this.cursor.x = 0; this.cursor.y = 0; } }
setInsertMode(enabled: boolean): void { this.insertMode = enabled; }
setAutoWrap(enabled: boolean): void { this.autoWrap = enabled; }
// ============================================================================ // Resizing // ============================================================================
resize(newCols: number, newRows: number): void { // Handle width change if (newCols !== this.cols) { for (let y = 0; y < this.lines.length; y++) { if (newCols > this.cols) { // Expand: add cells to the right const toAdd = newCols - this.cols; for (let i = 0; i < toAdd; i++) { this.lines[y].push(this.createEmptyCell()); } } else { // Shrink: remove cells from the right this.lines[y] = this.lines[y].slice(0, newCols); } } // Same for scrollback for (let y = 0; y < this.scrollback.length; y++) { if (newCols > this.cols) { const toAdd = newCols - this.cols; for (let i = 0; i < toAdd; i++) { this.scrollback[y].push(this.createEmptyCell()); } } else { this.scrollback[y] = this.scrollback[y].slice(0, newCols); } } } // Handle height change if (newRows !== this.rows) { if (newRows > this.rows) { // Expand: add blank lines at bottom const toAdd = newRows - this.rows; for (let i = 0; i < toAdd; i++) { this.lines.push(this.createBlankLine()); } } else { // Shrink: move excess lines to scrollback const toRemove = this.rows - newRows; const removed = this.lines.splice(0, toRemove); this.scrollback.push(...removed); // Trim scrollback if needed if (this.scrollback.length > this.maxScrollback) { this.scrollback = this.scrollback.slice(-this.maxScrollback); } } } this.cols = newCols; this.rows = newRows; // Clamp cursor to new bounds this.clampCursor(); // Clear scroll region if it's now invalid if (this.scrollRegion) { if (this.scrollRegion.bottom >= newRows) { this.scrollRegion = null; } } // Mark all lines as dirty for (let y = 0; y < this.rows; y++) { this.dirtyLines.add(y); } }
// ============================================================================ // Accessors (xterm.js-compatible) // ============================================================================
getLine(y: number): Cell[] | undefined { if (y < 0 || y >= this.rows) return undefined; // Return a copy to prevent external modification return this.lines[y].map(cell => ({ ...cell })); }
getAllLines(): Cell[][] { // Return a deep copy return this.lines.map(line => line.map(cell => ({ ...cell }))); }
getScrollback(): Cell[][] { // Return a deep copy return this.scrollback.map(line => line.map(cell => ({ ...cell }))); }
getCursor(): Readonly<Cursor> { return { ...this.cursor }; }
getDimensions(): { cols: number; rows: number } { return { cols: this.cols, rows: this.rows }; }
// xterm.js IBuffer-compatible getters get baseY(): number { return this.scrollback.length; }
get viewportY(): number { return 0; // For Phase 1, always at top }
get cursorX(): number { return this.cursor.x; }
get cursorY(): number { return this.cursor.y; }
get length(): number { return this.scrollback.length + this.rows; }
// ============================================================================ // Dirty Tracking for Renderer // ============================================================================
isDirty(y: number): boolean { return this.dirtyLines.has(y); }
clearDirty(): void { this.dirtyLines.clear(); }
getAllDirtyLines(): number[] { return Array.from(this.dirtyLines).sort((a, b) => a - b); }}