You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

260 lines
8.7 KiB
JavaScript

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

// 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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
// 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;
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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
).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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
// 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;