|
|
// 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, '<').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;
|