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, '&') .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, '$1'); 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;