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'; import { openToolDetail } from '../lib/open-tool.js'; import { t } from '../lib/i18n.js'; import { connectorIconUrl } from './shared/connector-common.js'; // ── Tool icons ───────────────────────────────────────────────────────────────── /** * Maps a tool's semantic `icon` key (from the backend `Tool::icon`) to a Bootstrap * glyph + a CSS accent class. The key commits to meaning; the look lives here and in * `copilot-messages.css` (theme-aware, no hardcoded colors). Unknown keys fall back * to the generic wrench. */ const TOOL_ICON = { edit: { glyph: 'bi-pencil-square', cls: 'tool-ico--edit' }, read: { glyph: 'bi-file-earmark-text', cls: 'tool-ico--read' }, list: { glyph: 'bi-folder2-open', cls: 'tool-ico--list' }, search: { glyph: 'bi-search', cls: 'tool-ico--search' }, shell: { glyph: 'bi-terminal', cls: 'tool-ico--shell' }, subagent: { glyph: 'bi-diagram-3', cls: 'tool-ico--subagent' }, image: { glyph: 'bi-image', cls: 'tool-ico--image' }, config: { glyph: 'bi-sliders', cls: 'tool-ico--config' }, introspection: { glyph: 'bi-info-circle', cls: 'tool-ico--introspection' }, file: { glyph: 'bi-file-earmark', cls: 'tool-ico--file' }, mcp: { glyph: 'bi-plug', cls: 'tool-ico--mcp' }, tool: { glyph: 'bi-wrench', cls: 'tool-ico--tool' }, }; /** Whether a tool call carries a file-write diff snapshot to render. */ function hasPreview(msg) { return msg.preview_new != null || msg.preview_old != null; } /** * Whether to render the diff inline on the tool card. Suppressed while a sibling * `pending_write` card for the same call is present (it already shows the diff — an * approval-gated write). After a reload there is no such card, so the tool card * becomes the single place the diff lives. `host` may be absent in bare renders. */ function showInlineDiff(host, msg) { if (!hasPreview(msg)) return false; const siblings = host && host._messages; if (Array.isArray(siblings) && siblings.some(m => m.kind === 'pending_write' && m.tool_call_id === msg.tool_call_id)) { return false; } return true; } /** The MCP server name embedded in an `mcp____` id, or null. */ function mcpServerOf(name) { if (typeof name !== 'string' || !name.startsWith('mcp__')) return null; const rest = name.slice(5); const i = rest.indexOf('__'); return i === -1 ? rest : rest.slice(0, i); } /** * The leading tool icon for a card. MCP tools show their connector's own icon * (parsed from the `mcp__server__tool` id), falling back to a plug glyph if the * connector shipped none; every other tool shows its semantic glyph + accent. */ function renderToolIcon(msg) { const server = mcpServerOf(msg.name); if (server) { return html` { const w = e.target.closest('.copilot-tool-ico-wrap'); if (w) w.classList.add('img-failed'); }}> `; } const ic = TOOL_ICON[msg.icon] || TOOL_ICON.tool; return html``; } /** * The muted secondary detail beside a tool's friendly title: the target path * (clickable) or the primary argument. Derived from `label_full` by stripping the * leading raw tool-name token — which the friendly `display_name` now replaces — so * an MCP tool (whose label is just its raw id) shows no redundant secondary. */ function toolSecondary(msg) { let rest = msg.label_full || ''; if (msg.name && rest.startsWith(msg.name)) rest = rest.slice(msg.name.length); rest = rest.trim(); if (!rest) return nothing; return html`${renderLabel(rest, msg.path)}`; } // ── 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`${t('copilot.unchanged_lines', { n: eqBuf.length - 6 })}`); 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`${t('approval.pending')}` : msg.status === 'approved' ? html`${t('approval.approved')}` : html`${t('approval.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} ${showInlineDiff(host, msg) ? html`
${t('copilot.changes')}
${renderDiff(msg.preview_old || '', msg.preview_new || '')}
` : 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`
${t('copilot.result_json')}
${
                truncate(prettyJson(msg.result))
              }
` : html`
${msg.status === 'done' ? t('copilot.result') : t('copilot.error_label')}
${
                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 ?? 'assistant'} ${msg.agent_id} ${msg.done ? html`${t('copilot.agent_done')}` : html`${t('copilot.agent_running')}`}
${msg.prompt_preview ? html`
${msg.prompt_preview}
` : nothing}
`; } export function renderAgentEnd(msg) { return html`
${msg.agent_id} ${msg.parent_agent_id ?? 'assistant'} ${t('copilot.agent_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}
`; } }