const SCREEN_SCROLLBACK = 1000;
const MIRRORED_MODES = [1, 25, 2004];
const MOUSE_TRACKING_MODES = new Set([1000, 1002, 1003]);
const MOUSE_SGR_MODE = 1006;
const ESC_RE = /\x1b(?:\[[0-?]*[ -/]*[@-~]|\][^\x07\x1b]*(?:\x07|\x1b\\)|.)/g;
const DEFAULT_STYLE = {
bold: false,
dim: false,
italic: false,
underline: false,
blink: false,
inverse: false,
invisible: false,
strike: false,
fg: null,
bg: null,
};
const FLAG_ON = {
1: "bold",
2: "dim",
3: "italic",
4: "underline",
5: "blink",
7: "inverse",
8: "invisible",
9: "strike",
};
const FLAG_OFF = {
22: ["bold", "dim"],
23: ["italic"],
24: ["underline"],
25: ["blink"],
27: ["inverse"],
28: ["invisible"],
29: ["strike"],
};
const FLAG_SGR = [
["bold", 1],
["dim", 2],
["italic", 3],
["underline", 4],
["blink", 5],
["inverse", 7],
["invisible", 8],
["strike", 9],
];
function extendedColour(params, i) {
if (params[i + 1] === "5") return [`5;${params[i + 2]}`, 3];
if (params[i + 1] === "2") {
return [`2;${params[i + 2]};${params[i + 3]};${params[i + 4]}`, 5];
}
return [null, 1];
}
function applySgr(style, body) {
const params = body.replace(/:/g, ";").split(";");
let next = { ...style };
for (let i = 0; i < params.length;) {
const p = params[i] === "" ? 0 : Number(params[i]);
if (p === 0) next = { ...DEFAULT_STYLE };
else if (FLAG_ON[p]) next[FLAG_ON[p]] = true;
else if (FLAG_OFF[p]) for (const k of FLAG_OFF[p]) next[k] = false;
else if ((p >= 30 && p <= 37) || (p >= 90 && p <= 97)) next.fg = `${p}`;
else if (p === 39) next.fg = null;
else if ((p >= 40 && p <= 47) || (p >= 100 && p <= 107)) next.bg = `${p}`;
else if (p === 49) next.bg = null;
else if (p === 38 || p === 48 || p === 58) {
const [colour, used] = extendedColour(params, i);
if (colour && p === 38) next.fg = `38;${colour}`;
if (colour && p === 48) next.bg = `48;${colour}`;
i += used;
continue;
}
i += 1;
}
return next;
}
function styleSgr(style) {
const p = ["0"];
for (const [k, code] of FLAG_SGR) if (style[k]) p.push(code);
if (style.fg) p.push(style.fg);
if (style.bg) p.push(style.bg);
return `\x1b[${p.join(";")}m`;
}
export function normalizeCapture(lines) {
let style = { ...DEFAULT_STYLE };
return lines.map((line) => {
let out = "";
let emitted = styleSgr(DEFAULT_STYLE);
let last = 0;
const text = (s) => {
if (!s) return;
const want = styleSgr(style);
if (want !== emitted) {
out += want;
emitted = want;
}
out += s;
};
for (const m of line.matchAll(ESC_RE)) {
text(line.slice(last, m.index));
last = m.index + m[0].length;
if (m[0].startsWith("\x1b[") && m[0].endsWith("m")) {
style = applySgr(style, m[0].slice(2, -1));
} else {
out += m[0];
}
}
text(line.slice(last));
return out;
});
}
export function splitCapture(text) {
if (!text) return [];
const lines = text.split("\n");
if (lines[lines.length - 1] === "") lines.pop();
return lines;
}
function suffixMatches(held, next, from, count, grows) {
for (let j = 0; j < count; j++) {
const a = held[from + j];
const b = next[j];
if (a === b) continue;
if (grows && from + j === held.length - 1 && b.startsWith(a)) continue;
return false;
}
return true;
}
export function alignHistory(held, next, grows = false) {
const L = held.length;
if (L === 0) return { drop: 0, keep: 0, extended: false };
for (let drop = 0; drop < L; drop++) {
const keep = L - drop;
if (keep > next.length) continue;
if (suffixMatches(held, next, drop, keep, grows)) {
return { drop, keep, extended: next[keep - 1] !== held[L - 1] };
}
}
for (let drop = 0; drop < L; drop++) {
const keep = Math.min(L - drop, next.length);
if (keep === 0 || keep === L - drop) continue;
let match = true;
for (let j = 0; j < keep && match; j++) match = held[drop + j] === next[j];
if (match) return { drop, keep, extended: false };
}
return null;
}
export function alignTail(held, tail, minOverlap, grows = false) {
const L = held.length;
if (L === 0) return { overlap: 0, extended: false };
let found = null;
for (let o = Math.min(L, tail.length); o > 0; o--) {
if (!suffixMatches(held, tail, L - o, o, grows)) continue;
if (found !== null) return null;
found = o;
}
if (found === null || found < Math.min(minOverlap, L)) return null;
return { overlap: found, extended: tail[found - 1] !== held[L - 1] };
}
export function isBlankCell(cell) {
const ch = cell.getChars();
return (
(ch === "" || ch === " ") &&
cell.isBgDefault() &&
!cell.isInverse() &&
!cell.isUnderline() &&
!cell.isStrikethrough?.()
);
}
function continuesLine(prev, row) {
if (!row?.isWrapped || !prev) return false;
const cell = prev.getCell(prev.length - 1);
return !!cell && (cell.getWidth() === 0 || !isBlankCell(cell));
}
export function logicalLines(rows) {
const lines = [];
for (const row of rows) {
if (lines.length && continuesLine(lines.at(-1).at(-1), row)) {
lines.at(-1).push(row);
} else {
lines.push([row]);
}
}
return lines;
}
function write(term, data) {
return new Promise((resolve) => term.write(data, resolve));
}
const historyText = (lines) => lines.map((l) => `\x1b[0m${l}\r\n`).join("");
function lineWidth(line) {
let width = 0;
for (const ch of line.replace(ESC_RE, "")) {
width += ch.codePointAt(0) >= 0x1100 ? 2 : 1;
}
return width;
}
export const plainText = (line) => line.replace(ESC_RE, "");
export function displayRowsOf(line, cols) {
return Math.max(1, Math.ceil(lineWidth(line) / Math.max(1, cols - 1)));
}
export function createTerminalBuffer({ cols, rows, scrollback }) {
const Headless = window.XtermHeadless;
if (!Headless || !Headless.Terminal) {
throw new Error(
"xterm headless bundle not loaded — check vendor/xterm-headless.bundle.js",
);
}
const screenLimit = Math.min(SCREEN_SCROLLBACK, Math.floor(scrollback / 10));
const historyBudget = scrollback - screenLimit;
const newTerminal = (options) =>
new Headless.Terminal({ allowProposedApi: true, ...options });
const screen = newTerminal({ cols, rows, scrollback: screenLimit });
const modes = new Map(MIRRORED_MODES.map((m) => [m, m === 25]));
const mouseModes = new Map();
const scalar = (p) => (Array.isArray(p) ? p[0] : p);
let switching = false;
const SCREEN_SWITCHES = new Set([47, 1047, 1049]);
const subs = ["h", "l"].map((final) =>
screen.parser.registerCsiHandler({ prefix: "?", final }, (params) => {
for (const m of params.map(scalar)) {
if (modes.has(m)) modes.set(m, final === "h");
if (MOUSE_TRACKING_MODES.has(m) || m === MOUSE_SGR_MODE) {
mouseModes.set(m, final === "h");
}
if (SCREEN_SWITCHES.has(m)) switching = true;
}
return false;
}),
);
let statusLines = 1;
let statusTop = false;
const paneRows = () => {
const first = statusTop ? statusLines : 0;
return { first, last: Math.max(first, first + rows - statusLines - 1) };
};
let scrolled = [];
const scrolledLines = () => scrolled.filter(Boolean).length;
const paneRow = (y) => {
const buf = screen.buffer.active;
return buf.getLine(buf.baseY + paneRows().first + y);
};
let regionTop = 1;
let screenTrimmed = 0;
let normalBase = 0;
const countTrim = () => {
const normal = screen.buffer.normal;
if (screen.buffer.active.type !== "normal") return;
const full = normal.baseY === screenLimit && normalBase === screenLimit;
if (full && regionTop === 1) screenTrimmed++;
normalBase = normal.baseY;
};
const scrollsHistory = () =>
regionTop === paneRows().first + 1 &&
screen.buffer.active.type === "alternate";
subs.push(
screen.parser.registerCsiHandler({ final: "r" }, (params) => {
regionTop = scalar(params[0]) || 1;
return false;
}),
screen.parser.registerCsiHandler({ final: "S" }, (params) => {
if (!scrollsHistory()) return false;
const n = scalar(params[0]) || 1;
for (let y = 0; y < n; y++) {
scrolled.push(!continuesLine(paneRow(y), paneRow(y + 1)));
}
return false;
}),
screen.onScroll(() => {
if (switching) {
switching = false;
return;
}
if (scrollsHistory()) scrolled.push(!paneRow(0)?.isWrapped);
countTrim();
}),
screen.onWriteParsed(() => {
switching = false;
changed();
}),
);
const newHistory = (width) =>
newTerminal({ cols: width, rows: 2, scrollback: historyBudget + 10 });
let history = newHistory(cols);
let historyCols = cols;
let historyLines = [];
let historyStart = 0;
let lastContinues = false;
let straddleRow = null;
let lastRevision = 0;
let historyEpoch = 0;
let ops = Promise.resolve();
const changeSubs = new Set();
function changed() {
for (const cb of [...changeSubs]) cb();
}
function queue(op) {
const run = ops.then(op).then((result) => {
changed();
return result;
});
ops = run.catch(() => {});
return run;
}
async function rebuild(lines, start, trim = false) {
const width = lines.reduce((w, l) => Math.max(w, lineWidth(l)), cols);
const next = newHistory(width);
if (lines.length) await write(next, historyText(lines));
history.dispose();
history = next;
historyCols = width;
historyLines = lines;
historyStart = start;
if (!trim) historyEpoch++;
}
function widen(lines) {
const width = lines.reduce((w, l) => Math.max(w, lineWidth(l)), 0);
if (width <= historyCols) return;
historyCols = width;
history.resize(historyCols, 2);
}
async function extendLast(line) {
widen([line]);
await write(history, `\x1b[A\r\x1b[2K\x1b[0m${line}\r\n`);
historyLines = historyLines.slice(0, -1).concat([line]);
lastRevision++;
}
async function append(fresh) {
if (!fresh.length) return;
widen(fresh);
await write(history, historyText(fresh));
historyLines = historyLines.concat(fresh);
}
async function cap() {
const rowsOf = historyLines.map((l) => displayRowsOf(l, cols));
let total = rowsOf.reduce((a, b) => a + b, 0);
if (total <= historyBudget) return;
let drop = 0;
while (total > (historyBudget * 2) / 3) total -= rowsOf[drop++];
await rebuild(historyLines.slice(drop), historyStart + drop, true);
}
const end = () => historyStart + historyLines.length;
async function syncWhole(raw, continues) {
const next = normalizeCapture(raw);
const before = end();
const aligned = alignHistory(historyLines, next, lastContinues);
let result = { replaced: false, before };
if (!aligned) {
await rebuild(next, before);
result = { replaced: true, before };
} else if (aligned.keep === historyLines.length - aligned.drop) {
const segments = grownSegments(
aligned.extended ? next[aligned.keep - 1] : null,
next.slice(aligned.keep),
);
if (aligned.extended) await extendLast(next[aligned.keep - 1]);
await append(next.slice(aligned.keep));
result = { replaced: false, before, segments };
} else {
const kept = historyLines.slice(0, aligned.drop);
await rebuild(kept.concat(next), historyStart);
}
setContinues(continues);
await cap();
return { ...result, after: end() };
}
async function syncTail(raw, continues, minOverlap) {
const tail = normalizeCapture(raw);
const aligned = alignTail(historyLines, tail, minOverlap, lastContinues);
if (!aligned) return null;
const before = end();
const segments = grownSegments(
aligned.extended ? tail[aligned.overlap - 1] : null,
tail.slice(aligned.overlap),
);
if (aligned.extended) await extendLast(tail[aligned.overlap - 1]);
await append(tail.slice(aligned.overlap));
setContinues(continues);
await cap();
return { replaced: false, before, after: end(), segments };
}
function grownSegments(grownLast, fresh) {
const segments = [];
const rowsOf = (line) => displayRowsOf(line, cols);
if (grownLast !== null) {
const held = historyLines[historyLines.length - 1];
segments.push({
key: end() - 1,
rows: rowsOf(grownLast) - rowsOf(held),
text: plainText(grownLast),
});
}
fresh.forEach((line, i) => {
segments.push({
key: end() + i,
rows: rowsOf(line),
text: plainText(line),
});
});
return segments;
}
function viewportRows() {
const buf = screen.buffer.active;
const out = [];
for (let r = 0; r < rows; r++) out.push(buf.getLine(buf.baseY + r));
return out;
}
function scrollbackRows(from = 0) {
const normal = screen.buffer.normal;
const out = [];
for (let i = from; i < normal.baseY; i++) out.push(normal.getLine(i));
return out;
}
function statusRows() {
const { first, last } = paneRows();
return viewportRows().filter((_, r) => r < first || r > last);
}
function paneLines() {
const { first, last } = paneRows();
let row = 0;
return logicalLines(viewportRows().slice(first, last + 1)).map((line) => {
const out = { rows: line, row };
row += line.length;
return out;
});
}
const firstRowText = () =>
viewportRows()[paneRows().first]?.translateToString(false) ?? "";
function setContinues(continues) {
lastContinues = continues;
straddleRow = continues ? firstRowText() : null;
}
const straddles = () =>
lastContinues &&
historyLines.length > 0 &&
screen.buffer.normal.baseY === 0 &&
firstRowText() === straddleRow;
return {
get cols() {
return cols;
},
get rows() {
return rows;
},
writeScreen(text) {
return write(screen, text);
},
resize(nextCols, nextRows) {
cols = nextCols;
rows = nextRows;
screen.resize(cols, rows);
regionTop = 1;
queue(cap);
},
setStatus(lines, position) {
const top = position === "top";
if (lines === statusLines && top === statusTop) return;
statusLines = lines;
statusTop = top;
changed();
},
paneRows,
statusRows,
onChange(cb) {
changeSubs.add(cb);
return { dispose: () => changeSubs.delete(cb) };
},
isAlternate() {
return screen.buffer.active.type === "alternate";
},
onBell(cb) {
return screen.onBell(cb);
},
onData(cb) {
return screen.onData(cb);
},
registerOscHandler(id, cb) {
return screen.parser.registerOscHandler(id, cb);
},
modes() {
return modes;
},
mouse() {
const tracking = [...MOUSE_TRACKING_MODES].some((m) => mouseModes.get(m));
return { tracking, sgr: mouseModes.get(MOUSE_SGR_MODE) === true };
},
historyRowCount() {
return historyLines.length;
},
historyRow(i) {
return history.buffer.active.getLine(i);
},
historyStart() {
return historyStart;
},
historyEpoch() {
return historyEpoch;
},
screenTrimmed() {
return screenTrimmed;
},
lastLineRevision() {
return lastRevision;
},
straddles,
screenScrollbackCount() {
return screen.buffer.normal.baseY;
},
scrollbackRows,
viewportRows,
cursor() {
const buf = screen.buffer.active;
return { x: buf.cursorX, y: buf.cursorY };
},
screenKeyBase() {
const base =
end() + logicalLines(scrollbackRows()).length + scrolledLines();
return straddles() ? base - 1 : base;
},
scrolledRows() {
return scrolled.length;
},
consumeScrolledRows(n) {
scrolled = scrolled.slice(n);
},
rowKey(r) {
const base = this.screenKeyBase();
const lines = paneLines();
const index = lines.findIndex(
(l) => r >= l.row && r < l.row + l.rows.length,
);
return index === -1 ? null : base + index;
},
screenLines() {
const base = this.screenKeyBase();
return paneLines().map((line, index) => ({
key: base + index,
row: line.row,
rows: line.rows,
text: line.rows.map((l) => l?.translateToString(true) ?? "").join(""),
}));
},
historyText(i) {
return plainText(historyLines[i] ?? "");
},
cursorPlace() {
const y = screen.buffer.active.cursorY - paneRows().first;
const lines = this.screenLines();
let lineIndex = 0;
while (lineIndex + 1 < lines.length && lines[lineIndex + 1].row <= y) {
lineIndex++;
}
return {
key: this.cursorLineKey(),
vrow: scrolled.length + y,
snapshot: lines.map((l) => l.text),
lineIndex,
};
},
cursorLineKey() {
const y = screen.buffer.active.cursorY - paneRows().first;
const lines = paneLines();
let index = 0;
while (index + 1 < lines.length && lines[index + 1].row <= y) index++;
return this.screenKeyBase() + index;
},
syncWhole(raw, continues) {
return queue(() => syncWhole(raw, continues));
},
syncTail(raw, continues, minOverlap) {
return queue(() => syncTail(raw, continues, minOverlap));
},
clearHistory() {
return queue(async () => {
const before = end();
await rebuild([], before);
setContinues(false);
return { replaced: true, before, after: end() };
});
},
dispose() {
changeSubs.clear();
for (const sub of subs) sub.dispose();
history.dispose();
screen.dispose();
},
};
}