import { u } from "./base.js";
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;
const OLDER_LIMIT = 200;
const NEAR_TOP_PX = 800;
const OLDER_BUTTON_TEXT = "Show earlier";
const OLDER_LOADING_TEXT = "Loading earlier turns…";
const RECORD_START_TEXT = "Start of the recorded conversation";
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 olderCursor = null;
let hasOlder = false;
let olderInflight = null;
let olderEl = 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);
maybeLoadOlder();
}
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(olderEl, frag);
refreshOlderStrip();
});
}
function restripOlder() {
if (!scroller) {
refreshOlderStrip();
return;
}
scroller.contentPrepended(() => refreshOlderStrip());
}
function refreshOlderStrip() {
if (!olderEl) return;
if (olderInflight) {
olderEl.replaceChildren(
makeEl("div", "cv-older-status", OLDER_LOADING_TEXT),
);
return;
}
if (hasOlder) {
const button = makeEl("button", "cv-older-btn", OLDER_BUTTON_TEXT);
button.type = "button";
button.addEventListener("click", (event) => {
event.stopPropagation();
loadOlder();
});
olderEl.replaceChildren(button);
return;
}
if (entries.length === 0) {
olderEl.replaceChildren();
return;
}
olderEl.replaceChildren(makeEl("div", "cv-older-start", RECORD_START_TEXT));
}
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 prependEntries(next) {
const added = Array.isArray(next) ? next : [];
const wasEmpty = entries.length === 0;
entries = added.concat(entries);
if (!inner) return;
if (wasEmpty) {
render();
return;
}
scroller.contentPrepended(() => {
refreshOlderStrip();
if (added.length === 0) return;
const frag = window.document.createDocumentFragment();
for (const entry of added) frag.appendChild(buildEntry(entry));
inner.insertBefore(frag, olderEl.nextSibling);
});
}
function conversationPath() {
const params =
cursor === null
? { tail: String(MOUNT_TAIL) }
: { cursor, limit: String(REFRESH_LIMIT) };
const query = new URLSearchParams(params).toString();
return u(
`api/sessions/${encodeURIComponent(session)}/conversation?${query}`,
);
}
function olderPath() {
const query = new URLSearchParams({
before: olderCursor,
limit: String(OLDER_LIMIT),
}).toString();
return u(
`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) {
appendEntries(fetched);
return;
}
adoptOlderCursor(page, fetched);
setEntries(fetched);
maybeLoadOlder();
}
function adoptOlderCursor(page, fetched) {
if (typeof page?.prevCursor === "string" && page.prevCursor !== "") {
olderCursor = page.prevCursor;
}
hasOlder =
page?.hasOlder === true && fetched.length > 0 && olderCursor !== null;
}
function applyOlderPage(page) {
const fetched = Array.isArray(page?.entries) ? page.entries : [];
adoptOlderCursor(page, fetched);
clearError();
prependEntries(fetched);
maybeLoadOlder();
}
function maybeLoadOlder() {
if (!mounted || !hasOlder || olderInflight || !scroller) return;
if (scroller.scrollY > NEAR_TOP_PX) return;
loadOlder();
}
function loadOlder() {
if (!mounted || !fetchPage) return Promise.resolve();
if (olderInflight) return olderInflight;
if (!hasOlder || olderCursor === null) return Promise.resolve();
const gen = generation;
const settle = (handle) => (value) => {
if (gen !== generation) return;
olderInflight = null;
handle(value);
};
const request = fetchPage(olderPath())
.then(
settle((older) => applyOlderPage(older)),
settle(() => {
showError();
restripOlder();
}),
)
.catch((error) => {
if (gen === generation) {
olderInflight = null;
restripOlder();
}
window.setTimeout(() => {
throw error;
}, 0);
});
olderInflight = request;
restripOlder();
return request;
}
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";
olderEl = makeEl("div", "cv-older");
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;
olderInflight = null;
olderCursor = null;
hasOlder = false;
olderEl = 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,
loadOlderNow: () => loadOlder(),
scrollBy,
stickToBottom,
get session() {
return session;
},
get mounted() {
return mounted;
},
get entryCount() {
return entries.length;
},
get hasOlder() {
return hasOlder;
},
get scrollY() {
return scroller ? scroller.scrollY : 0;
},
get maxScroll() {
return scroller ? scroller.maxScroll : 0;
},
get atBottom() {
return scroller ? scroller.atBottom : true;
},
};
}
export const _internals = { isMonoLine };
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);
const head = makeEl("header", "cv-turn-head");
head.appendChild(buildCommand(entry.command));
const chip = buildExitChip(entry.exitCode);
if (chip) head.appendChild(chip);
turn.appendChild(head);
const output = buildOutput(entry);
if (output) turn.appendChild(output);
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 group of groupByShape(lines)) {
el.appendChild(
group.mono ? buildCodeGroup(group.lines) : buildProseGroup(group.lines),
);
}
return el;
}
const INDENTED_RE = /^(?: {2,}|\t)/;
const COLUMNED_RE = /\S {2,}\S/;
const BOX_DRAWN_RE = /[\u2500-\u257F\u2580-\u259F]/;
const PIPE_TABLE_RE = /^\s*\|[^|]*\|/;
const ASCII_RULE_RE = /^\s*(?:[-=_*]{4,}|\+[-=+]{2,}\+[-=+\s]*)\s*$/;
const DIFF_RE = /^(?:@@ |\+\+\+ |--- |[+-](?=\S)|[+-] {2,})/;
const CODEY_RE = /[{};]\s*$|^\s*[$#] \S|=>|::|\|\||&&/;
function isMonoLine(text) {
if (text === "") return false;
return (
INDENTED_RE.test(text) ||
COLUMNED_RE.test(text) ||
BOX_DRAWN_RE.test(text) ||
PIPE_TABLE_RE.test(text) ||
ASCII_RULE_RE.test(text) ||
DIFF_RE.test(text) ||
CODEY_RE.test(text)
);
}
function groupByShape(lines) {
if (lines.every((line) => line === "")) {
return lines.length > 0 ? [{ mono: false, lines: lines.slice() }] : [];
}
const groups = [];
for (const line of lines) {
const last = groups[groups.length - 1];
if (last && (line === "" || last.mono === isMonoLine(line))) {
last.lines.push(line);
continue;
}
if (line === "") continue;
groups.push({ mono: isMonoLine(line), lines: [line] });
}
for (const group of groups) trimBlankEdges(group.lines);
return groups.filter((group) => group.lines.length > 0);
}
function trimBlankEdges(lines) {
while (lines.length > 0 && lines[lines.length - 1] === "") lines.pop();
while (lines.length > 0 && lines[0] === "") lines.shift();
}
function buildProseGroup(lines) {
const block = makeEl("div", "cv-prose");
for (const line of lines) {
block.appendChild(makeEl("div", "cv-line", line === "" ? "\u00a0" : line));
}
return block;
}
function buildCodeGroup(lines) {
const block = makeEl("pre", "cv-code");
for (const line of lines) {
block.appendChild(
makeEl("div", "cv-codeline", line === "" ? "\u00a0" : line),
);
}
return block;
}
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 = u("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;
}