${esc(ev.output)}${rows}${esc(ev.command)}` : '';
node.innerHTML = `+
block + Instruction: text
const rawDocMatch = b.innerHTML.match(/In the document, edit this specific text \((lines? [\d–\-]+)\)/);
if (rawDocMatch) {
const lineRef = rawDocMatch[1];
// Extract instruction text (after "Instruction: ")
const instrMatch = b.textContent.match(/Instruction:\s*([\s\S]*)$/);
const instrText = instrMatch ? instrMatch[1].trim() : '';
b.innerHTML = 'Doc edit: ' + lineRef + ' ' + markdownModule.processWithThinking(instrText);
}
// Render attachment cards
if (attachments?.length) {
b.appendChild(buildAttachCards(attachments));
}
}
wrap.appendChild(r);
wrap.appendChild(b);
// Add stopped indicator + continue button for messages that were stopped by user
if (role === 'assistant' && metadata?.stopped) {
const stoppedIndicator = document.createElement('div');
stoppedIndicator.className = 'stopped-indicator';
const stoppedLabel = document.createElement('span');
// Differentiate between "stopped mid-stream" (had content, can continue)
// and "cancelled before any content" — the latter has no Continue affordance.
stoppedLabel.textContent = metadata.cancelled
? '[Cancelled by user]'
: '[Message interrupted]';
stoppedIndicator.appendChild(stoppedLabel);
// Continue button only makes sense when there's partial content to
// resume from \u2014 skip it for fully-cancelled (empty) turns.
if (!metadata.cancelled) {
const continueBtn = document.createElement('button');
continueBtn.className = 'continue-btn';
continueBtn.title = 'Continue';
continueBtn.textContent = '\u25B8';
continueBtn.addEventListener('click', () => {
stoppedIndicator.remove();
if (window.chatModule) {
window.chatModule.setHideUserBubble();
window.chatModule.setPendingContinue(wrap);
const rawText = wrap.dataset.raw || wrap.querySelector('.body')?.textContent || '';
const cutoff = rawText;
const msgInput = document.getElementById('message');
if (msgInput) {
msgInput.value = 'Your previous response was interrupted. It ended with:\n\n' + cutoff.slice(-500) + '\n\nDo NOT repeat what you already said. Continue exactly from where you were cut off.';
const sb = document.querySelector('.send-btn');
if (sb) sb.click();
}
}
});
stoppedIndicator.appendChild(continueBtn);
}
b.appendChild(stoppedIndicator);
}
if (metadata?.edited) {
const editedIndicator = document.createElement('div');
editedIndicator.className = 'edited-indicator';
editedIndicator.textContent = '[Message edited]';
b.appendChild(editedIndicator);
}
// Restore variant navigation from saved metadata
if (role === 'assistant' && metadata?.variants && metadata.variants.length > 1) {
wrap.dataset.variants = JSON.stringify(metadata.variants);
const idx = metadata.variantIndex ?? metadata.variants.length - 1;
wrap.dataset.variantIndex = String(idx);
// Re-render from `raw` markdown rather than trusting cached `v.html`.
// Variants ride through localStorage / chat export-import; cached HTML
// would let an attacker-controlled session JSON inject markup.
const _renderVariant = (v) => (v && v.raw)
? markdownModule.processWithThinking(markdownModule.squashOutsideCode(v.raw))
: (v && v.html) || '';
// Show the selected variant's content
const v = metadata.variants[idx];
if (v) {
b.innerHTML = _renderVariant(v);
wrap.dataset.raw = v.raw;
}
// Render nav
const nav = document.createElement('span');
nav.className = 'variant-nav';
nav.addEventListener('click', (e) => e.stopPropagation());
const divider = document.createElement('span');
divider.className = 'variant-divider';
divider.textContent = '|';
nav.appendChild(divider);
const tagLabel = document.createElement('span');
const _icons = { regen: '\u21BB', shorter: '\u2702', simpler: '?', original: '\u25CB' };
const _tl0 = metadata.variants[idx]?.label;
tagLabel.className = 'variant-tag' + (_tl0 === 'shorter' ? ' variant-tag-scissors' : '');
tagLabel.textContent = _icons[_tl0] || '';
nav.appendChild(tagLabel);
const prevBtn = document.createElement('button');
prevBtn.className = 'variant-btn';
prevBtn.textContent = '<';
prevBtn.disabled = idx === 0;
nav.appendChild(prevBtn);
const numLeft = document.createElement('button');
numLeft.className = 'variant-num';
numLeft.textContent = String(idx + 1);
numLeft.disabled = idx === 0;
nav.appendChild(numLeft);
const slash = document.createElement('span');
slash.className = 'variant-slash';
slash.textContent = '/';
nav.appendChild(slash);
const numRight = document.createElement('button');
numRight.className = 'variant-num';
numRight.textContent = String(metadata.variants.length);
numRight.disabled = idx === metadata.variants.length - 1;
nav.appendChild(numRight);
const nextBtn = document.createElement('button');
nextBtn.className = 'variant-btn';
nextBtn.textContent = '>';
nextBtn.disabled = idx === metadata.variants.length - 1;
nav.appendChild(nextBtn);
const switchFn = (newIdx) => {
const vars = metadata.variants;
if (newIdx < 0 || newIdx >= vars.length) return;
const sv = vars[newIdx];
b.innerHTML = _renderVariant(sv);
wrap.dataset.raw = sv.raw;
wrap.dataset.variantIndex = String(newIdx);
if (window.hljs) wrap.querySelectorAll('pre code').forEach(bl => window.hljs.highlightElement(bl));
tagLabel.textContent = _icons[sv.label] || '';
tagLabel.className = 'variant-tag' + (sv.label === 'shorter' ? ' variant-tag-scissors' : '');
numLeft.textContent = String(newIdx + 1);
numLeft.disabled = newIdx === 0;
numRight.disabled = newIdx === vars.length - 1;
prevBtn.disabled = newIdx === 0;
nextBtn.disabled = newIdx === vars.length - 1;
};
prevBtn.addEventListener('click', (e) => { e.stopPropagation(); switchFn(parseInt(wrap.dataset.variantIndex) - 1); });
numLeft.addEventListener('click', (e) => { e.stopPropagation(); switchFn(parseInt(wrap.dataset.variantIndex) - 1); });
numRight.addEventListener('click', (e) => { e.stopPropagation(); switchFn(parseInt(wrap.dataset.variantIndex) + 1); });
nextBtn.addEventListener('click', (e) => { e.stopPropagation(); switchFn(parseInt(wrap.dataset.variantIndex) + 1); });
r.appendChild(nav);
}
if (role === 'assistant') {
// The "N pinned" / "N recalled" pill in the footer reads from
// wrap._memoriesUsed — propagate it from saved metadata so the pill
// survives a page refresh (live-stream path sets it via SSE, but
// history reloads need this assignment).
if (metadata?.memories_used?.length) wrap._memoriesUsed = metadata.memories_used;
wrap.appendChild(createMsgFooter(wrap));
if (metadata) displayMetrics(wrap, metadata);
} else {
// Add timestamp to user header (like AI messages)
r.appendChild(roleTimestamp(metadata?.timestamp, metadata?.mode));
wrap.appendChild(createUserMsgFooter(wrap));
}
box.appendChild(wrap);
// TTS is now part of the msg-actions system
if (role === 'assistant' && markdownModule.renderMermaid) {
markdownModule.renderMermaid(wrap);
}
return wrap;
} catch (error) {
console.error('Error in addMessage:', error);
if (uiModule) uiModule.showError('Failed to add message: ' + error.message);
}
}
const chatRenderer = {
shortModel,
sameModelName,
modelRouteLabel,
replyModelPair,
modelColor,
applyModelColor,
getModelCost,
isCostTrackedEndpoint,
isSubscriptionEndpoint,
getImageCost,
getSessionCost,
resetSessionCost,
updateSessionCostUI,
roleTimestamp,
stripToolBlocks,
safeToolScreenshotSrc,
safeDisplayImageSrc,
buildSourcesBox,
buildFindingsBox,
appendReportButton,
buildImageBubble,
hideWelcomeScreen,
showWelcomeScreen,
createMsgFooter,
displayMetrics,
addMessage,
updateMessageAttachments,
};
export default chatRenderer;