import { html, nothing } from 'lit'; import { unsafeHTML } from 'lit/directives/unsafe-html.js'; import { renderMarkdown } from '../lib/base.js'; import { openFile } from '../lib/open-file.js'; // ── Utilities ──────────────────────────────────────────────────────────────── /** * Render a tool label (with backtick-wrapped arguments) as Lit nodes. Each * backtick segment becomes a ``; the segment that exactly matches `path` * — the file a file-targeting tool acts on, supplied by the backend * `target_path` — instead becomes a link that opens it in the file viewer. * Lit auto-escapes text, so no manual HTML escaping is needed. */ function renderLabel(label, path) { const out = []; let rest = label || ''; while (rest.length) { const open = rest.indexOf('`'); if (open === -1) { out.push(rest); break; } if (open > 0) out.push(rest.slice(0, open)); rest = rest.slice(open + 1); const close = rest.indexOf('`'); if (close === -1) { out.push('`' + rest); break; } out.push(renderPath(rest.slice(0, close), path)); rest = rest.slice(close + 1); } return out; } /** A backtick segment: a clickable file link when it is the call's target path, else plain ``. */ function renderPath(seg, path) { if (!path || seg !== path) return html`${seg}`; const open = (e) => { e.stopPropagation(); openFile(seg); }; return html` { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); open(e); } }} >${seg}`; } export function truncate(s, max = 400) { if (!s) return ''; const str = typeof s === 'string' ? s : JSON.stringify(s, null, 2); return str.length > max ? str.slice(0, max) + '\n…' : str; } /** * Pretty-prints a structured (`result_type === 'json'`) tool result for display. * The backend stores `structuredContent` as a compact JSON string; re-indent it * for readability, falling back to the raw string if it isn't valid JSON. */ function prettyJson(s) { try { return JSON.stringify(JSON.parse(s), null, 2); } catch { return s; } } // ── Diff ───────────────────────────────────────────────────────────────────── export function renderDiff(oldText, newText) { const oldLines = (oldText || '').split('\n'); const newLines = (newText || '').split('\n'); const m = oldLines.length, n = newLines.length; const dp = Array.from({ length: m + 1 }, () => new Array(n + 1).fill(0)); for (let i = 1; i <= m; i++) for (let j = 1; j <= n; j++) dp[i][j] = oldLines[i-1] === newLines[j-1] ? dp[i-1][j-1] + 1 : Math.max(dp[i-1][j], dp[i][j-1]); const ops = []; let i = m, j = n; while (i > 0 || j > 0) { if (i > 0 && j > 0 && oldLines[i-1] === newLines[j-1]) { ops.push({ type: 'eq', text: oldLines[i-1] }); i--; j--; } else if (j > 0 && (i === 0 || dp[i][j-1] >= dp[i-1][j])) { ops.push({ type: 'add', text: newLines[j-1] }); j--; } else { ops.push({ type: 'del', text: oldLines[i-1] }); i--; } } ops.reverse(); const result = []; let eqBuf = []; const flushEq = () => { if (eqBuf.length === 0) return; if (eqBuf.length <= 6) { result.push(html`${eqBuf.join('\n')}\n`); } else { result.push(html`${eqBuf.slice(0, 3).join('\n')}\n`); result.push(html`⋯ ${eqBuf.length - 6} unchanged lines ⋯`); result.push(html`\n${eqBuf.slice(-3).join('\n')}\n`); } eqBuf = []; }; for (const op of ops) { if (op.type === 'eq') { eqBuf.push(op.text); } else { flushEq(); const cls = op.type === 'add' ? 'diff-added' : 'diff-removed'; result.push(html`${op.text}\n`); } } flushEq(); return result; } // ── Message renderers ──────────────────────────────────────────────────────── export function renderPendingWrite(host, msg) { console.debug('[renderPendingWrite]', msg.path, 'old_len=' + (msg.old_content?.length ?? 0), 'new_len=' + (msg.new_content?.length ?? 0)); const isRejecting = host._rejectingId === msg.request_id; return html`
openFile(msg.path)} @keydown=${(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); openFile(msg.path); } }} >${msg.path} ${msg.status === 'pending' ? html`Pending approval` : msg.status === 'approved' ? html`Approved` : html`Rejected`}
${renderDiff(msg.old_content, msg.new_content)}
${msg.status === 'pending' ? html`
${isRejecting ? html`
` : html`
`}
` : nothing}
`; } export function renderTool(host, msg) { const isOpen = host._expanded.has(msg.tool_call_id); const argsStr = truncate(msg.arguments); const isPending = msg.status === 'pending'; const isRejecting = isPending && host._rejectingId === msg.tool_call_id; const statusIcon = msg.status === 'running' ? html`` : isPending ? html`` : msg.status === 'done' ? html`` : msg.status === 'cancelled' ? html`` : msg.status === 'rejected' ? html`` : html``; return html`
${isOpen ? html`
${!(isPending && msg.name === 'ask_user_clarification') ? html`
args
${argsStr}
` : nothing} ${isPending ? (msg.name === 'ask_user_clarification' ? html`
${msg.question_title ? html`
${msg.question_title}
` : nothing}
${unsafeHTML(renderMarkdown(msg.question ?? msg.arguments?.question ?? ''))}
${(msg.suggested_answers ?? []).length > 0 ? html`
${(msg.suggested_answers ?? []).map(s => html` `)}
` : nothing}
` : html`
${isRejecting ? html`
` : html`
${msg.request_id != null ? html` ` : nothing}
`}
`) : msg.status !== 'running' ? ( msg.status === 'done' && msg.result_type === 'json' ? html`
result · json
${
                truncate(prettyJson(msg.result))
              }
` : html`
${msg.status === 'done' ? 'result' : 'error'}
${
                truncate(msg.status === 'done' ? msg.result : msg.error)
              }
`) : nothing}
` : nothing}
`; } export function renderAgent(msg) { const icon = msg.done ? 'check2-all' : 'arrow-right-circle'; return html`
${msg.parent_agent_id ?? 'main'} ${msg.agent_id} ${msg.done ? html`done` : html`running…`}
${msg.prompt_preview ? html`
${msg.prompt_preview}
` : nothing}
`; } export function renderAgentEnd(msg) { return html`
${msg.agent_id} ${msg.parent_agent_id ?? 'main'} finished
${msg.result_preview ? html`
${msg.result_preview}
` : nothing}
`; } function failedBadge() { return html` `; } // ── Attachment chips ─────────────────────────────────────────────────────────── /** Bootstrap icon class for a file based on its MIME type / extension. */ function attachmentIcon(att) { const m = (att.mimetype || '').toLowerCase(); const n = (att.name || '').toLowerCase(); if (m.startsWith('image/')) return 'bi-file-earmark-image'; if (m === 'application/pdf' || n.endsWith('.pdf')) return 'bi-file-earmark-pdf'; if (m.startsWith('audio/')) return 'bi-file-earmark-music'; if (m.startsWith('video/')) return 'bi-file-earmark-play'; if (m.startsWith('text/') || /\.(md|txt|csv|json|ya?ml|rs|js|ts|py)$/.test(n)) return 'bi-file-earmark-text'; return 'bi-file-earmark'; } /** Human-readable file size, e.g. "1.2 MB". */ function fmtSize(bytes) { if (bytes == null) return ''; const u = ['B', 'KB', 'MB', 'GB']; let i = 0, n = bytes; while (n >= 1024 && i < u.length - 1) { n /= 1024; i++; } return `${n < 10 && i > 0 ? n.toFixed(1) : Math.round(n)} ${u[i]}`; } /** * Render a list of attachment chips. Used both above the composer (pending * uploads — `removable`, with an × button and a spinner while uploading) and * inside a sent user bubble (clickable to open the file). `host` must provide * `_removeAttachment(i)` when `removable` is true. */ export function renderAttachmentChips(host, attachments, { removable = false } = {}) { if (!attachments?.length) return nothing; return html`
${attachments.map((att, i) => html`
{ if (!removable && att.path) openFile(att.path); }}> ${att.uploading ? html`` : html``} ${att.name} ${att.filesize != null ? html`${fmtSize(att.filesize)}` : nothing} ${removable ? html` ` : nothing}
`)}
`; } export function renderMsg(host, msg) { try { switch (msg.kind) { case 'user': return html`
${msg.failed ? failedBadge() : nothing}${msg.content}${renderAttachmentChips(host, msg.attachments)}
`; case 'thinking': return html`
${msg.failed ? failedBadge() : nothing} ${unsafeHTML(renderMarkdown(msg.content))} ${msg.input_tokens != null ? html`
↑${msg.input_tokens.toLocaleString()} tok  ↓${msg.output_tokens?.toLocaleString()} tok
` : nothing}
`; case 'assistant': return html`
${msg.failed ? failedBadge() : nothing} ${unsafeHTML(renderMarkdown(msg.content))} ${msg.input_tokens != null ? html`
↑${msg.input_tokens.toLocaleString()} tok  ↓${msg.output_tokens?.toLocaleString()} tok
` : nothing}
`; case 'error': return html`
${msg.content}
`; case 'info': return html`
${msg.content}
`; case 'pending_write': return renderPendingWrite(host, msg); case 'tool': return renderTool(host, msg); case 'agent': return renderAgent(msg); case 'agent_end': return renderAgentEnd(msg); default: return nothing; } } catch (err) { console.error('[renderMsg] kind=' + msg.kind, err); return html`
Render error [${msg.kind}]: ${err.message}
`; } }