class TerminalView { constructor(element) { this.el = element; this.lines = []; this.renderPending = false; this.autoScroll = true; // 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); } this.scheduleRender(); } 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() { 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 const fontSize = Math.max(3, Math.floor(containerWidth / (maxLen * 0.62))); for (const qline of qrBlock) { const esc = qline .replace(/&/g, '&') .replace(//g, '>'); output.push(`${esc}`); } } 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) { // Escape HTML entities first let escaped = line .replace(/&/g, '&') .replace(//g, '>'); // Apply highlighting patterns (order matters — more specific first) // Diff: added lines (starts with +) if (/^\+[^+]/.test(line)) { return `${escaped}`; } // Diff: removed lines (starts with -) if (/^-[^-]/.test(line)) { return `${escaped}`; } // Tool use markers (blue circle) escaped = escaped.replace( /(\u23FA|\u25CF|\u2B24)/g, '$1' ); // Thinking markers escaped = escaped.replace( /(\u273B)/g, '$1' ); // Output indent marker escaped = escaped.replace( /(\u23BF)/g, '$1' ); // Prompt marker escaped = escaped.replace( /(\u276F)/g, '$1' ); // Success markers escaped = escaped.replace( /(\u2713|\u2714|passed|PASS)/g, '$1' ); // Error markers escaped = escaped.replace( /(\u2717|\u2718|error|ERROR|FAIL|failed)/g, '$1' ); // Box drawing characters escaped = escaped.replace( /([\u2500-\u257F\u250C\u2510\u2514\u2518\u251C\u2524\u252C\u2534\u253C]+)/g, '$1' ); // File paths (simplified: word containing / with file extension) escaped = escaped.replace( /(?$1' ); // Line numbers in context (e.g., " 123 |" or ":42:") escaped = escaped.replace( /^(\s*\d+\s*[|:])/, '$1' ); return escaped; } scrollToBottom() { const container = this.el.parentElement; container.scrollTop = container.scrollHeight; this.autoScroll = true; } clear() { this.lines = []; this.el.innerHTML = ''; } } window.TerminalView = TerminalView;