fix(coding-agent): strip skill wrapper XML from HTML export user messages (#4234)
Skill slash commands store a structural <skill>...</skill> wrapper in raw user messages. The TUI uses parseSkillBlock() to split this into separate SkillInvocationMessageComponent and UserMessageComponent siblings, but the HTML export renderer passed the full raw text through markdown, causing broken/dangling XML tags to appear in exported HTML. Add parseSkillBlock() to the export template and render skill-invocation and user-message as separate sibling blocks: - Sidebar tree shows skill name + user prompt separately - Content area shows a clickable skill-invocation block (collapsed by default, markdown content on expand) followed by the user message - Copy-link button preserved on the wrapper element - Toggle tools (O key) expands/collapses skill invocations alongside compaction and tool output blocks
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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: <skill name="..." location="...">\n...\n</skill>\n\nuser message
|
||||
*/
|
||||
function parseSkillBlock(text) {
|
||||
const match = text.match(/^<skill name="([^"]+)" location="([^"]+)">\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 + `<span class="tree-role-skill">skill:</span> ${escapeHtml(skillBlock.name)}`;
|
||||
if (skillBlock.userMessage) {
|
||||
treeHtml += ` · <span class="tree-role-user">user:</span> ${escapeHtml(truncate(normalize(skillBlock.userMessage)))}`;
|
||||
}
|
||||
return treeHtml;
|
||||
}
|
||||
const content = truncate(normalize(rawContent));
|
||||
return labelHtml + `<span class="tree-role-user">user:</span> ${escapeHtml(content)}`;
|
||||
}
|
||||
if (msg.role === 'assistant') {
|
||||
@@ -1140,8 +1165,46 @@
|
||||
const msg = entry.message;
|
||||
|
||||
if (msg.role === 'user') {
|
||||
let html = `<div class="user-message" id="${entryDomId}">${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 = `<div class="skill-user-entry" id="${entryDomId}">${copyBtnHtml}${tsHtml}`;
|
||||
|
||||
// Skill invocation (collapsed by default, click to expand)
|
||||
html += `<div class="skill-invocation" onclick="if(window.getSelection().toString())return;this.classList.toggle('expanded')">
|
||||
<div class="skill-invocation-label">[skill] ${escapeHtml(skillBlock.name)}</div>
|
||||
<div class="skill-invocation-collapsed">${escapeHtml(skillBlock.name)} (click to expand)</div>
|
||||
<div class="skill-invocation-content markdown-content">${safeMarkedParse(skillBlock.content)}</div>
|
||||
</div>`;
|
||||
|
||||
// User message (separate block if present)
|
||||
if (hasUserContent) {
|
||||
html += '<div class="user-message">';
|
||||
if (images.length > 0) {
|
||||
html += '<div class="message-images">';
|
||||
for (const img of images) {
|
||||
html += `<img src="data:${escapeHtml(img.mimeType || 'image/png')};base64,${escapeHtml(img.data || '')}" class="message-image" />`;
|
||||
}
|
||||
html += '</div>';
|
||||
}
|
||||
if (skillBlock.userMessage) {
|
||||
html += `<div class="markdown-content">${safeMarkedParse(skillBlock.userMessage)}</div>`;
|
||||
}
|
||||
html += '</div>';
|
||||
}
|
||||
|
||||
html += '</div>';
|
||||
return html;
|
||||
}
|
||||
|
||||
// No skill block - normal user message
|
||||
let html = `<div class="user-message" id="${entryDomId}">${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 += `<div class="markdown-content">${safeMarkedParse(text)}</div>`;
|
||||
}
|
||||
@@ -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 = () => {
|
||||
|
||||
40
packages/coding-agent/test/export-html-skill-block.test.ts
Normal file
40
packages/coding-agent/test/export-html-skill-block.test.ts
Normal file
@@ -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:
|
||||
// <skill name="..." location="...">\n...\n</skill>\n\nactual prompt
|
||||
// The export renderer must detect that wrapper and render only the user-visible prompt,
|
||||
// not the Pi-generated <skill>...</skill> 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/);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user