Files
paseo/packages/server/src/shared/terminal-snapshot.ts
T
Mohamed BoudraandGitHub 846c9b9da3 Make mobile terminals load faster (#1147)
* Speed up terminal restores on mobile

* Preserve terminal output bytes in the app
2026-05-23 18:41:10 +08:00

206 lines
4.9 KiB
TypeScript

import type { TerminalCell, TerminalState } from "./messages.js";
interface TerminalStyle {
fg: number | undefined;
bg: number | undefined;
fgMode: number | undefined;
bgMode: number | undefined;
bold: boolean;
italic: boolean;
underline: boolean;
dim: boolean;
inverse: boolean;
strikethrough: boolean;
}
const DEFAULT_STYLE: TerminalStyle = {
fg: undefined,
bg: undefined,
fgMode: undefined,
bgMode: undefined,
bold: false,
italic: false,
underline: false,
dim: false,
inverse: false,
strikethrough: false,
};
export function renderTerminalSnapshotToAnsi(state: TerminalState): string {
const rows = [...state.scrollback, ...state.grid];
const lines: string[] = ["\u001b[?7l"];
for (let rowIndex = 0; rowIndex < rows.length; rowIndex += 1) {
const row = rows[rowIndex] ?? [];
lines.push(renderTerminalRow(row));
if (rowIndex < rows.length - 1) {
lines.push("\r\n");
}
}
lines.push("\u001b[0m");
const cursorPresentationAnsi = renderCursorPresentationToAnsi(state.cursor);
if (cursorPresentationAnsi) {
lines.push(cursorPresentationAnsi);
}
lines.push(`\u001b[${state.cursor.row + 1};${state.cursor.col + 1}H`);
lines.push(state.cursor.hidden ? "\u001b[?25l" : "\u001b[?25h");
lines.push("\u001b[?7h");
return lines.join("");
}
function renderCursorPresentationToAnsi(cursor: TerminalState["cursor"]): string | null {
if (!cursor.style) {
return null;
}
const cursorStyleCode = (() => {
if (cursor.style === "block") {
return cursor.blink === false ? 2 : 1;
}
if (cursor.style === "underline") {
return cursor.blink === false ? 4 : 3;
}
return cursor.blink === false ? 6 : 5;
})();
return `\u001b[${cursorStyleCode} q`;
}
function renderTerminalRow(row: TerminalCell[]): string {
const output: string[] = [];
const length = getTerminalRowLength(row);
let previousStyle = DEFAULT_STYLE;
for (let index = 0; index < length; index += 1) {
const cell = row[index] ?? { char: " " };
const nextStyle = getTerminalStyle(cell);
if (!terminalStylesEqual(previousStyle, nextStyle)) {
output.push(styleToAnsi(nextStyle));
previousStyle = nextStyle;
}
output.push(cell.char || " ");
}
if (!terminalStylesEqual(previousStyle, DEFAULT_STYLE)) {
output.push("\u001b[0m");
}
return output.join("");
}
function getTerminalRowLength(row: TerminalCell[]): number {
for (let index = row.length - 1; index >= 0; index -= 1) {
const cell = row[index];
if (!cell) {
continue;
}
if (cell.char !== " ") {
return index + 1;
}
if (
cell.fg !== undefined ||
cell.bg !== undefined ||
cell.fgMode !== undefined ||
cell.bgMode !== undefined ||
cell.bold ||
cell.italic ||
cell.underline ||
cell.dim ||
cell.inverse ||
cell.strikethrough
) {
return index + 1;
}
}
return 0;
}
function getTerminalStyle(cell: TerminalCell): TerminalStyle {
return {
fg: cell.fg,
bg: cell.bg,
fgMode: cell.fgMode,
bgMode: cell.bgMode,
bold: Boolean(cell.bold),
italic: Boolean(cell.italic),
underline: Boolean(cell.underline),
dim: Boolean(cell.dim),
inverse: Boolean(cell.inverse),
strikethrough: Boolean(cell.strikethrough),
};
}
function terminalStylesEqual(left: TerminalStyle, right: TerminalStyle): boolean {
return (
left.fg === right.fg &&
left.bg === right.bg &&
left.fgMode === right.fgMode &&
left.bgMode === right.bgMode &&
left.bold === right.bold &&
left.italic === right.italic &&
left.underline === right.underline &&
left.dim === right.dim &&
left.inverse === right.inverse &&
left.strikethrough === right.strikethrough
);
}
function styleToAnsi(style: TerminalStyle): string {
const codes = ["0"];
if (style.bold) {
codes.push("1");
}
if (style.dim) {
codes.push("2");
}
if (style.italic) {
codes.push("3");
}
if (style.underline) {
codes.push("4");
}
if (style.inverse) {
codes.push("7");
}
if (style.strikethrough) {
codes.push("9");
}
if (style.fg !== undefined && style.fgMode !== undefined) {
codes.push(...colorToSgr(style.fgMode, style.fg, false));
}
if (style.bg !== undefined && style.bgMode !== undefined) {
codes.push(...colorToSgr(style.bgMode, style.bg, true));
}
return `\u001b[${codes.join(";")}m`;
}
function colorToSgr(mode: number, value: number, background: boolean): string[] {
if (mode === 1) {
if (value >= 8) {
return [String((background ? 100 : 90) + (value - 8))];
}
return [String((background ? 40 : 30) + value)];
}
if (mode === 2) {
return [background ? "48" : "38", "5", String(value)];
}
if (mode === 3) {
return [
background ? "48" : "38",
"2",
String((value >> 16) & 0xff),
String((value >> 8) & 0xff),
String(value & 0xff),
];
}
return [];
}