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;