// ANSI escape code → HTML converter
function ansiToHtml(raw) {
if (!raw) return '';
// 16-color palette (One Dark)
const C16 = [
'#21252b','#e06c75','#98c379','#d19a66',
'#61afef','#c678dd','#56b6c2','#abb2bf',
'#5c6370','#ff6b6b','#b5f5a0','#e5c07b',
'#61afef','#c678dd','#56b6c2','#ffffff',
];
function c256(n) {
if (n < 16) return C16[n];
if (n >= 232) { const v = 8 + (n - 232) * 10; return `rgb(${v},${v},${v})`; }
n -= 16;
const r = Math.floor(n / 36), g = Math.floor((n % 36) / 6), b = n % 6;
const v = x => x ? 55 + x * 40 : 0;
return `rgb(${v(r)},${v(g)},${v(b)})`;
}
function escHtml(s) {
return s.replace(/&/g, '&').replace(//g, '>');
}
// Strip ALL remaining escape sequences from a text chunk
// Covers: CSI (\x1b[...final), OSC (\x1b]...BEL), other single-char ESC sequences
function stripEsc(s) {
return s.replace(/\x1b(?:\[[^a-zA-Z]*[a-zA-Z]|\][^\x07]*\x07|.)/g, '');
}
let fg = null, bg = null, bold = false, italic = false, dim = false;
function mkStyle() {
const p = [];
if (fg) p.push(`color:${fg}`);
if (bg) p.push(`background:${bg}`);
if (bold) p.push('font-weight:bold');
if (italic) p.push('font-style:italic');
if (dim && !fg) p.push('opacity:0.6');
return p.join(';');
}
// Match only SGR sequences (color/attribute codes ending with 'm')
const SGR_RE = /\x1b\[([0-9;]*)m/g;
let html = '';
let last = 0;
let m;
function flush(text) {
if (!text) return;
const s = mkStyle();
const e = escHtml(stripEsc(text));
if (!e) return;
html += s ? `${e}` : e;
}
while ((m = SGR_RE.exec(raw)) !== null) {
flush(raw.slice(last, m.index));
last = m.index + m[0].length;
const codes = m[1] ? m[1].split(';').map(Number) : [0];
let i = 0;
while (i < codes.length) {
const c = codes[i++];
if (c === 0 || isNaN(c)) { fg = bg = null; bold = italic = dim = false; }
else if (c === 1) bold = true;
else if (c === 2) dim = true;
else if (c === 3) italic = true;
else if (c === 22) { bold = dim = false; }
else if (c === 23) italic = false;
else if (c >= 30 && c <= 37) fg = C16[c - 30];
else if (c === 38) {
if (codes[i] === 5 && i + 1 < codes.length) { fg = c256(codes[i+1]); i += 2; }
else if (codes[i] === 2 && i + 3 < codes.length) { fg = `rgb(${codes[i+1]},${codes[i+2]},${codes[i+3]})`; i += 4; }
}
else if (c === 39) fg = null;
else if (c >= 40 && c <= 47) bg = C16[c - 40];
else if (c === 48) {
if (codes[i] === 5 && i + 1 < codes.length) { bg = c256(codes[i+1]); i += 2; }
else if (codes[i] === 2 && i + 3 < codes.length) { bg = `rgb(${codes[i+1]},${codes[i+2]},${codes[i+3]})`; i += 4; }
}
else if (c === 49) bg = null;
else if (c >= 90 && c <= 97) fg = C16[c - 90 + 8];
else if (c >= 100 && c <= 107) bg = C16[c - 100 + 8];
}
}
flush(raw.slice(last));
return html;
}
window.ansiToHtml = ansiToHtml;
class TerminalView {
constructor(element) {
this.el = element;
this.lines = [];
this.renderPending = false;
this.autoScroll = true;
this.claudeMode = false;
this.claudeParser = null;
this.claudeRenderer = null;
// Track scroll position to determine auto-scroll
const container = this.el.parentElement;
container.addEventListener('scroll', () => {
const atBottom =
container.scrollHeight - container.scrollTop - container.clientHeight < 30;
this.autoScroll = atBottom;
});
}
setContent(lines) {
this.lines = lines;
this.scheduleRender();
}
applyDiff(patches) {
for (const patch of patches) {
this.lines.splice(patch.startLine, patch.deleteCount, ...patch.insertLines);
}
// Invalidate parser cache since lines were mutated in-place
if (this.claudeParser) this.claudeParser._lastLines = null;
this.scheduleRender();
}
setClaudeMode(active, onAction) {
const changed = this.claudeMode !== active;
this.claudeMode = active;
if (active) {
if (!this.claudeParser) this.claudeParser = new ClaudeParser();
if (!this.claudeRenderer) {
this.claudeRenderer = new ClaudeRenderer(this.el);
}
// Attach status bar to container if not already there
const container = this.el.parentElement;
if (container && !this.claudeRenderer.statusBarEl.parentNode) {
container.appendChild(this.claudeRenderer.statusBarEl);
}
this.claudeRenderer.setOnAction(onAction);
// Re-render existing content in claude mode (fixes race with async mode detection)
if (changed && this.lines.length > 0) this.scheduleRender();
} else {
if (this.claudeRenderer) this.claudeRenderer.clear();
}
}
scheduleRender() {
if (this.renderPending) return;
this.renderPending = true;
requestAnimationFrame(() => {
this.render();
this.renderPending = false;
});
}
isQrLine(line) {
// Detect QR code lines: mostly Unicode block characters (▀▄█)
const blockChars = (line.match(/[\u2580-\u2588]/g) || []).length;
const nonSpace = line.replace(/\s/g, '').length;
return nonSpace > 4 && blockChars / nonSpace > 0.3;
}
render() {
if (this.claudeMode && this.claudeParser && this.claudeRenderer) {
const doc = this.claudeParser.parse(this.lines);
this.claudeRenderer.render(doc);
if (this.autoScroll) {
const container = this.el.parentElement;
container.scrollTop = container.scrollHeight;
}
return;
}
const output = [];
let i = 0;
while (i < this.lines.length) {
if (this.isQrLine(this.lines[i])) {
// Collect all consecutive QR lines
const qrBlock = [];
while (i < this.lines.length && this.isQrLine(this.lines[i])) {
qrBlock.push(this.lines[i]);
i++;
}
// Calculate font-size so QR block fits container width without clipping
const maxLen = Math.max(...qrBlock.map(l => l.length));
const containerWidth = (this.el.parentElement?.clientWidth || 375) - 32;
// monospace char width ≈ 0.62 × font-size; cap at 13px (normal size)
const fontSize = Math.max(3, Math.min(13, Math.floor(containerWidth / (maxLen * 0.62))));
const escapedLines = qrBlock.map(l =>
l.replace(/&/g, '&').replace(//g, '>')
).join('\n');
// Single block span — no \n between spans so no extra blank lines
output.push(`${escapedLines}`);
} else {
output.push(this.highlightLine(this.lines[i]));
i++;
}
}
const html = output.join('\n');
this.el.innerHTML = html;
if (this.autoScroll) {
const container = this.el.parentElement;
container.scrollTop = container.scrollHeight;
}
}
highlightLine(line) {
// cmux strips ANSI codes — use pattern-based coloring
const stripped = line.replace(/\x1b\[[0-9;]*[A-Za-z]/g, '');
let e = stripped
.replace(/&/g, '&')
.replace(//g, '>');
// Diff lines
if (/^\+[^+]/.test(stripped)) return `${e}`;
if (/^-[^-]/.test(stripped)) return `${e}`;
// Tool-use markers ⏺●⬤
e = e.replace(/([\u23FA\u25CF\u2B24])/g, '$1');
// Dingbats — spinner/thinking characters (✻ ✳ ✢ etc.)
e = e.replace(/([\u2700-\u27BF])/g, '$1');
// Output marker ⎿
e = e.replace(/(\u23BF)/g, '$1');
// Prompt marker ❯
e = e.replace(/(\u276F)/g, '$1');
// Success
e = e.replace(/(\u2713|\u2714|passed|PASS)/g, '$1');
// Error
e = e.replace(/(\u2717|\u2718|error|ERROR|FAIL|failed)/g, '$1');
// Box drawing
e = e.replace(/([\u2500-\u257F]+)/g, '$1');
// File paths
e = e.replace(
/(?$1'
);
// Line numbers
e = e.replace(/^(\s*\d+\s*[|:])/, '$1');
return e;
}
scrollToBottom() {
const container = this.el.parentElement;
container.scrollTop = container.scrollHeight;
this.autoScroll = true;
}
clear() {
this.lines = [];
this.el.innerHTML = '';
}
}
window.TerminalView = TerminalView;