|
|
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, '&')
|
|
|
.replace(/</g, '<')
|
|
|
.replace(/>/g, '>');
|
|
|
}
|
|
|
|
|
|
// 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;
|