|
|
// 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, '<').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 ? `<span style="${s}">${e}</span>` : 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, '<').replace(/>/g, '>')
|
|
|
).join('\n');
|
|
|
// Single block span — no \n between spans so no extra blank lines
|
|
|
output.push(`<span class="qr-block" style="font-size:${fontSize}px">${escapedLines}</span>`);
|
|
|
} 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, '<')
|
|
|
.replace(/>/g, '>');
|
|
|
|
|
|
// Diff lines
|
|
|
if (/^\+[^+]/.test(stripped)) return `<span class="hl-added">${e}</span>`;
|
|
|
if (/^-[^-]/.test(stripped)) return `<span class="hl-removed">${e}</span>`;
|
|
|
|
|
|
// Tool-use markers ⏺●⬤
|
|
|
e = e.replace(/([\u23FA\u25CF\u2B24])/g, '<span class="hl-tool">$1</span>');
|
|
|
// Dingbats — spinner/thinking characters (✻ ✳ ✢ etc.)
|
|
|
e = e.replace(/([\u2700-\u27BF])/g, '<span class="hl-thinking">$1</span>');
|
|
|
// Output marker ⎿
|
|
|
e = e.replace(/(\u23BF)/g, '<span class="hl-output">$1</span>');
|
|
|
// Prompt marker ❯
|
|
|
e = e.replace(/(\u276F)/g, '<span class="hl-prompt">$1</span>');
|
|
|
// Success
|
|
|
e = e.replace(/(\u2713|\u2714|passed|PASS)/g, '<span class="hl-success">$1</span>');
|
|
|
// Error
|
|
|
e = e.replace(/(\u2717|\u2718|error|ERROR|FAIL|failed)/g, '<span class="hl-error">$1</span>');
|
|
|
// Box drawing
|
|
|
e = e.replace(/([\u2500-\u257F]+)/g, '<span class="hl-box">$1</span>');
|
|
|
// File paths
|
|
|
e = e.replace(
|
|
|
/(?<!\w)((?:\.?\.?\/)?(?:[\w.-]+\/)+[\w.-]+\.[\w]+)/g,
|
|
|
'<span class="hl-path">$1</span>'
|
|
|
);
|
|
|
// Line numbers
|
|
|
e = e.replace(/^(\s*\d+\s*[|:])/, '<span class="hl-linenum">$1</span>');
|
|
|
|
|
|
return e;
|
|
|
}
|
|
|
|
|
|
scrollToBottom() {
|
|
|
const container = this.el.parentElement;
|
|
|
container.scrollTop = container.scrollHeight;
|
|
|
this.autoScroll = true;
|
|
|
}
|
|
|
|
|
|
clear() {
|
|
|
this.lines = [];
|
|
|
this.el.innerHTML = '';
|
|
|
}
|
|
|
}
|
|
|
|
|
|
window.TerminalView = TerminalView;
|