`; 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}`;
}
}