Terminal snapshots now carry soft-wrap row metadata only to clients that advertise support, so restored output can resize like live output without breaking older clients.
219 lines
5.8 KiB
TypeScript
219 lines
5.8 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 wrapFlags = [...(state.scrollbackWrapped ?? []), ...(state.gridWrapped ?? [])];
|
|
// Soft-wrapped lines can only be re-wrapped on resize when we know which rows
|
|
// were continuations. With that per-row flag we replay each logical line as one
|
|
// unbroken run (autowrap on) so xterm marks the continuations wrapped and reflows
|
|
// them. Without it (old daemon) we keep the verbatim per-row replay: autowrap off
|
|
// plus a hard newline per row.
|
|
const hasWrapInfo = wrapFlags.length === rows.length;
|
|
const lines: string[] = hasWrapInfo ? [] : ["\u001b[?7l"];
|
|
|
|
for (let rowIndex = 0; rowIndex < rows.length; rowIndex += 1) {
|
|
const row = rows[rowIndex] ?? [];
|
|
const continuesToNextRow = hasWrapInfo && wrapFlags[rowIndex] === true;
|
|
// A continuation row must fill the full width so the next row first cell
|
|
// triggers xterm auto-wrap, which is what marks the row wrapped/reflowable.
|
|
lines.push(renderTerminalRow(row, continuesToNextRow ? state.cols : undefined));
|
|
if (rowIndex < rows.length - 1 && !continuesToNextRow) {
|
|
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");
|
|
if (!hasWrapInfo) {
|
|
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[], padToCols?: number): string {
|
|
const output: string[] = [];
|
|
const contentLength = getTerminalRowLength(row);
|
|
const length = padToCols !== undefined ? Math.max(contentLength, padToCols) : contentLength;
|
|
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 [];
|
|
}
|