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.

551 lines
18 KiB
JavaScript

This file contains invisible Unicode characters!

This file contains invisible Unicode characters that may be processed differently from what appears below. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to reveal hidden 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, '');
}
_dedentRunaway(line) {
// Claude Code's TUI shoves some lines far to the right (often right-aligned to
// the pane edge) when the cmux pane is very wide (e.g. ~185 cols); read-screen
// captures the literal leading spaces, so the parsed reading view renders the
// text pushed off to the right. Prose/output is never legitimately indented this
// far (lists/quotes stay ≤ ~12), so collapse runaway leading whitespace to the
// left. Code / tables / diffs are rendered by other paths and never come here.
return /^[ \t]{16,}\S/.test(line) ? line.replace(/^[ \t]+/, '') : line;
}
_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) {
// Hash the FULL block content, not just the first line. With a first-line-only
// signature, a block whose inner lines change while type/count/first-line stay
// the same (very common while streaming: tool output, multi-line text, a
// selection whose focused option moves) keeps its stale DOM — and because this
// feeds the top-level render() signature gate, a content-only change skips ALL
// rendering, freezing the view ("parsed, then not"). Hashing fixes both layers.
return `${block.type}:${block.lines.length}:${this._hash(block.lines.join(''))}`;
}
_hash(s) {
let h = 5381;
for (let i = 0; i < s.length; i++) h = (((h << 5) + h) ^ s.charCodeAt(i)) | 0;
return h;
}
_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 = '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(this._dedentRunaway(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(this._dedentRunaway(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(this._dedentRunaway(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 = '';
const mode = statusBar.mode || 'ask';
{
const pill = document.createElement('button');
pill.className = `cc-mode-pill cc-mode-${mode}`;
const icons = { plan: '⏸', ask: '●', bypass: '⏵⏵', accept: '✓' };
const labels = { plan: 'Plan', ask: 'Ask', bypass: 'Bypass', accept: 'Accept' };
pill.textContent = `${icons[mode] || '●'} ${labels[mode] || mode}`;
pill.addEventListener('click', () => {
if (this._onAction) this._onAction('mode-toggle', mode);
});
this.statusBarEl.appendChild(pill);
}
if (statusBar.model) {
const model = document.createElement('span');
model.className = 'cc-status-model';
model.textContent = statusBar.model;
this.statusBarEl.appendChild(model);
}
if (statusBar.branch) {
const branch = document.createElement('span');
branch.className = 'cc-status-branch';
branch.textContent = statusBar.branch;
this.statusBarEl.appendChild(branch);
}
if (statusBar.ctxPct != null) {
const bar = document.createElement('div');
bar.className = 'cc-context-bar';
const fill = document.createElement('div');
fill.className = 'cc-context-fill';
fill.style.width = `${statusBar.ctxPct}%`;
if (statusBar.ctxPct < 30) fill.classList.add('cc-ctx-low');
else if (statusBar.ctxPct < 60) fill.classList.add('cc-ctx-mid');
bar.appendChild(fill);
this.statusBarEl.appendChild(bar);
}
if (statusBar.stats) {
const stats = document.createElement('span');
stats.className = 'cc-status-stats';
stats.textContent = statusBar.stats;
this.statusBarEl.appendChild(stats);
}
}
}
window.ClaudeRenderer = ClaudeRenderer;