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.

287 lines
10 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;
this.codexMode = false;
this.codexParser = null;
this.codexRenderer = null;
// autoScroll is an explicit gate (default on). It is NOT recomputed from
// scroll position on every scroll event — that produced a stale-read race
// where a render firing before the scroll event dispatched would yank the
// view back to the bottom. Instead render() measures the real position at
// render time. app.js sets this false only to pin position in PageUp mode.
}
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 caches since lines were mutated in-place
if (this.claudeParser) this.claudeParser._lastLines = null;
if (this.codexParser) this.codexParser._lastLines = null;
this.scheduleRender();
}
setCodexMode(active) {
const changed = this.codexMode !== active;
this.codexMode = active;
if (active) {
if (!this.codexParser) this.codexParser = new CodexParser();
if (!this.codexRenderer) this.codexRenderer = new CodexRenderer(this.el);
if (changed && this.lines.length > 0) this.scheduleRender();
} else {
if (this.codexRenderer) this.codexRenderer.clear();
}
}
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(() => {
try { this.render(); } catch (e) { console.error('[render]', e); }
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() {
const container = this.el.parentElement;
// Measure the real scroll position BEFORE mutating the DOM. Only auto-follow
// if the explicit gate is on AND the view is actually at the bottom right now.
// Reading the live DOM here (instead of a scroll-event-derived cache) avoids
// the stale-read race that yanked scrolled-up users back to the bottom.
const wasAtBottom =
container.scrollHeight - container.scrollTop - container.clientHeight < 40;
const follow = this.autoScroll && wasAtBottom;
if (this.codexMode && this.codexParser && this.codexRenderer) {
const doc = this.codexParser.parse(this.lines);
this.codexRenderer.render(doc);
if (follow) container.scrollTop = container.scrollHeight;
return;
}
if (this.claudeMode && this.claudeParser && this.claudeRenderer) {
const doc = this.claudeParser.parse(this.lines);
this.claudeRenderer.render(doc);
if (follow) 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 (follow) 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;