/** Chat area DOM helpers. */
import { marked } from 'https://esm.sh/marked@12';
import hljs from 'https://esm.sh/highlight.js@11';
import { esc, timeLabel, formatBytes } from './utils.js';
// ── Markdown setup ────────────────────────────────────────────────────────────
marked.use({
gfm: true,
breaks: true,
});
function renderMarkdown(text) {
const div = document.createElement('div');
div.className = 'prose';
div.innerHTML = marked.parse(text);
div.querySelectorAll('pre code').forEach(el => hljs.highlightElement(el));
return div;
}
// ── Tool icons ────────────────────────────────────────────────────────────────
const TOOL_ICONS = {
web_search: '🔍',
web_view: '🌍',
filesystem: '📁',
http_request: '🌐',
code_exec: '⚙️',
terminal: '💻',
ssh_exec: '🖧',
image_view: '🖼️',
spawn_agent: '🤖',
memory_search: '🧠',
memory_forget: '🗑️',
write_tool: '✏️',
reload_tools: '🔄',
list_tools: '📋',
tool_manual: '📖',
};
// ── Public API ────────────────────────────────────────────────────────────────
/** File icon by extension/name fallback. */
function fileIconByName(name) {
const ext = (name.split('.').pop() ?? '').toLowerCase();
if (['jpg','jpeg','png','gif','webp','bmp'].includes(ext)) return '🖼️';
if (ext === 'svg') return '🎨';
if (['mp4','mkv','avi','mov','webm'].includes(ext)) return '🎬';
if (['mp3','wav','ogg','flac','m4a'].includes(ext)) return '🎵';
if (ext === 'pdf') return '📄';
if (['zip','tar','gz','bz2','7z','rar'].includes(ext)) return '🗜️';
if (['json','xml','yaml','yml','toml','ini','env'].includes(ext)) return '⚙️';
if (['js','ts','py','rs','go','java','c','cpp','h','rb','php','sh'].includes(ext)) return '💻';
if (['md','txt','log','csv'].includes(ext)) return '📝';
return '📎';
}
/** Parse and strip the [Uploaded files on disk:] block injected by the server. */
function extractFileBlock(content) {
if (!content) return { text: content, files: null };
const match = content.match(/\n\n\[Uploaded files on disk:\n([\s\S]*?)\]$/);
if (!match) return { text: content, files: null };
const text = content.slice(0, content.length - match[0].length) || null;
const files = match[1]
.split('\n')
.filter(l => l.startsWith('- '))
.map(l => {
const m = l.match(/^- (.+?) → (.+)$/);
return m ? { name: m[1], path: m[2] } : null;
})
.filter(Boolean);
return { text, files: files.length ? files : null };
}
/**
* Append a complete message bubble (used for history and user messages).
* Assistant messages are rendered as markdown; user messages as plain text.
* Pass images (array of base64 strings) to render them in the bubble.
* Pass files (array of {name, size?, path?, content_type?}) to render file badges.
* Returns the bubble element.
*/
export function appendMessage(el, role, content, images = null, timestamp = null, files = null) {
const wrap = document.createElement('div');
wrap.className = `msg ${role}`;
const bubble = document.createElement('div');
bubble.className = 'bubble';
// For user messages: extract file block from content if files not provided directly
let displayContent = content;
let displayFiles = files;
if (role === 'user') {
const extracted = extractFileBlock(content);
displayContent = extracted.text;
if (!displayFiles && extracted.files) displayFiles = extracted.files;
}
if (images?.length) {
const imgStrip = document.createElement('div');
imgStrip.className = 'bubble-images';
for (const b64 of images) {
const img = document.createElement('img');
img.src = b64.startsWith('data:') ? b64 : `data:image/jpeg;base64,${b64}`;
img.className = 'bubble-img';
img.alt = 'attached image';
imgStrip.appendChild(img);
}
bubble.appendChild(imgStrip);
}
if (role === 'assistant') {
bubble.appendChild(renderMarkdown(content));
} else if (displayContent) {
const text = document.createElement('span');
text.textContent = displayContent;
bubble.appendChild(text);
}
if (displayFiles?.length) {
const strip = document.createElement('div');
strip.className = 'bubble-files';
for (const f of displayFiles) {
const badge = document.createElement('div');
badge.className = 'file-badge';
const icon = document.createElement('span');
icon.className = 'file-badge-icon';
icon.textContent = fileIconByName(f.name);
const info = document.createElement('div');
info.className = 'file-badge-info';
const nameEl = document.createElement('div');
nameEl.className = 'file-badge-name';
nameEl.title = f.name;
nameEl.textContent = f.name;
info.appendChild(nameEl);
if (f.size) {
const sizeEl = document.createElement('div');
sizeEl.className = 'file-badge-size';
sizeEl.textContent = formatBytes(f.size);
info.appendChild(sizeEl);
}
badge.append(icon, info);
strip.appendChild(badge);
}
bubble.appendChild(strip);
}
const time = document.createElement('div');
time.className = 'msg-time';
time.textContent = timeLabel(timestamp ?? new Date().toISOString());
wrap.append(bubble, time);
el.appendChild(wrap);
return bubble;
}
/**
* Called during streaming: bubble shows raw text with a cursor.
* Returns the bubble element — caller appends deltas via textContent.
*/
export function appendStreamBubble(el) {
const wrap = document.createElement('div');
wrap.className = 'msg assistant';
const bubble = document.createElement('div');
bubble.className = 'bubble cursor';
const time = document.createElement('div');
time.className = 'msg-time';
time.textContent = 'just now';
wrap.append(bubble, time);
el.appendChild(wrap);
return bubble;
}
/**
* Called on stream_end: replaces raw text with rendered markdown.
*/
export function finalizeStreamBubble(bubble, content) {
bubble.classList.remove('cursor');
bubble.textContent = '';
bubble.appendChild(renderMarkdown(content));
}
/** Build an args grid element from a {key:val} object. Returns null if empty. */
function buildArgsEl(args) {
const entries = Object.entries(args ?? {});
if (!entries.length) return null;
const div = document.createElement('div');
div.className = 'tool-args';
div.innerHTML = entries
.map(([k, v]) => `<span class="arg-key">${esc(k)}</span><span class="arg-val">${esc(JSON.stringify(v))}</span>`)
.join('');
return div;
}
/**
* Create a pending tool card (spinner, no result yet).
* Returns the card element — pass to finalizeToolCard() when done.
*/
export function appendPendingToolCard(el, event) {
const icon = TOOL_ICONS[event.tool] ?? '🔧';
const card = document.createElement('div');
card.className = 'tool-card pending';
const header = document.createElement('div');
header.className = 'tool-header';
header.innerHTML = `
<span class="tool-icon">${icon}</span>
<span class="tool-name">${esc(event.tool)}</span>
<span class="tool-status"><span class="spinner-inline"></span></span>`;
// For spawn_agent: open body immediately to show sub-agent log as it streams
const body = document.createElement('div');
body.className = event.tool === 'spawn_agent' ? 'tool-body tool-body-open' : 'tool-body';
const argsEl = buildArgsEl(event.args);
if (argsEl) body.appendChild(argsEl);
if (event.tool === 'spawn_agent') {
const log = document.createElement('div');
log.className = 'subagent-log';
body.appendChild(log);
card._subagentLog = log;
}
card.append(header, body);
el.appendChild(card);
return card;
}
/**
* Fill in a pending card with the completed result.
*/
export function finalizeToolCard(card, event) {
const success = event.success;
card.classList.remove('pending');
if (!success) card.classList.add('error');
const statusEl = card.querySelector('.tool-status');
if (statusEl) statusEl.innerHTML = success ? '✓' : '✗';
const body = card.querySelector('.tool-body');
if (body) {
body.classList.remove('tool-body-open');
// Strip the "[Sub-agent result — ...]" reminder prefix before showing to user
const result = event.result.replace(/^\[Sub-agent result[^\]]*\]\n\n/, '');
const pre = document.createElement('pre');
pre.className = 'tool-result-pre';
pre.textContent = result;
body.appendChild(pre);
}
const header = card.querySelector('.tool-header');
if (header) header.addEventListener('click', () => card.classList.toggle('open'));
}
/**
* Append a pending sub-agent step inside a spawn_agent card.
* Returns the step element — pass to finalizeSubagentStep() when done.
*/
export function appendSubagentStep(card, event) {
const log = card._subagentLog;
if (!log) return null;
const icon = TOOL_ICONS[event.tool] ?? '🔧';
const step = document.createElement('div');
step.className = 'subagent-step pending';
step.innerHTML = `
<span class="step-arrow">↳</span>
<span class="step-icon">${icon}</span>
<span class="step-name">${esc(event.tool)}</span>
<span class="step-status"><span class="spinner-inline"></span></span>`;
log.appendChild(step);
return step;
}
/**
* Mark a sub-agent step as complete.
*/
export function finalizeSubagentStep(step, event) {
if (!step) return;
step.classList.remove('pending');
if (!event.success) step.classList.add('error');
const statusEl = step.querySelector('.step-status');
if (statusEl) statusEl.innerHTML = event.success ? '✓' : '✗';
}
/**
* Tool call card from history — complete, collapsed by default.
*/
export function appendToolCall(el, event) {
const icon = TOOL_ICONS[event.tool] ?? '🔧';
const success = event.success;
const card = document.createElement('div');
card.className = `tool-card${success ? '' : ' error'}`;
const header = document.createElement('div');
header.className = 'tool-header';
header.innerHTML = `
<span class="tool-icon">${icon}</span>
<span class="tool-name">${esc(event.tool)}</span>
<span class="tool-status">${success ? '✓' : '✗'}</span>`;
const body = document.createElement('div');
body.className = 'tool-body';
const argsEl = buildArgsEl(event.args);
if (argsEl) body.appendChild(argsEl);
const pre = document.createElement('pre');
pre.className = 'tool-result-pre';
pre.textContent = event.result;
body.appendChild(pre);
header.addEventListener('click', () => card.classList.toggle('open'));
card.append(header, body);
el.appendChild(card);
}
/**
* Create a thinking block (open by default, collapses on finalizeThinkingCard).
* Returns {card, pre} — caller appends thinking text to pre.textContent.
*/
export function appendThinkingCard(el) {
const card = document.createElement('div');
card.className = 'thinking-card open';
const header = document.createElement('div');
header.className = 'thinking-header';
header.innerHTML = '<span class="thinking-icon">💭</span><span class="thinking-label">Thinking…</span>';
const body = document.createElement('div');
body.className = 'thinking-body';
const pre = document.createElement('pre');
pre.className = 'thinking-pre';
body.appendChild(pre);
header.addEventListener('click', () => card.classList.toggle('open'));
card.append(header, body);
el.appendChild(card);
return { card, pre };
}
/**
* Called on thinking_end: update label and collapse the card.
*/
export function finalizeThinkingCard(card) {
const label = card.querySelector('.thinking-label');
if (label) label.textContent = 'Thought';
card.classList.remove('open');
}
export function appendTypingIndicator(el) {
removeTypingIndicator(el);
const div = document.createElement('div');
div.className = 'typing';
div.id = 'typing-indicator';
div.innerHTML = '<span></span><span></span><span></span>';
el.appendChild(div);
}
export function removeTypingIndicator(el) {
el.querySelector('#typing-indicator')?.remove();
}
export function appendError(el, message) {
const div = document.createElement('div');
div.className = 'msg-error';
div.textContent = `Error: ${message}`;
el.appendChild(div);
}
export function showEmptyState(el) {
el.innerHTML = `
<div class="empty-chat">
<div class="icon">💬</div>
<p>Start a new conversation</p>
</div>`;
}
/**
* Summary card — rendered for is_summary messages loaded from history.
* Collapsed by default, click to expand.
*/
export function appendSummaryCard(el, content) {
const text = content.replace(/^\[Context Summary\]\n?/, '');
const card = document.createElement('div');
card.className = 'summary-card';
const header = document.createElement('div');
header.className = 'summary-header';
header.innerHTML = '<span class="summary-icon">📋</span><span>Context Summary</span>';
const body = document.createElement('div');
body.className = 'summary-body';
body.appendChild(renderMarkdown(text));
header.addEventListener('click', () => card.classList.toggle('open'));
card.append(header, body);
el.appendChild(card);
}
/**
* Inline notice that compression ran — appended to the message list.
*/
export function appendCompressionNotice(el) {
const div = document.createElement('div');
div.className = 'compression-notice';
div.textContent = '↑ Older messages summarized to free context space';
el.appendChild(div);
}
/**
* Intermediate text the model emitted alongside tool calls (not the final response).
* Displayed as a subtle note between tool cards.
*/
export function appendAgentNote(el, text) {
const div = document.createElement('div');
div.className = 'agent-note';
div.textContent = text;
el.appendChild(div);
return div;
}
/**
* Sub-agent note — text emitted alongside tool calls inside run_ephemeral.
* Rendered as a text line inside the spawn_agent card's subagent log.
*/
export function appendSubagentNote(card, text) {
const log = card?._subagentLog;
if (!log) return;
const div = document.createElement('div');
div.className = 'subagent-note';
div.textContent = text;
log.appendChild(div);
}
/**
* Thinking block from a tool-calling turn (complete() — full text, not streaming).
* Rendered collapsed — content is already complete when received.
*/
export function appendTurnThinkingCard(el, thinking) {
const card = document.createElement('div');
card.className = 'thinking-card'; // collapsed by default (no 'open')
const header = document.createElement('div');
header.className = 'thinking-header';
header.innerHTML = '<span class="thinking-icon">💭</span><span class="thinking-label">Thought</span>';
const body = document.createElement('div');
body.className = 'thinking-body';
const pre = document.createElement('pre');
pre.className = 'thinking-pre';
pre.textContent = thinking;
body.appendChild(pre);
header.addEventListener('click', () => card.classList.toggle('open'));
card.append(header, body);
el.appendChild(card);
return card;
}
/**
* Sub-agent thinking block — collapsible block inside the spawn_agent subagent log.
*/
export function appendSubagentThinking(card, thinking) {
const log = card?._subagentLog;
if (!log) return;
const block = document.createElement('details');
block.className = 'subagent-thinking';
const summary = document.createElement('summary');
summary.textContent = '💭 Thought';
const pre = document.createElement('pre');
pre.className = 'subagent-thinking-pre';
pre.textContent = thinking;
block.append(summary, pre);
log.appendChild(block);
}
export function scrollToBottom(el) {
el.scrollTop = el.scrollHeight;
}