import { openExternal } from "./external-link.js";
import { createTerminalDocument } from "./terminal-document.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 WINDOW_SWITCH_CMDS = new Set([
"next-window",
"prev-window",
"new-window",
"kill-window",
]);
export class TerminalEngine extends EventTarget {
constructor({ session, node, host, renderer, build }) {
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.intentionalClose = false;
this._reconnectTimer = null;
this._reconnectDelay = 0;
this._reconnectMin = 500;
this._reconnectMax = 10000;
this.oscMarkers = new Map();
this.oscDetected = false;
this._oscAOpen = false;
this._ingestChain = Promise.resolve();
this._oscSub = this.renderer.registerOscHandler(133, (data) => {
const kind = (data || "").charAt(0);
if (kind !== "A" && kind !== "B" && kind !== "C" && kind !== "D") {
return false;
}
if (kind !== "A") {
const buf = this.getActiveBuffer();
const absY = (buf.baseY || 0) + (buf.cursorY || 0);
this._recordOscMarker(absY, 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.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;
const proto = location.protocol === "https:" ? "wss" : "ws";
this.ws = new WebSocket(
`${proto}://${location.host}/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 = async (ev) => {
let bytes;
if (typeof ev.data === "string") {
this._ingestPtyData(ev.data);
bytes = ev.data;
} else if (ev.data instanceof ArrayBuffer) {
const u8 = new Uint8Array(ev.data);
this._ingestPtyData(u8);
bytes = u8;
} else if (ev.data instanceof Blob) {
const u8 = new Uint8Array(await ev.data.arrayBuffer());
this._ingestPtyData(u8);
bytes = u8;
}
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;
try {
this._oscSub?.dispose();
} catch (_) {}
try {
this._inputSub?.dispose();
} catch (_) {}
try {
this.renderer.dispose();
} catch (_) {}
}
resize() {
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) return;
const { cols, rows } = this.renderer.measure();
this.renderer.resize(cols, rows);
this.ws.send(JSON.stringify({ type: "resize", cols, rows }));
}
_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.renderer.resize(cols, rows);
this.ws.send(JSON.stringify({ type: "resize", cols, rows }));
}, 50);
}
measure() {
return this.renderer.measure();
}
cellSize() {
return this.renderer.cellSize();
}
getActiveBuffer() {
return this.renderer.buffer.active;
}
scrollLines(n) {
this.renderer.scrollLines(n);
}
scrollToBottom() {
this.renderer.scrollToBottom();
}
clear() {
this.renderer.clear();
}
get cols() {
return this.renderer.cols;
}
get rows() {
return this.renderer.rows;
}
write(data) {
return this._ingestPtyData(data);
}
_recordOscMarker(absY, marker) {
const existing = this.oscMarkers.get(absY);
this.oscMarkers.set(absY, existing ? `${existing}|${marker}` : marker);
}
_ingestPtyData(raw) {
const str = oscInputToString(raw);
const step = () => this._consumeChunk(str);
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);
const buf = this.getActiveBuffer();
this._recordOscMarker((buf.baseY || 0) + (buf.cursorY || 0), "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.renderer.write(text.slice(from, to));
}
onBufferChanged(cb) {
return this.renderer.onBufferChanged(cb);
}
isAlternateScreenActive() {
return this.renderer.isAlternateScreenActive();
}
focus() {
this.renderer.focus();
}
setNativeInputEnabled(enabled) {
this.renderer.setNativeInputEnabled(enabled);
}
setTheme(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.renderer.onBell(cb);
}
setFontSize(px) {
if (px !== this.renderer.getFontSize()) {
this.renderer.setFontSize(px);
this.resize();
}
}
getFontSize() {
return this.renderer.getFontSize();
}
async refreshPanes() {
try {
const res = await fetch(
`/api/sessions/${encodeURIComponent(this.session)}/panes${this._nodeQuery()}`,
);
if (!res.ok) return;
this.panes = await res.json();
this.activeIndex = this.panes.findIndex((p) => p.active);
if (this.activeIndex < 0) this.activeIndex = 0;
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.clear();
this.scrollToBottom();
setTimeout(async () => {
await this.refreshPanes();
await this.reloadHistory();
this._forceRedraw();
}, 300);
}
async runTmuxCmd(command) {
try {
await fetch(
`/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.clear();
this.scrollToBottom();
}
setTimeout(() => {
this.refreshPanes();
this.reloadHistory();
}, 300);
}
async reloadHistory() {
try {
const res = await fetch(
`/api/sessions/${encodeURIComponent(this.session)}/history${this._nodeQuery()}`,
);
if (!res.ok) return;
const history = await res.text();
if (history.trim()) {
this.renderer.write(history.replace(/\n/g, "\r\n"));
this.scrollToBottom();
this.dispatchEvent(new CustomEvent("history", { detail: history }));
}
} catch (_) {}
}
}