import { createGestureRecognizer } from "./touch.js";
import { createSyntheticScroller } from "./synthetic-scroll.js";
const OUTPUT_LINE_LIMIT = 200;
const PROMPT_SIGILS = ["$ ", "% ", "# ", "❯ ", "➜ "];
const EMPTY_COMMAND_TEXT = "(command not recorded)";
const EMPTY_STATE_TEXT =
"Nothing recorded yet. Commands appear here as they finish.";
const ERROR_TEXT = "can't reach the server — retrying";
const POLL_INTERVAL_MS = 3000;
const MOUNT_TAIL = 200;
const REFRESH_LIMIT = 500;
export function createReadMode({
host,
session = "",
handlers = {},
fetchPage = null,
pollIntervalMs = POLL_INTERVAL_MS,
} = {}) {
let mounted = false;
let inner = null;
let scroller = null;
let gestures = null;
let entries = [];
let cursor = null;
let inflight = null;
let timer = null;
let errorEl = null;
let generation = 0;
function mountGestures() {
if (gestures) return;
gestures = createGestureRecognizer(
host,
{
onScroll: (dy) => scrollBy(dy),
onDoubleTap: () => handlers.onExit?.(),
},
{ passiveScroll: false },
);
}
function unmountGestures() {
if (!gestures) return;
gestures.destroy();
gestures = null;
}
function scrollBy(dy) {
if (!mounted) return;
scroller.scrollBy(dy);
}
function stickToBottom() {
if (!mounted) return;
scroller.stickToBottom();
}
function render() {
if (!inner) return;
scroller.contentChanged(() => {
const frag = window.document.createDocumentFragment();
if (entries.length === 0) frag.appendChild(buildEmptyState());
else for (const entry of entries) frag.appendChild(buildEntry(entry));
inner.replaceChildren(frag);
});
}
function setEntries(next) {
entries = Array.isArray(next) ? next.slice() : [];
render();
}
function appendEntries(next) {
const added = Array.isArray(next) ? next : [];
if (added.length === 0) return;
const wasEmpty = entries.length === 0;
entries = entries.concat(added);
if (!inner) return;
if (wasEmpty) {
render();
return;
}
scroller.contentChanged(() => {
const frag = window.document.createDocumentFragment();
for (const entry of added) frag.appendChild(buildEntry(entry));
inner.appendChild(frag);
});
}
function conversationPath() {
const params =
cursor === null
? { tail: String(MOUNT_TAIL) }
: { cursor, limit: String(REFRESH_LIMIT) };
const query = new URLSearchParams(params).toString();
return `/api/sessions/${encodeURIComponent(session)}/conversation?${query}`;
}
function applyPage(page) {
const fetched = Array.isArray(page?.entries) ? page.entries : [];
const first = cursor === null;
if (typeof page?.nextCursor === "string" && page.nextCursor !== "") {
cursor = page.nextCursor;
}
clearError();
if (first) setEntries(fetched);
else appendEntries(fetched);
}
function poll() {
if (!mounted || !fetchPage) return Promise.resolve();
if (inflight) return inflight;
const gen = generation;
const request = fetchPage(conversationPath())
.then(
(page) => {
if (gen === generation) applyPage(page);
},
() => {
if (gen === generation) showError();
},
)
.finally(() => {
if (gen === generation) inflight = null;
});
inflight = request;
return request;
}
function visible() {
return window.document.visibilityState === "visible";
}
function startTimer() {
if (timer !== null) return;
timer = window.setInterval(poll, pollIntervalMs);
}
function stopTimer() {
if (timer === null) return;
window.clearInterval(timer);
timer = null;
}
function onVisibilityChange() {
if (!mounted) return;
if (!visible()) {
stopTimer();
return;
}
startTimer();
poll();
}
function showError() {
if (!mounted || errorEl) return;
errorEl = makeEl("div", "cv-error", ERROR_TEXT);
host.appendChild(errorEl);
}
function clearError() {
if (!errorEl) return;
errorEl.remove();
errorEl = null;
}
function mount() {
if (mounted) return;
mounted = true;
host.classList.remove("hidden");
host.classList.add("cv-host");
inner = window.document.createElement("div");
inner.className = "cv-inner";
host.replaceChildren(inner);
scroller = createSyntheticScroller({ host, inner });
mountGestures();
render();
if (!fetchPage) return;
window.document.addEventListener("visibilitychange", onVisibilityChange);
if (!visible()) return;
startTimer();
poll();
}
function unmount() {
if (!mounted) return;
mounted = false;
host.classList.add("hidden");
generation++;
stopTimer();
window.document.removeEventListener("visibilitychange", onVisibilityChange);
inflight = null;
cursor = null;
errorEl = null;
unmountGestures();
if (scroller) {
scroller.dispose();
scroller = null;
}
host.replaceChildren();
inner = null;
}
function dispose() {
unmount();
}
function refreshNow() {
return poll();
}
return {
mount,
unmount,
dispose,
setEntries,
appendEntries,
refreshNow,
scrollBy,
stickToBottom,
get session() {
return session;
},
get mounted() {
return mounted;
},
get entryCount() {
return entries.length;
},
get scrollY() {
return scroller ? scroller.scrollY : 0;
},
get maxScroll() {
return scroller ? scroller.maxScroll : 0;
},
get atBottom() {
return scroller ? scroller.atBottom : true;
},
};
}
export function ansiToText(input) {
if (typeof input !== "string" || input === "") return "";
let out = "";
let i = 0;
const n = input.length;
while (i < n) {
const ch = input[i];
if (ch === "\u001b") {
i = skipEscape(input, i + 1);
continue;
}
const code = input.charCodeAt(i);
if (code >= 0x80 && code <= 0x9f) {
if (code === 0x9b) i = skipControlSequence(input, i + 1);
else if (isC1StringIntroducer(code)) i = skipStringSequence(input, i + 1);
else i += 1;
continue;
}
out += ch;
i++;
}
return normaliseControls(out);
}
function skipEscape(text, start) {
const n = text.length;
if (start >= n) return n;
const kind = text[start];
if (kind === "[") return skipControlSequence(text, start + 1);
if (
kind === "]" ||
kind === "P" ||
kind === "X" ||
kind === "^" ||
kind === "_"
) {
return skipStringSequence(text, start + 1);
}
if (kind === "N" || kind === "O") return start + 2;
let i = start;
while (i < n && isIntermediateByte(text.charCodeAt(i))) i++;
return i < n ? i + 1 : n;
}
function isC1StringIntroducer(code) {
return (
code === 0x90 ||
code === 0x98 ||
code === 0x9d ||
code === 0x9e ||
code === 0x9f
);
}
function isIntermediateByte(code) {
return code >= 0x20 && code <= 0x2f;
}
function skipControlSequence(text, start) {
let i = start;
const n = text.length;
while (i < n) {
const code = text.charCodeAt(i);
if (code < 0x30 || code > 0x3f) break;
i++;
}
while (i < n && isIntermediateByte(text.charCodeAt(i))) i++;
if (i < n) i++;
return i;
}
function skipStringSequence(text, start) {
let i = start;
const n = text.length;
while (i < n) {
const ch = text[i];
if (ch === "\u0007" || ch === "\u009c") return i + 1;
if (ch === "\n") return i;
if (ch === "\u001b") return text[i + 1] === "\\" ? i + 2 : i;
i++;
}
return n;
}
function normaliseControls(text) {
return text
.replace(/\r\n/g, "\n")
.split("\n")
.map((line) => lastWrittenSegment(stripControls(line)))
.join("\n");
}
function stripControls(line) {
return line.replace(
/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f-\u009f]/g,
"",
);
}
function lastWrittenSegment(line) {
const segments = line.split("\r");
for (let i = segments.length - 1; i >= 0; i--) {
if (segments[i] !== "") return segments[i];
}
return "";
}
function buildEntry(entry) {
if (typeof entry.raw === "string") return buildRaw(entry);
return buildTurn(entry);
}
function buildTurn(entry) {
const turn = makeEl("article", "cv-turn");
turn.dataset.seq = String(entry.seq);
turn.appendChild(buildCommand(entry.command));
const output = buildOutput(entry);
if (output) turn.appendChild(output);
const chip = buildExitChip(entry.exitCode);
if (chip) turn.appendChild(chip);
return turn;
}
function buildCommand(command) {
const el = makeEl("div", "cv-cmd");
const text = typeof command === "string" ? command : "";
if (text.trim() === "") {
el.classList.add("cv-cmd--empty");
el.textContent = EMPTY_COMMAND_TEXT;
return el;
}
const { prefix, rest } = splitPrompt(text);
if (prefix) {
const prefixEl = makeEl("span", "cv-cmd-prompt");
prefixEl.textContent = prefix;
el.appendChild(prefixEl);
}
const textEl = makeEl("span", "cv-cmd-text");
textEl.textContent = rest;
el.appendChild(textEl);
return el;
}
function splitPrompt(text) {
let cut = -1;
for (const sigil of PROMPT_SIGILS) {
const at = text.lastIndexOf(sigil);
if (at === -1) continue;
const end = at + sigil.length;
if (end > cut) cut = end;
}
if (cut === -1) return { prefix: "", rest: text };
return { prefix: text.slice(0, cut), rest: text.slice(cut) };
}
function buildOutput(entry) {
let lines = toLines(ansiToText(entry.output || ""));
const droppedLines = Math.max(0, lines.length - OUTPUT_LINE_LIMIT);
const droppedBytes = Number(entry.outputTruncatedBytes) || 0;
if (lines.length === 0 && droppedBytes <= 0) return null;
const el = makeEl("div", "cv-out");
if (droppedBytes > 0) {
const kb = Math.max(1, Math.round(droppedBytes / 1024));
el.appendChild(
makeEl(
"div",
"cv-trunc cv-trunc--server",
`… ${kb} KB dropped by the server`,
),
);
}
if (droppedLines > 0) {
el.appendChild(
makeEl(
"div",
"cv-trunc cv-trunc--clamp",
`… ${droppedLines} earlier lines`,
),
);
lines = lines.slice(-OUTPUT_LINE_LIMIT);
}
for (const line of lines) {
el.appendChild(makeEl("div", "cv-line", line === "" ? "\u00a0" : line));
}
return el;
}
function toLines(text) {
if (text === "") return [];
const lines = text.split("\n");
if (lines[lines.length - 1] === "") lines.pop();
return lines;
}
function buildExitChip(exitCode) {
if (exitCode === null || exitCode === undefined) return null;
const ok = exitCode === 0;
const chip = makeEl(
"span",
`cv-exit ${ok ? "cv-exit--ok" : "cv-exit--fail"}`,
ok ? "✓" : `✗ ${exitCode}`,
);
chip.title = `exit ${exitCode}`;
return chip;
}
function buildRaw(entry) {
const el = makeEl("div", "cv-raw", ansiToText(entry.raw));
el.dataset.seq = String(entry.seq);
return el;
}
function buildEmptyState() {
const wrap = makeEl("div", "cv-empty");
wrap.appendChild(makeEl("p", "cv-empty-text", EMPTY_STATE_TEXT));
const link = makeEl("a", "cv-empty-link", "Set up shell integration →");
link.href = "/settings#shell-integration";
wrap.appendChild(link);
return wrap;
}
function makeEl(tag, className, text) {
const el = window.document.createElement(tag);
el.className = className;
if (text !== undefined) el.textContent = text;
return el;
}