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.

454 lines
14 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.

class ClaudeRenderer {
constructor(outputEl) {
this.outputEl = outputEl;
this._lastSignature = null;
this._onAction = null;
this._blockEls = null;
this._blockSigs = null;
this.statusBarEl = document.createElement('div');
this.statusBarEl.className = 'cc-status-bar';
}
setOnAction(fn) {
this._onAction = fn;
}
render(doc) {
const sig = this._signature(doc.blocks);
if (sig !== this._lastSignature) {
this._renderBlocks(doc.blocks);
this._lastSignature = sig;
}
this._renderStatusBar(doc.statusBar);
}
clear() {
this.outputEl.innerHTML = '';
if (this.statusBarEl.parentNode) this.statusBarEl.parentNode.removeChild(this.statusBarEl);
this._lastSignature = null;
this._blockEls = null;
this._blockSigs = null;
}
_blockSig(block) {
return `${block.type}:${block.lines.length}:${block.lines[0] || ''}`;
}
_signature(blocks) {
return blocks.map(b => this._blockSig(b)).join(',');
}
_renderBlocks(blocks) {
const newSigs = blocks.map(b => this._blockSig(b));
// Full rebuild on first render or block count change
if (!this._blockEls || this._blockEls.length !== blocks.length) {
this._fullRebuild(blocks, newSigs);
return;
}
// Per-block update: only replace DOM nodes that changed
for (let i = 0; i < blocks.length; i++) {
if (newSigs[i] === this._blockSigs[i]) continue;
const newEl = this._renderBlock(blocks[i]);
const oldEl = this._blockEls[i];
if (newEl && oldEl && oldEl.parentNode) {
oldEl.parentNode.replaceChild(newEl, oldEl);
this._blockEls[i] = newEl;
} else if (!newEl && !oldEl) {
// both null, nothing to do
} else {
// mismatch — fall back to full rebuild
this._fullRebuild(blocks, newSigs);
return;
}
this._blockSigs[i] = newSigs[i];
}
}
_fullRebuild(blocks, sigs) {
const frag = document.createDocumentFragment();
const els = [];
for (const block of blocks) {
const el = this._renderBlock(block);
els.push(el);
if (el) frag.appendChild(el);
}
this.outputEl.innerHTML = '';
this.outputEl.appendChild(frag);
this._blockEls = els;
this._blockSigs = sigs;
}
_renderBlock(block) {
switch (block.type) {
case 'prompt': return this._renderPrompt(block);
case 'tool-use': return this._renderToolUse(block);
case 'tool-result': return this._renderToolResult(block);
case 'response': return this._renderResponse(block);
case 'code': return this._renderCode(block);
case 'table': return this._renderTable(block);
case 'diff': return this._renderDiff(block);
case 'selection': return this._renderSelection(block);
case 'text': return this._renderText(block);
case 'empty': return this._renderEmpty();
default: return null;
}
}
_esc(text) {
return text
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
}
// Pattern-based coloring for a single line (no ANSI codes from server)
_renderLine(line) {
const stripped = line.replace(/\x1b\[[0-9;]*[A-Za-z]/g, '');
let e = this._esc(stripped);
// Dingbats — spinner/thinking characters (✻ ✳ ✢ ✦ etc.)
e = e.replace(/([\u2700-\u27BF])/g, '<span style="color:var(--yellow)">$1</span>');
return e;
}
_renderPrompt(block) {
const div = document.createElement('div');
div.className = block.isActive ? 'cc-prompt cc-prompt-active' : 'cc-prompt';
const firstLine = block.lines[0];
const stripped = firstLine.replace(/\x1b\[[0-9;]*[A-Za-z]/g, '');
const markerMatch = stripped.match(/^(\s*\u276F\s*)/);
const text = markerMatch ? firstLine.slice(markerMatch[1].length) : firstLine;
const marker = document.createElement('span');
marker.className = 'cc-prompt-marker';
marker.textContent = '';
const textEl = document.createElement('span');
textEl.className = 'cc-prompt-text';
textEl.innerHTML = ansiToHtml(text);
div.appendChild(marker);
div.appendChild(textEl);
for (let i = 1; i < block.lines.length; i++) {
const cont = document.createElement('div');
cont.className = 'cc-prompt-cont';
cont.innerHTML = ansiToHtml(block.lines[i]);
div.appendChild(cont);
}
return div;
}
_renderToolUse(block) {
const div = document.createElement('div');
const firstLine = block.lines[0];
// Match: ⏺ ToolName(args) or ⏺ ToolName args
const match = firstLine.match(/[\u23FA\u25CF\u2B24]\s*(\w[\w.]*)?(.*)$/);
if (!match || !match[1]) {
// No tool name — this is response text with ⏺ prefix; render as plain text
div.className = 'cc-text';
for (const line of block.lines) {
const lineEl = document.createElement('div');
lineEl.className = 'cc-line';
lineEl.innerHTML = ansiToHtml(line.replace(/^\s+/, ''));
div.appendChild(lineEl);
}
return div;
}
div.className = 'cc-tool-use';
const header = document.createElement('div');
header.className = 'cc-tool-header';
const icon = document.createElement('span');
icon.className = 'cc-tool-icon';
icon.textContent = '⏺';
const name = document.createElement('span');
name.className = 'cc-tool-name';
name.textContent = match[1];
const args = document.createElement('span');
args.className = 'cc-tool-args';
args.textContent = (match[2] || '').trim();
header.appendChild(icon);
header.appendChild(name);
if (match[2] && match[2].trim()) header.appendChild(args);
div.appendChild(header);
if (block.lines.length > 1) {
const body = document.createElement('div');
body.className = 'cc-tool-body';
for (let i = 1; i < block.lines.length; i++) {
const line = document.createElement('div');
line.className = 'cc-line';
line.innerHTML = ansiToHtml(block.lines[i]);
body.appendChild(line);
}
div.appendChild(body);
}
return div;
}
_renderToolResult(block) {
const div = document.createElement('div');
div.className = 'cc-tool-result';
const scrollable = document.createElement('div');
scrollable.className = 'cc-scrollable';
const pre = document.createElement('pre');
// Strip ╭/╰ border lines; for │ content lines, remove the │ markers
// and trailing terminal padding so content is immune to terminal width changes
const stripAnsi = s => s.replace(/\x1b\[[0-9;]*[A-Za-z]/g, '');
const contentLines = [];
for (const line of block.lines) {
const s = stripAnsi(line).trimStart();
if (/^[│┃]/.test(s)) {
// Remove leading ANSI codes + │ + optional space
let cleaned = line.replace(/^(\x1b\[[0-9;]*[A-Za-z])*[│┃]\s?/, '');
// Remove trailing space + │ + trailing ANSI codes
cleaned = cleaned.replace(/\s*[│┃](\x1b\[[0-9;]*[A-Za-z])*\s*$/, '');
// Remove terminal box padding (trailing spaces)
contentLines.push(cleaned.trimEnd());
}
// Skip ╭/╰ border lines
}
pre.innerHTML = contentLines.map(l => ansiToHtml(l)).join('\n');
scrollable.appendChild(pre);
div.appendChild(scrollable);
return div;
}
_renderThinking(block) {
const div = document.createElement('div');
div.className = 'cc-thinking';
// Strip ANSI escape codes — thinking spinner lines are often colorized
// eslint-disable-next-line no-control-regex
div.textContent = block.lines.join('\n').replace(/\x1b\[[0-9;]*[A-Za-z]/g, '');
return div;
}
_renderResponse(block) {
const div = document.createElement('div');
div.className = 'cc-response';
for (const line of block.lines) {
const lineEl = document.createElement('div');
lineEl.className = 'cc-line';
lineEl.innerHTML = this._renderLine(line);
div.appendChild(lineEl);
}
return div;
}
_renderText(block) {
const div = document.createElement('div');
div.className = 'cc-text';
for (const line of block.lines) {
const lineEl = document.createElement('div');
lineEl.className = 'cc-line';
lineEl.innerHTML = this._renderLine(line);
div.appendChild(lineEl);
}
return div;
}
_renderCode(block) {
const div = document.createElement('div');
div.className = 'cc-code';
const scrollable = document.createElement('div');
scrollable.className = 'cc-scrollable';
const pre = document.createElement('pre');
pre.className = 'cc-code-content';
pre.innerHTML = block.lines.map(l => ansiToHtml(l)).join('\n');
scrollable.appendChild(pre);
div.appendChild(scrollable);
return div;
}
_renderTable(block) {
const div = document.createElement('div');
div.className = 'cc-table-wrap';
const scrollable = document.createElement('div');
scrollable.className = 'cc-scrollable';
const table = document.createElement('table');
table.className = 'cc-table';
const thead = document.createElement('thead');
const tbody = document.createElement('tbody');
let headerDone = false;
const stripAnsi = s => s.replace(/\x1b\[[0-9;]*[A-Za-z]/g, '');
const isMiddleBorder = s => /^[]/.test(s);
const isBorderLine = s => /^[]/.test(s);
const isDataLine = s => /^[]/.test(s);
const lines = block.lines;
for (let i = 0; i < lines.length; i++) {
const stripped = stripAnsi(lines[i]).trimStart();
if (isBorderLine(stripped)) {
if (isMiddleBorder(stripped)) headerDone = true;
continue;
}
if (!isDataLine(stripped)) continue;
// Check if the next border is a middle border (making this row a header)
let nextIsMiddleBorder = false;
if (!headerDone) {
for (let j = i + 1; j < lines.length; j++) {
const ns = stripAnsi(lines[j]).trimStart();
if (!ns) continue;
if (isMiddleBorder(ns)) nextIsMiddleBorder = true;
break;
}
}
const isHeader = !headerDone && nextIsMiddleBorder;
const cells = stripped.split(/[│┃]/).slice(1, -1);
const tr = document.createElement('tr');
for (const cell of cells) {
const cellEl = document.createElement(isHeader ? 'th' : 'td');
cellEl.textContent = cell.trim();
tr.appendChild(cellEl);
}
if (isHeader) {
thead.appendChild(tr);
} else {
tbody.appendChild(tr);
}
}
if (thead.children.length > 0) table.appendChild(thead);
table.appendChild(tbody);
scrollable.appendChild(table);
div.appendChild(scrollable);
return div;
}
_renderDiff(block) {
const div = document.createElement('div');
div.className = 'cc-diff';
const scrollable = document.createElement('div');
scrollable.className = 'cc-scrollable';
const content = document.createElement('div');
content.className = 'cc-diff-content';
for (const line of block.lines) {
const span = document.createElement('span');
span.className = 'cc-diff-line';
if (/^\+(?!\+)/.test(line)) span.classList.add('cc-added');
else if (/^-(?!-)/.test(line)) span.classList.add('cc-removed');
else if (/^@@/.test(line)) span.classList.add('cc-hunk');
span.innerHTML = ansiToHtml(line);
content.appendChild(span);
content.appendChild(document.createTextNode('\n'));
}
scrollable.appendChild(content);
div.appendChild(scrollable);
return div;
}
_renderSelection(block) {
const div = document.createElement('div');
div.className = 'cc-selection-options';
if (block.options && block.options.length > 0) {
for (const opt of block.options) {
const btn = document.createElement('button');
btn.className = 'cc-option';
btn.dataset.key = opt.key;
const indexEl = document.createElement('span');
indexEl.className = 'cc-option-index';
indexEl.textContent = opt.index;
const labelEl = document.createElement('span');
labelEl.className = 'cc-option-label';
labelEl.textContent = opt.label;
btn.appendChild(indexEl);
btn.appendChild(labelEl);
btn.addEventListener('click', () => {
if (this._onAction) this._onAction('select', opt.key);
});
div.appendChild(btn);
}
} else {
// Fallback: render lines as text
for (const line of block.lines) {
const lineEl = document.createElement('div');
lineEl.className = 'cc-line';
lineEl.textContent = line;
div.appendChild(lineEl);
}
}
return div;
}
_renderEmpty() {
const div = document.createElement('div');
div.className = 'cc-empty';
return div;
}
_renderStatusBar(statusBar) {
if (!statusBar || !this.statusBarEl) return;
this.statusBarEl.innerHTML = '';
if (statusBar.mode) {
const pill = document.createElement('button');
pill.className = `cc-mode-pill cc-mode-${statusBar.mode}`;
const icons = { plan: '⏸', code: '●', bypass: '⏵' };
const labels = { plan: 'Plan', code: 'Code', bypass: 'Bypass' };
pill.textContent = `${icons[statusBar.mode] || ''} ${labels[statusBar.mode] || statusBar.mode}`;
pill.addEventListener('click', () => {
if (this._onAction) this._onAction('mode-toggle', statusBar.mode);
});
this.statusBarEl.appendChild(pill);
}
if (statusBar.branch) {
const branch = document.createElement('span');
branch.className = 'cc-status-branch';
branch.textContent = statusBar.branch;
this.statusBarEl.appendChild(branch);
}
if (statusBar.stats) {
const stats = document.createElement('span');
stats.className = 'cc-status-stats';
stats.textContent = statusBar.stats;
this.statusBarEl.appendChild(stats);
}
}
}
window.ClaudeRenderer = ClaudeRenderer;