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.

388 lines
12 KiB
JavaScript

This file contains ambiguous Unicode 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.

// 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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
_addCopyButton(container, text) {
container.classList.add('cc-copy-wrap');
const btn = document.createElement('button');
btn.className = 'cc-copy-btn';
btn.innerHTML = '&#x2398;';
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 = '&#x2398;';
}, 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;