diff --git a/packages/coding-agent/src/core/export-html/template.css b/packages/coding-agent/src/core/export-html/template.css index ac292311..c807161e 100644 --- a/packages/coding-agent/src/core/export-html/template.css +++ b/packages/coding-agent/src/core/export-html/template.css @@ -204,6 +204,10 @@ color: var(--accent); } + .tree-role-skill { + color: var(--customMessageLabel); + } + .tree-role-assistant { color: var(--success); } @@ -383,7 +387,8 @@ } .user-message:hover .copy-link-btn, - .assistant-message:hover .copy-link-btn { + .assistant-message:hover .copy-link-btn, + .skill-user-entry:hover .copy-link-btn { opacity: 1; } @@ -786,6 +791,45 @@ font-weight: bold; } + /* Skill invocation - matches compaction style (clickable, collapsed by default) */ + .skill-invocation { + background: var(--customMessageBg); + border-radius: 4px; + padding: var(--line-height); + cursor: pointer; + } + + .skill-invocation-label { + color: var(--customMessageLabel); + font-weight: bold; + } + + .skill-invocation-collapsed { + color: var(--customMessageText); + } + + .skill-invocation-content { + display: none; + color: var(--customMessageText); + margin-top: var(--line-height); + } + + .skill-invocation.expanded .skill-invocation-collapsed { + display: none; + } + + .skill-invocation.expanded .skill-invocation-content { + display: block; + } + + .skill-invocation + .user-message { + margin-top: var(--line-height); + } + + .skill-user-entry { + position: relative; + } + /* Branch summary */ .branch-summary { background: var(--customMessageBg); diff --git a/packages/coding-agent/src/core/export-html/template.js b/packages/coding-agent/src/core/export-html/template.js index 844f1eb3..cf28b935 100644 --- a/packages/coding-agent/src/core/export-html/template.js +++ b/packages/coding-agent/src/core/export-html/template.js @@ -313,6 +313,22 @@ return ''; } + /** + * Parse a skill block from message text. + * Returns null if the text doesn't contain a skill block. + * Matches the format: \n...\n\n\nuser message + */ + function parseSkillBlock(text) { + const match = text.match(/^\n([\s\S]*?)\n<\/skill>(?:\n\n([\s\S]+))?$/); + if (!match) return null; + return { + name: match[1], + location: match[2], + content: match[3], + userMessage: match[4]?.trim() || undefined, + }; + } + function getSearchableText(entry, label) { const parts = []; if (label) parts.push(label); @@ -613,7 +629,16 @@ case 'message': { const msg = entry.message; if (msg.role === 'user') { - const content = truncate(normalize(extractContent(msg.content))); + const rawContent = extractContent(msg.content); + const skillBlock = parseSkillBlock(rawContent); + if (skillBlock) { + let treeHtml = labelHtml + `skill: ${escapeHtml(skillBlock.name)}`; + if (skillBlock.userMessage) { + treeHtml += ` ยท user: ${escapeHtml(truncate(normalize(skillBlock.userMessage)))}`; + } + return treeHtml; + } + const content = truncate(normalize(rawContent)); return labelHtml + `user: ${escapeHtml(content)}`; } if (msg.role === 'assistant') { @@ -1140,8 +1165,46 @@ const msg = entry.message; if (msg.role === 'user') { - let html = `
${copyBtnHtml}${tsHtml}`; const content = msg.content; + const text = typeof content === 'string' ? content : + content.filter(c => c.type === 'text').map(c => c.text).join('\n'); + const skillBlock = parseSkillBlock(text); + + if (skillBlock) { + // Collect images from content array + const images = Array.isArray(content) ? content.filter(c => c.type === 'image') : []; + const hasUserContent = skillBlock.userMessage || images.length > 0; + let html = `
${copyBtnHtml}${tsHtml}`; + + // Skill invocation (collapsed by default, click to expand) + html += `
+
[skill] ${escapeHtml(skillBlock.name)}
+
${escapeHtml(skillBlock.name)} (click to expand)
+
${safeMarkedParse(skillBlock.content)}
+
`; + + // User message (separate block if present) + if (hasUserContent) { + html += '
'; + if (images.length > 0) { + html += '
'; + for (const img of images) { + html += ``; + } + html += '
'; + } + if (skillBlock.userMessage) { + html += `
${safeMarkedParse(skillBlock.userMessage)}
`; + } + html += '
'; + } + + html += '
'; + return html; + } + + // No skill block - normal user message + let html = `
${copyBtnHtml}${tsHtml}`; if (Array.isArray(content)) { const images = content.filter(c => c.type === 'image'); @@ -1154,8 +1217,6 @@ } } - const text = typeof content === 'string' ? content : - content.filter(c => c.type === 'text').map(c => c.text).join('\n'); if (text.trim()) { html += `
${safeMarkedParse(text)}
`; } @@ -1716,6 +1777,9 @@ document.querySelectorAll('.compaction').forEach(el => { el.classList.toggle('expanded', toolOutputsExpanded); }); + document.querySelectorAll('.skill-invocation').forEach(el => { + el.classList.toggle('expanded', toolOutputsExpanded); + }); }; const attachHeaderHandlers = () => { diff --git a/packages/coding-agent/test/export-html-skill-block.test.ts b/packages/coding-agent/test/export-html-skill-block.test.ts new file mode 100644 index 00000000..ae89decf --- /dev/null +++ b/packages/coding-agent/test/export-html-skill-block.test.ts @@ -0,0 +1,40 @@ +import { readFileSync } from "fs"; +import { describe, expect, it } from "vitest"; + +describe("export HTML skill block rendering", () => { + const templateJs = readFileSync(new URL("../src/core/export-html/template.js", import.meta.url), "utf-8"); + + it("strips skill wrapper XML from user message rendering", () => { + // Skill commands store a structural wrapper in the raw user message: + // \n...\n\n\nactual prompt + // The export renderer must detect that wrapper and render only the user-visible prompt, + // not the Pi-generated ... XML tags. + expect(templateJs).toMatch(/parseSkillBlock/); + expect(templateJs).toMatch(/skillBlock\.userMessage/); + }); + + it("renders skill invocation and user message as separate sibling blocks", () => { + // The skill block and user message should render as separate entry-level elements, + // matching the TUI layout where SkillInvocationMessageComponent and + // UserMessageComponent are siblings, not nested. + expect(templateJs).toMatch(/skill-invocation/); + + // When a skill block has a userMessage, the user-message div must be emitted + // as a separate block after the skill-invocation div, containing the user-authored text. + // Verify the code checks hasUserContent so the user-message div is only omitted + // when the skill block has no user prompt and no images. + expect(templateJs).toMatch(/hasUserContent/); + }); + + it("renders skill content as markdown, not raw text", () => { + // The skill block body is markdown (from the SKILL.md file). + // It should be rendered through safeMarkedParse, not escaped as raw text. + expect(templateJs).toMatch(/safeMarkedParse\(skillBlock\.content\)/); + }); + + it("shows skill name and user message in the sidebar tree", () => { + // The sidebar tree should display both the skill name and the user prompt, + // not just one or the other. + expect(templateJs).toMatch(/tree-role-skill/); + }); +});