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