/** Chat area DOM helpers. */
import { marked } from 'https://esm.sh/marked@12';
import hljs from 'https://esm.sh/highlight.js@11';
// ── 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: '🔍',
filesystem: '📁',
http_request: '🌐',
code_exec: '⚙️',
terminal: '💻',
ssh_exec: '🖧',
image_view: '🖼️',
};
// ── Helpers ───────────────────────────────────────────────────────────────────
function esc(str) {
return String(str ?? '')
.replace(/&/g, '&').replace(/</g, '<')
.replace(/>/g, '>').replace(/"/g, '"');
}
function timeLabel(iso) {
if (!iso) return '';
const d = new Date(iso);
if (isNaN(d)) return '';
const diff = Date.now() - d;
if (diff < 60_000) return 'just now';
if (diff < 3_600_000) return `${Math.floor(diff / 60_000)}m ago`;
if (diff < 86_400_000) return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
return d.toLocaleDateString();
}
// ── Public API ────────────────────────────────────────────────────────────────
/**
* 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.
* Returns the bubble element.
*/
export function appendMessage(el, role, content, images = null, timestamp = null) {
const wrap = document.createElement('div');
wrap.className = `msg ${role}`;
const bubble = document.createElement('div');
bubble.className = 'bubble';
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 (content) {
const text = document.createElement('span');
text.textContent = content;
bubble.appendChild(text);
}
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));
}
/**
* Tool call card — collapsed by default, click header to toggle.
*/
export function appendToolCall(el, event) {
const icon = TOOL_ICONS[event.tool] ?? '🔧';
const success = event.success;
const argsLines = Object.entries(event.args ?? {})
.map(([k, v]) => `<span class="arg-key">${esc(k)}</span><span class="arg-val">${esc(JSON.stringify(v))}</span>`)
.join('');
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';
if (argsLines) {
const argsDiv = document.createElement('div');
argsDiv.className = 'tool-args';
argsDiv.innerHTML = argsLines;
body.appendChild(argsDiv);
}
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);
}
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>`;
}
export function scrollToBottom(el) {
el.scrollTop = el.scrollHeight;
}