import { u, wsUrl } from "./base.js";
import { openExternal } from "./external-link.js";
import { createTerminalDocument } from "./terminal-document.js";
import { createTerminalBuffer, splitCapture } from "./terminal-buffer.js";
import { createRedrawWriter } from "./terminal-redraw.js";
import { createMarkerBook } from "./terminal-markers.js";
import {
findOsc133AEnd,
scanForNextAAndCandidate,
} from "./osc133-attribution.js";
const oscTextDecoder = new TextDecoder("utf-8", { fatal: false });
function oscInputToString(data) {
return typeof data === "string" ? data : oscTextDecoder.decode(data);
}
const HISTORY_QUIET_MS = 400;
const HISTORY_MAX_WAIT_MS = 2000;
const HISTORY_TAIL_LINES = 500;
const HISTORY_TAIL_OVERLAP = 50;
const HISTORY_WHOLE_LINES = 10000;
const COLOUR_QUERIES = {
10: (theme) => theme.foreground || theme.palette?.[7],
11: (theme) => theme.background || theme.palette?.[0],
12: (theme) => theme.cursor || theme.foreground || theme.palette?.[7],
};
const hex2 = (n) => n.toString(16).padStart(2, "0");
function paletteHex(theme, n) {
if (n < 16) return theme.palette?.[n];
if (n < 232) {
const level = (v) => (v === 0 ? 0 : 55 + v * 40);
const c = n - 16;
return `#${[Math.floor(c / 36), Math.floor(c / 6) % 6, c % 6]
.map((v) => hex2(level(v)))
.join("")}`;
}
if (n < 256) return `#${hex2(8 + (n - 232) * 10).repeat(3)}`;
return null;
}
function oscColour(hex) {
const m = /^#?([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/i.exec(hex || "");
if (!m) return null;
return `rgb:${m
.slice(1)
.map((c) => c + c)
.join("/")}`;
}
export const WINDOW_SETTLE_MS = 300;
const WINDOW_SWITCH_CMDS = new Set([
"next-window",
"prev-window",
"new-window",
"kill-window",
]);
export class TerminalEngine extends EventTarget {
constructor({ session, node, host, renderer, build, scrollback }) {
super();
this.session = session;
this.node = node || "";
this.build = build || "";
this.host = host;
this.renderer = renderer;
this.ws = null;
this.panes = [];
this.activeIndex = 0;
this._panesAsked = 0;
this._panesApplied = 0;
this._paneScreenFrom = 0;
this._paneScreenKnown = false;
this.intentionalClose = false;
this._reconnectTimer = null;
this._reconnectDelay = 0;
this._reconnectMin = 500;
this._reconnectMax = 10000;
this.oscDetected = false;
this._oscAOpen = false;
this._ingestChain = Promise.resolve();
this.buffer = createTerminalBuffer({
scrollback,
cols: this.renderer.cols,
rows: this.renderer.rows,
});
this.view = this.renderer.drawBuffer
? this.renderer.drawBuffer(this.buffer)
: createRedrawWriter(this.buffer, this.renderer);
this.markers = createMarkerBook(this.buffer);
this._historyTimer = null;
this._historyMaxTimer = null;
this._historySync = null;
this._historyNext = null;
this._historyAbort = null;
this._historyGeneration = 0;
this._theme = null;
this._oscSub = this.buffer.registerOscHandler(133, (data) => {
const kind = (data || "").charAt(0);
if (kind !== "A" && kind !== "B" && kind !== "C" && kind !== "D") {
return false;
}
if (kind !== "A") {
this.markers.record(data);
}
if (!this.oscDetected) {
this.oscDetected = true;
this.dispatchEvent(new Event("osc-detected"));
}
return false; });
this._inputSub = this.renderer.onInput((d) => this.send(d));
this._replySub = this.buffer.onData((d) => this.send(d));
this._colourSubs = Object.entries(COLOUR_QUERIES).map(([id, pick]) =>
this.buffer.registerOscHandler(Number(id), (data) => {
if (data !== "?") return false;
const colour = this._theme && oscColour(pick(this._theme));
if (colour) this.send(`\x1b]${id};${colour}\x1b\\`);
return true;
}),
);
this._colourSubs.push(
this.buffer.registerOscHandler(4, (data) => {
const parts = data.split(";");
if (parts.length % 2 || parts.some((p, i) => i % 2 && p !== "?")) {
return false;
}
for (let i = 0; i < parts.length; i += 2) {
const colour =
this._theme && oscColour(paletteHex(this._theme, Number(parts[i])));
if (colour) this.send(`\x1b]4;${parts[i]};${colour}\x1b\\`);
}
return true;
}),
);
this.document = createTerminalDocument(this);
}
_nodeQuery() {
return this.node ? `?node=${encodeURIComponent(this.node)}` : "";
}
_wsQuery() {
const params = new URLSearchParams();
if (this.node) params.set("node", this.node);
if (this.build) params.set("build", this.build);
const qs = params.toString();
return qs ? `?${qs}` : "";
}
connect() {
if (this._reconnectTimer !== null) {
clearTimeout(this._reconnectTimer);
this._reconnectTimer = null;
}
this.intentionalClose = false;
this.ws = new WebSocket(
wsUrl(`ws/${encodeURIComponent(this.session)}${this._wsQuery()}`),
);
this.ws.binaryType = "arraybuffer";
this.ws.onopen = () => {
this._reconnectDelay = 0;
this.resize();
this.refreshPanes();
this.dispatchEvent(new Event("open"));
};
this.ws.onmessage = (ev) => {
const bytes =
typeof ev.data === "string" ? ev.data : new Uint8Array(ev.data);
this._ingestPtyData(bytes);
this._scheduleHistoryTail();
this.dispatchEvent(new CustomEvent("data", { detail: bytes }));
};
this.ws.onclose = () => {
this.dispatchEvent(new Event("close"));
this._scheduleReconnect();
};
this.ws.onerror = () => {};
}
_scheduleReconnect() {
if (this.intentionalClose) return;
if (this._reconnectTimer !== null) return;
this._reconnectDelay = this._reconnectDelay
? Math.min(this._reconnectDelay * 2, this._reconnectMax)
: this._reconnectMin;
this._reconnectTimer = setTimeout(() => {
this._reconnectTimer = null;
this.connect();
}, this._reconnectDelay);
}
reconnect() {
if (
this.ws &&
(this.ws.readyState === WebSocket.OPEN ||
this.ws.readyState === WebSocket.CONNECTING)
) {
return;
}
if (this.ws) {
this.intentionalClose = true;
try {
this.ws.close();
} catch (_) {}
}
this.connect();
}
send(data) {
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
this.ws.send(data);
}
}
dispose() {
this.intentionalClose = true;
if (this._reconnectTimer !== null) {
clearTimeout(this._reconnectTimer);
this._reconnectTimer = null;
}
try {
this.ws?.close();
} catch (_) {}
this.ws = null;
this._disposed = true;
clearTimeout(this._historyTimer);
clearTimeout(this._historyMaxTimer);
try {
this._oscSub?.dispose();
} catch (_) {}
try {
this._inputSub?.dispose();
} catch (_) {}
this._replySub.dispose();
for (const sub of this._colourSubs) sub.dispose();
this.view.dispose();
try {
this.renderer.dispose();
} catch (_) {}
this.buffer.dispose();
}
resize() {
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) return;
const { cols, rows } = this.renderer.measure();
this._resizeBuffer(cols, rows);
this.ws.send(JSON.stringify({ type: "resize", cols, rows }));
}
_resizeBuffer(cols, rows) {
if (cols === this.buffer.cols && rows === this.buffer.rows) return;
const widthChanged = cols !== this.buffer.cols;
this.buffer.resize(cols, rows);
if (widthChanged) this.view.invalidate();
this.view.flush();
}
_forceRedraw() {
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) return;
const { cols, rows } = this.renderer.measure();
this.ws.send(
JSON.stringify({ type: "resize", cols, rows: Math.max(2, rows - 1) }),
);
setTimeout(() => {
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) return;
this._resizeBuffer(cols, rows);
this.ws.send(JSON.stringify({ type: "resize", cols, rows }));
}, 50);
}
measure() {
return this.renderer.measure();
}
cellSize() {
return this.renderer.cellSize();
}
viewport() {
return this.renderer.viewport();
}
rowText(y) {
return this.renderer.rowText(y);
}
scrollLines(n) {
this.renderer.scrollLines(n);
}
scrollToBottom() {
this.renderer.scrollToBottom();
}
async clear() {
this._historyGeneration++;
this._historyAbort?.abort();
this.markers.clear();
await this.buffer.clearHistory();
this.view.invalidate();
await this.view.flush();
}
get cols() {
return this.buffer.cols;
}
get rows() {
return this.buffer.rows;
}
write(data) {
return this._ingestPtyData(data).then(() => this.view.settle());
}
get oscMarkers() {
return this.markers.map;
}
_ingestPtyData(raw) {
const str = oscInputToString(raw);
const step = async () => {
await this._consumeChunk(str);
await this.view.flush();
};
this._ingestChain = this._ingestChain.then(step, step);
return this._ingestChain;
}
async _consumeChunk(text) {
let cursor = 0;
for (;;) {
if (!this._oscAOpen) {
const markerEnd = findOsc133AEnd(text, cursor);
if (markerEnd === -1) {
await this._writeSlice(text, cursor, text.length);
return;
}
await this._writeSlice(text, cursor, markerEnd);
this._oscAOpen = true;
cursor = markerEnd;
continue;
}
const { candidateEnd, nextAEnd } = scanForNextAAndCandidate(text, cursor);
if (candidateEnd === cursor) {
await this._writeSlice(
text,
cursor,
nextAEnd === -1 ? text.length : nextAEnd,
);
if (nextAEnd === -1) return;
cursor = nextAEnd;
continue;
}
await this._writeSlice(text, cursor, candidateEnd);
this.markers.record("A");
this._oscAOpen = false;
cursor = candidateEnd;
if (nextAEnd !== -1) {
await this._writeSlice(text, cursor, nextAEnd);
this._oscAOpen = true;
cursor = nextAEnd;
}
}
}
_writeSlice(text, from, to) {
if (to <= from) return Promise.resolve();
return this.buffer.writeScreen(text.slice(from, to));
}
onBufferChanged(cb) {
return this.buffer.onChange(cb);
}
isAlternateScreenActive() {
return this.buffer.isAlternate();
}
wheelScrollsPane() {
if (!this._paneScreenKnown) return false;
const pane = this.panes[this.activeIndex];
return pane?.alternateOn === true && this.buffer.mouse().tracking;
}
paneRows() {
return this.buffer.paneRows();
}
forgetPaneScreen(settleMs) {
this._paneScreenKnown = false;
this._paneScreenFrom = performance.now() + settleMs;
}
sendWheel(up, col, row) {
const button = up ? 64 : 65;
if (this.buffer.mouse().sgr) {
this.send(`\x1b[<${button};${col + 1};${row + 1}M`);
return;
}
const cell = (n) => String.fromCharCode(33 + Math.min(n, 93));
this.send(
`\x1b[M${String.fromCharCode(32 + button)}${cell(col)}${cell(row)}`,
);
}
focus() {
this.renderer.focus();
}
setNativeInputEnabled(enabled) {
this.renderer.setNativeInputEnabled(enabled);
}
setTheme(theme) {
this._theme = theme;
this.renderer.setTheme(theme);
}
getSelection() {
return this.renderer.getSelection();
}
hasSelection() {
return this.renderer.hasSelection();
}
clearSelection() {
this.renderer.clearSelection();
}
selectAll() {
this.renderer.selectAll();
}
onSelectionChange(cb) {
return this.renderer.onSelectionChange(cb);
}
onLink(cb) {
return this.renderer.onLink(cb);
}
onBell(cb) {
return this.buffer.onBell(cb);
}
setFontSize(px) {
if (px !== this.renderer.getFontSize()) {
this.renderer.setFontSize(px);
this.resize();
}
}
getFontSize() {
return this.renderer.getFontSize();
}
async refreshPanes() {
const asked = ++this._panesAsked;
const askedAt = performance.now();
try {
const res = await fetch(
u(
`api/sessions/${encodeURIComponent(this.session)}/panes${this._nodeQuery()}`,
),
);
if (!res.ok) return;
const panes = await res.json();
if (asked < this._panesApplied) return;
this._panesApplied = asked;
this.panes = panes;
if (askedAt >= this._paneScreenFrom) this._paneScreenKnown = true;
this.activeIndex = this.panes.findIndex((p) => p.active);
if (this.activeIndex < 0) this.activeIndex = 0;
const pane = this.panes[this.activeIndex];
this.buffer.setStatus(pane?.statusLines ?? 1, pane?.statusPosition);
this.dispatchEvent(
new CustomEvent("panes", {
detail: { panes: this.panes, activeIndex: this.activeIndex },
}),
);
} catch (_) {}
}
switchWindow(direction) {
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) return;
this.send(direction === "next" ? "\x02n" : "\x02p");
this.forgetPaneScreen(WINDOW_SETTLE_MS);
this.clear();
this.scrollToBottom();
setTimeout(async () => {
await this.refreshPanes();
await this.reloadHistory();
this._forceRedraw();
}, WINDOW_SETTLE_MS);
}
async runTmuxCmd(command) {
try {
await fetch(
u(
`api/sessions/${encodeURIComponent(this.session)}/command${this._nodeQuery()}`,
),
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ command }),
},
);
} catch (_) {}
if (WINDOW_SWITCH_CMDS.has(command)) {
this.forgetPaneScreen(WINDOW_SETTLE_MS);
this.clear();
this.scrollToBottom();
}
setTimeout(() => {
this.refreshPanes();
this.reloadHistory();
}, WINDOW_SETTLE_MS);
}
_historyUrl(lines) {
const q = new URLSearchParams({ scope: "history", lines: String(lines) });
if (this.node) q.set("node", this.node);
return u(
`api/sessions/${encodeURIComponent(this.session)}/history?${q.toString()}`,
);
}
reloadHistory() {
return this._requestHistory("whole");
}
_requestHistory(kind) {
if (this._historySync) {
if (this._historyNext !== "whole") this._historyNext = kind;
return this._historySync;
}
this._historySync = this._runHistorySync(kind).finally(() => {
this._historySync = null;
const next = this._historyNext;
this._historyNext = null;
if (next && !this._disposed) this._requestHistory(next);
});
return this._historySync;
}
async _fetchHistory(lines, signal) {
const res = await fetch(this._historyUrl(lines), { signal }).catch(
() => null,
);
if (!res || !res.ok) return null;
const text = await res.text().catch(() => null);
if (text === null) return null;
return {
lines: splitCapture(text),
continues: res.headers.get("x-history-continues") === "1",
};
}
async _runHistorySync(kind) {
clearTimeout(this._historyTimer);
clearTimeout(this._historyMaxTimer);
this._historyMaxTimer = null;
const generation = this._historyGeneration;
const abort = new AbortController();
this._historyAbort = abort;
const current = () =>
!this._disposed && generation === this._historyGeneration;
let moved = null;
let scrolled = this.buffer.scrolledRows();
if (kind === "tail") {
const tail = await this._fetchHistory(HISTORY_TAIL_LINES, abort.signal);
if (!tail || !current()) return;
moved = await this.buffer.syncTail(
tail.lines,
tail.continues,
HISTORY_TAIL_OVERLAP,
);
if (!current()) return;
}
if (!moved) {
scrolled = this.buffer.scrolledRows();
const whole = await this._fetchHistory(HISTORY_WHOLE_LINES, abort.signal);
if (!whole || !current()) return;
moved = await this.buffer.syncWhole(whole.lines, whole.continues);
if (!current()) return;
}
this.markers.place(moved, scrolled);
await this.view.flush();
this.dispatchEvent(new Event("history"));
}
_scheduleHistoryTail() {
clearTimeout(this._historyTimer);
this._historyTimer = setTimeout(() => {
this._requestHistory("tail");
this.refreshPanes();
}, HISTORY_QUIET_MS);
if (this._historyMaxTimer === null) {
this._historyMaxTimer = setTimeout(
() => this._requestHistory("tail"),
HISTORY_MAX_WAIT_MS,
);
}
}
}