// 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.length > 10000 ? lines.slice(lines.length - 10000) : lines; this.scheduleRender(); } applyDiff(patches) { for (const patch of patches) { this.lines.splice(patch.startLine, patch.deleteCount, ...patch.insertLines); } if (this.lines.length > 10000) { this.lines = this.lines.slice(this.lines.length - 10000); } // 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.lastClaudeDoc = doc; 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;