/** * ANSI screen buffer — parses terminal escape sequences and stores a grid of cells. * Extracted from the weather widget for reuse by the background renderer. */ type ParserMode = "normal" | "escape" | "csi" | "osc" | "osc_escape"; export interface CellColor { r: number; g: number; b: number; } export interface ScreenCell { character: string; style: string; fg: CellColor | null; bg: CellColor | null; } function createBlankCell(): ScreenCell { return { character: " ", style: "", fg: null, bg: null, }; } /** * Standard 16 ANSI colors (0-15). Indexes 0-7 are normal, 8-15 are bright. */ const ANSI_16_COLORS: CellColor[] = [ { r: 0, g: 0, b: 0 }, // 0 black { r: 170, g: 0, b: 0 }, // 1 red { r: 0, g: 170, b: 0 }, // 2 green { r: 170, g: 85, b: 0 }, // 3 yellow/brown { r: 0, g: 0, b: 170 }, // 4 blue { r: 170, g: 0, b: 170 }, // 5 magenta { r: 0, g: 170, b: 170 }, // 6 cyan { r: 170, g: 170, b: 170 }, // 7 white { r: 85, g: 85, b: 85 }, // 8 bright black { r: 255, g: 85, b: 85 }, // 9 bright red { r: 85, g: 255, b: 85 }, // 10 bright green { r: 255, g: 255, b: 85 }, // 11 bright yellow { r: 85, g: 85, b: 255 }, // 12 bright blue { r: 255, g: 85, b: 255 }, // 13 bright magenta { r: 85, g: 255, b: 255 }, // 14 bright cyan { r: 255, g: 255, b: 255 }, // 15 bright white ]; /** * Convert a 256-color index to RGB. */ function color256ToRgb(index: number): CellColor { if (index < 16) { return ANSI_16_COLORS[index] ?? { r: 0, g: 0, b: 0 }; } if (index < 232) { // 6x6x6 color cube (indices 16-231) const adjusted = index - 16; const b = (adjusted % 6) * 51; const g = (Math.floor(adjusted / 6) % 6) * 51; const r = Math.floor(adjusted / 36) * 51; return { r, g, b }; } // Grayscale ramp (indices 232-255): 24 shades from 8 to 238 const gray = 8 + (index - 232) * 10; return { r: gray, g: gray, b: gray }; } /** * Map basic ANSI foreground codes (30-37, 90-97) to 16-color index. */ function fgCodeToColorIndex(code: number): number | null { if (code >= 30 && code <= 37) return code - 30; if (code >= 90 && code <= 97) return code - 90 + 8; return null; } /** * Map basic ANSI background codes (40-47, 100-107) to 16-color index. */ function bgCodeToColorIndex(code: number): number | null { if (code >= 40 && code <= 47) return code - 40; if (code >= 100 && code <= 107) return code - 100 + 8; return null; } export class AnsiScreenBuffer { private readonly cells: ScreenCell[][]; private row = 0; private col = 0; private mode: ParserMode = "normal"; private csiBuffer = ""; private currentStyle = ""; private readonly formatTokens = new Set(); private foregroundToken: string | null = null; private backgroundToken: string | null = null; private currentFg: CellColor | null = null; private currentBg: CellColor | null = null; constructor( private readonly columns: number, private readonly rows: number, ) { this.cells = Array.from({ length: rows }, () => Array.from({ length: columns }, () => createBlankCell()), ); } clear(): void { for (let row = 0; row < this.rows; row += 1) { const currentRow = this.cells[row]; if (!currentRow) continue; for (let col = 0; col < this.columns; col += 1) { const cell = currentRow[col]; if (!cell) continue; cell.character = " "; cell.style = ""; cell.fg = null; cell.bg = null; } } this.row = 0; this.col = 0; this.mode = "normal"; this.csiBuffer = ""; this.resetStyleState(); } feed(chunk: string): void { for (const character of chunk) { this.consume(character); } } getLines(): string[] { return this.cells.map((line) => this.renderLine(line)); } getCells(): ReadonlyArray> { return this.cells; } getColumns(): number { return this.columns; } getRows(): number { return this.rows; } private renderLine(line: ScreenCell[]): string { let lastVisibleIndex = -1; for (let index = line.length - 1; index >= 0; index -= 1) { const cell = line[index]; if (cell && cell.character !== " ") { lastVisibleIndex = index; break; } } if (lastVisibleIndex < 0) { return ""; } let output = ""; let activeStyle = ""; for (let index = 0; index <= lastVisibleIndex; index += 1) { const cell = line[index]; if (!cell) { continue; } if (cell.style !== activeStyle) { if (cell.style.length === 0) { if (activeStyle.length > 0) { output += "\u001b[0m"; } } else { output += cell.style; } activeStyle = cell.style; } output += cell.character; } if (activeStyle.length > 0) { output += "\u001b[0m"; } return output; } private consume(character: string): void { switch (this.mode) { case "normal": this.consumeNormal(character); return; case "escape": this.consumeEscape(character); return; case "csi": this.consumeCsi(character); return; case "osc": this.consumeOsc(character); return; case "osc_escape": this.consumeOscEscape(character); return; } } private consumeNormal(character: string): void { if (character === "\u001b") { this.mode = "escape"; return; } if (character === "\n") { this.row = Math.min(this.rows - 1, this.row + 1); return; } if (character === "\r") { this.col = 0; return; } if (character === "\b") { this.col = Math.max(0, this.col - 1); return; } if (character === "\t") { const tabWidth = 4; const targetCol = Math.min(this.columns - 1, this.col + (tabWidth - (this.col % tabWidth))); while (this.col < targetCol) { this.writeChar(" "); } return; } const codePoint = character.codePointAt(0); if (codePoint === undefined || codePoint < 0x20) { return; } this.writeChar(character); } private consumeEscape(character: string): void { if (character === "[") { this.mode = "csi"; this.csiBuffer = ""; return; } if (character === "]") { this.mode = "osc"; return; } this.mode = "normal"; } private consumeCsi(character: string): void { if (!this.isFinalCsiCharacter(character)) { this.csiBuffer += character; return; } this.applyCsi(this.csiBuffer, character); this.mode = "normal"; this.csiBuffer = ""; } private consumeOsc(character: string): void { if (character === "\u0007") { this.mode = "normal"; return; } if (character === "\u001b") { this.mode = "osc_escape"; } } private consumeOscEscape(character: string): void { if (character === "\\") { this.mode = "normal"; return; } this.mode = "osc"; } private applyCsi(sequence: string, finalChar: string): void { switch (finalChar) { case "H": case "f": { const [rowRaw, colRaw] = sequence.split(";"); const targetRow = this.parseCsiNumber(rowRaw, 1) - 1; const targetCol = this.parseCsiNumber(colRaw, 1) - 1; this.row = this.clamp(targetRow, 0, this.rows - 1); this.col = this.clamp(targetCol, 0, this.columns - 1); return; } case "A": { const amount = this.parseCsiNumber(sequence, 1); this.row = this.clamp(this.row - amount, 0, this.rows - 1); return; } case "B": { const amount = this.parseCsiNumber(sequence, 1); this.row = this.clamp(this.row + amount, 0, this.rows - 1); return; } case "C": { const amount = this.parseCsiNumber(sequence, 1); this.col = this.clamp(this.col + amount, 0, this.columns - 1); return; } case "D": { const amount = this.parseCsiNumber(sequence, 1); this.col = this.clamp(this.col - amount, 0, this.columns - 1); return; } case "J": { this.eraseDisplay(this.parseCsiNumber(sequence, 0)); return; } case "K": { this.eraseLine(this.parseCsiNumber(sequence, 0)); return; } case "h": { if (sequence === "?1049") { this.clear(); } return; } case "l": { if (sequence === "?1049") { this.clear(); } return; } case "m": { this.applySgr(sequence); return; } default: return; } } private applySgr(sequence: string): void { const tokens = sequence.length === 0 ? ["0"] : sequence .split(";") .map((token) => token.trim()) .filter((token) => token.length > 0); if (tokens.length === 0) { this.resetStyleState(); return; } for (let index = 0; index < tokens.length; index += 1) { const token = tokens[index]; const code = Number.parseInt(token, 10); if (Number.isNaN(code)) { continue; } if (code === 0) { this.resetStyleState(); continue; } if (code >= 1 && code <= 9) { this.formatTokens.add(String(code)); continue; } if (code === 22) { this.formatTokens.delete("1"); this.formatTokens.delete("2"); continue; } if (code === 23) { this.formatTokens.delete("3"); continue; } if (code === 24) { this.formatTokens.delete("4"); continue; } if (code === 25) { this.formatTokens.delete("5"); continue; } if (code === 27) { this.formatTokens.delete("7"); continue; } if (code === 28) { this.formatTokens.delete("8"); continue; } if (code === 29) { this.formatTokens.delete("9"); continue; } if ((code >= 30 && code <= 37) || (code >= 90 && code <= 97)) { this.foregroundToken = String(code); const colorIndex = fgCodeToColorIndex(code); this.currentFg = colorIndex !== null ? (ANSI_16_COLORS[colorIndex] ?? null) : null; continue; } if (code === 39) { this.foregroundToken = null; this.currentFg = null; continue; } if ((code >= 40 && code <= 47) || (code >= 100 && code <= 107)) { this.backgroundToken = String(code); const colorIndex = bgCodeToColorIndex(code); this.currentBg = colorIndex !== null ? (ANSI_16_COLORS[colorIndex] ?? null) : null; continue; } if (code === 49) { this.backgroundToken = null; this.currentBg = null; continue; } if (code === 38 || code === 48) { const mode = tokens[index + 1]; if (mode === "5") { const value = tokens[index + 2]; if (value) { const colorIdx = Number.parseInt(value, 10); const tokenValue = `${code};5;${value}`; const rgb = Number.isNaN(colorIdx) ? null : color256ToRgb(colorIdx); if (code === 38) { this.foregroundToken = tokenValue; this.currentFg = rgb; } else { this.backgroundToken = tokenValue; this.currentBg = rgb; } index += 2; } continue; } if (mode === "2") { const r = tokens[index + 2]; const g = tokens[index + 3]; const b = tokens[index + 4]; if (r && g && b) { const tokenValue = `${code};2;${r};${g};${b}`; const rgb: CellColor = { r: Number.parseInt(r, 10) || 0, g: Number.parseInt(g, 10) || 0, b: Number.parseInt(b, 10) || 0, }; if (code === 38) { this.foregroundToken = tokenValue; this.currentFg = rgb; } else { this.backgroundToken = tokenValue; this.currentBg = rgb; } index += 4; } } } } this.rebuildCurrentStyle(); } private resetStyleState(): void { this.formatTokens.clear(); this.foregroundToken = null; this.backgroundToken = null; this.currentStyle = ""; this.currentFg = null; this.currentBg = null; } private rebuildCurrentStyle(): void { const orderedFormats = ["1", "2", "3", "4", "5", "7", "8", "9"] .filter((token) => this.formatTokens.has(token)); const tokens = [...orderedFormats]; if (this.foregroundToken) { tokens.push(this.foregroundToken); } if (this.backgroundToken) { tokens.push(this.backgroundToken); } this.currentStyle = tokens.length === 0 ? "" : `\u001b[${tokens.join(";")}m`; } private eraseDisplay(mode: number): void { if (mode === 2 || mode === 3) { this.clear(); return; } if (mode === 0) { for (let row = this.row; row < this.rows; row += 1) { const currentRow = this.cells[row]; if (!currentRow) continue; const startCol = row === this.row ? this.col : 0; for (let col = startCol; col < this.columns; col += 1) { const cell = currentRow[col]; if (!cell) continue; cell.character = " "; cell.style = ""; cell.fg = null; cell.bg = null; } } return; } if (mode === 1) { for (let row = 0; row <= this.row; row += 1) { const currentRow = this.cells[row]; if (!currentRow) continue; const endCol = row === this.row ? this.col : this.columns - 1; for (let col = 0; col <= endCol; col += 1) { const cell = currentRow[col]; if (!cell) continue; cell.character = " "; cell.style = ""; cell.fg = null; cell.bg = null; } } } } private eraseLine(mode: number): void { const currentRow = this.cells[this.row]; if (!currentRow) return; if (mode === 2) { for (let col = 0; col < this.columns; col += 1) { const cell = currentRow[col]; if (!cell) continue; cell.character = " "; cell.style = ""; cell.fg = null; cell.bg = null; } return; } if (mode === 1) { for (let col = 0; col <= this.col; col += 1) { const cell = currentRow[col]; if (!cell) continue; cell.character = " "; cell.style = ""; cell.fg = null; cell.bg = null; } return; } for (let col = this.col; col < this.columns; col += 1) { const cell = currentRow[col]; if (!cell) continue; cell.character = " "; cell.style = ""; cell.fg = null; cell.bg = null; } } private writeChar(character: string): void { const currentRow = this.cells[this.row]; if (!currentRow) return; const cell = currentRow[this.col]; if (!cell) return; cell.character = character; cell.style = this.currentStyle; cell.fg = this.currentFg ? { ...this.currentFg } : null; cell.bg = this.currentBg ? { ...this.currentBg } : null; this.col += 1; if (this.col >= this.columns) { this.col = 0; if (this.row < this.rows - 1) { this.row += 1; } } } private parseCsiNumber(raw: string | undefined, fallback: number): number { if (!raw || raw.length === 0) { return fallback; } const normalized = raw.replace(/^\?/u, ""); const parsed = Number.parseInt(normalized, 10); if (Number.isNaN(parsed)) { return fallback; } return parsed; } private isFinalCsiCharacter(character: string): boolean { const codePoint = character.codePointAt(0); if (codePoint === undefined) { return false; } return codePoint >= 0x40 && codePoint <= 0x7e; } private clamp(value: number, min: number, max: number): number { return Math.max(min, Math.min(max, value)); } }