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.

530 lines
17 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;
}
_stripAnsi(s) {
return s.replace(/\x1b(?:\[[0-9;?]*[A-Za-z]|\][^\x07\x1b]*(?:\x07|\x1b\\)|.)/g, '');
}
_addCopyButton(container, text) {
container.classList.add('cc-copy-wrap');
const btn = document.createElement('button');
btn.className = 'cc-copy-btn';
btn.innerHTML = '⎘';
btn.title = 'Copy';
btn.addEventListener('click', (e) => {
e.stopPropagation();
navigator.clipboard.writeText(text).then(() => {
btn.classList.add('copied');
btn.textContent = '\u2713';
setTimeout(() => {
btn.classList.remove('copied');
btn.innerHTML = '⎘';
}, 1500);
});
});
container.appendChild(btn);
}
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 'thinking': return this._renderThinking(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 = this._stripAnsi(line);
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 = this._stripAnsi(firstLine);
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);
const bodyLineCount = block.lines.length - 1;
if (bodyLineCount > 5) {
div.classList.add('cc-collapsible');
const toggle = document.createElement('button');
toggle.className = 'cc-collapse-toggle';
toggle.textContent = `Show all (${bodyLineCount} lines)`;
toggle.addEventListener('click', () => {
const expanded = div.classList.toggle('cc-expanded');
toggle.textContent = expanded ? 'Show less' : `Show all (${bodyLineCount} lines)`;
});
div.appendChild(toggle);
}
}
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 contentLines = [];
for (const line of block.lines) {
const s = this._stripAnsi(line).trimStart();
if (/^[│┃║]/.test(s)) {
let cleaned = line.replace(/^(?:\x1b(?:\[[0-9;?]*[A-Za-z]|\][^\x07\x1b]*(?:\x07|\x1b\\)|.))*[│┃║]\s?/, '');
cleaned = cleaned.replace(/\s*[│┃║](?:\x1b(?:\[[0-9;?]*[A-Za-z]|\][^\x07\x1b]*(?:\x07|\x1b\\)|.))*\s*$/, '');
contentLines.push(cleaned.trimEnd());
}
// Skip ╭/╰ border lines
}
pre.innerHTML = contentLines.map(l => ansiToHtml(l)).join('\n');
scrollable.appendChild(pre);
div.appendChild(scrollable);
// Collapsible for long results
if (contentLines.length > 8) {
div.classList.add('cc-collapsible');
const toggle = document.createElement('button');
toggle.className = 'cc-collapse-toggle';
toggle.textContent = `Show all (${contentLines.length} lines)`;
toggle.addEventListener('click', () => {
const expanded = div.classList.toggle('cc-expanded');
toggle.textContent = expanded ? 'Show less' : `Show all (${contentLines.length} lines)`;
});
div.appendChild(toggle);
}
this._addCopyButton(div, contentLines.map(l => this._stripAnsi(l)).join('\n'));
return div;
}
_renderThinking(block) {
const div = document.createElement('div');
div.className = 'cc-thinking';
div.textContent = this._stripAnsi(block.lines.join('\n'));
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';
// Strip ⎿ prefix marker, preserve ANSI colors
const content = line.replace(/^(\x1b\[[0-9;]*m)*\s*\u23BF\s?/, '');
lineEl.innerHTML = ansiToHtml(content);
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 = ansiToHtml(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);
this._addCopyButton(div, block.lines.map(l => this._stripAnsi(l)).join('\n'));
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 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 = this._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 = this._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);
this._addCopyButton(div, block.lines.map(l => this._stripAnsi(l)).join('\n'));
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' + (opt.focused ? ' cc-option-focused' : '');
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;
const hint = document.createElement('span');
hint.className = 'cc-option-hint';
hint.textContent = `${opt.key}`;
btn.appendChild(indexEl);
btn.appendChild(labelEl);
btn.appendChild(hint);
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 icons = { plan: '⏸', code: '●', bypass: '⏵' };
const labels = { plan: 'Plan', code: 'Code', bypass: 'Bypass' };
const modes = ['plan', 'code', 'bypass'];
const modeGroup = document.createElement('div');
modeGroup.className = 'cc-mode-group';
for (const mode of modes) {
const pill = document.createElement('button');
pill.className = `cc-mode-pill cc-mode-${mode}`;
if (mode === statusBar.mode) pill.classList.add('cc-mode-active');
pill.textContent = `${icons[mode]} ${labels[mode]}`;
pill.addEventListener('click', () => {
if (this._onAction) this._onAction('mode-toggle', mode);
});
modeGroup.appendChild(pill);
}
this.statusBarEl.appendChild(modeGroup);
}
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 pctMatch = statusBar.stats.match(/(\d+)%/);
if (pctMatch) {
const pct = parseInt(pctMatch[1], 10);
const bar = document.createElement('div');
bar.className = 'cc-context-bar';
const fill = document.createElement('div');
fill.className = 'cc-context-fill';
fill.style.width = `${pct}%`;
if (pct > 80) fill.classList.add('cc-ctx-high');
else if (pct > 50) fill.classList.add('cc-ctx-mid');
bar.appendChild(fill);
this.statusBarEl.appendChild(bar);
}
const stats = document.createElement('span');
stats.className = 'cc-status-stats';
stats.textContent = statusBar.stats;
this.statusBarEl.appendChild(stats);
}
}
}
window.ClaudeRenderer = ClaudeRenderer;