// Codex CLI renderer — renders CodexParser output to DOM // Reuses cc-* CSS classes from claude.css (body.codex-mode scope) // No status bar, no mode switching, no selection options class CodexRenderer { constructor(outputEl) { this.outputEl = outputEl; this._lastSignature = null; this._blockEls = null; this._blockSigs = null; } render(doc) { const sig = this._signature(doc.blocks); if (sig !== this._lastSignature) { this._renderBlocks(doc.blocks); this._lastSignature = sig; } } clear() { this.outputEl.innerHTML = ''; this._lastSignature = null; this._blockEls = null; this._blockSigs = null; } // ── Signature-based DOM diffing ────────────────────────────────────────── _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)); if (!this._blockEls || this._blockEls.length !== blocks.length) { this._fullRebuild(blocks, newSigs); return; } 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 { 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; } // ── Block dispatcher ───────────────────────────────────────────────────── _renderBlock(block) { switch (block.type) { case 'prompt': return this._renderPrompt(block); case 'thinking': return this._renderThinking(block); case 'tool-use': return this._renderToolUse(block); case 'tool-result': return this._renderToolResult(block); case 'response': return this._renderResponse(block); case 'table': return this._renderTable(block); case 'diff': return this._renderDiff(block); case 'code': return this._renderCode(block); case 'text': return this._renderText(block); case 'empty': return this._renderEmpty(); default: return null; } } // ── Shared utilities ───────────────────────────────────────────────────── _stripAnsi(s) { return s.replace(/\x1b(?:\[[0-9;?]*[A-Za-z]|\][^\x07\x1b]*(?:\x07|\x1b\\)|.)/g, ''); } _esc(text) { return text.replace(/&/g, '&').replace(//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); } // ── Block renderers ─────────────────────────────────────────────────────── _renderPrompt(block) { const div = document.createElement('div'); div.className = 'cc-prompt'; const firstLine = block.lines[0]; const stripped = this._stripAnsi(firstLine); // Remove › marker const markerMatch = stripped.match(/^(\s*\u203A\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; } _renderThinking(block) { const div = document.createElement('div'); div.className = 'cc-thinking'; // Strip • prefix and show content const text = this._stripAnsi(block.lines[0]).replace(/^\s*\u2022\s*/, ''); div.textContent = text; return div; } _renderToolUse(block) { const div = document.createElement('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 = '•'; // Extract verb from "• Explored" etc. const firstStripped = this._stripAnsi(block.lines[0]).replace(/^\s*\u2022\s*/, ''); const verbMatch = firstStripped.match(/^(\w+)(.*)/); const name = document.createElement('span'); name.className = 'cc-tool-name'; name.textContent = verbMatch ? verbMatch[1] : firstStripped; const args = document.createElement('span'); args.className = 'cc-tool-args'; args.textContent = verbMatch ? (verbMatch[2] || '').trim() : ''; header.appendChild(icon); header.appendChild(name); if (args.textContent) header.appendChild(args); div.appendChild(header); // Sub-bullets (└ ├ lines) const subLines = block.lines.slice(1); if (subLines.length > 0) { const body = document.createElement('div'); body.className = 'cc-tool-body'; for (const line of subLines) { const lineEl = document.createElement('div'); lineEl.className = 'cc-line'; lineEl.innerHTML = ansiToHtml(line); body.appendChild(lineEl); } 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 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); 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; } _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 from first line const content = line === block.lines[0] ? line.replace(/^(\x1b\[[0-9;]*m)*\s*\u2022\s?/, '') : line; lineEl.innerHTML = ansiToHtml(content); div.appendChild(lineEl); } return div; } // Markdown pipe table: | col | col | _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'); // Detect separator row: |---|---:| or |:---:| const isSepRow = s => /^\s*\|[\s|:=-]+\|\s*$/.test(s.replace(/[^|:\-= ]/g, ' ')); let headerDone = false; for (let i = 0; i < block.lines.length; i++) { const stripped = this._stripAnsi(block.lines[i]).trim(); if (!stripped.startsWith('|')) continue; // Check if NEXT line is a separator → this is header const nextStripped = i + 1 < block.lines.length ? this._stripAnsi(block.lines[i + 1]).trim() : ''; const nextIsSep = isSepRow(nextStripped); if (isSepRow(stripped)) { headerDone = true; continue; } const cells = stripped.split('|').slice(1, -1); const isHeader = !headerDone && nextIsSep; const tr = document.createElement('tr'); for (const cell of cells) { const cellEl = document.createElement(isHeader ? 'th' : 'td'); cellEl.innerHTML = ansiToHtml(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; } _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; } _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; } _renderEmpty() { const div = document.createElement('div'); div.className = 'cc-empty'; return div; } } window.CodexRenderer = CodexRenderer;