${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 += `
};base64,${escapeHtml(img.data || '')})
`;
+ }
+ 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/);
+ });
+});