Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions components/MessageView.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -49,3 +49,36 @@ test("renders partial assistant content before the provider error", () => {
assert.match(html, /Partial response/);
assert.match(html, /Error: Connection closed/);
});

test("collapses expanded skill blocks in user messages", () => {
const html = renderMessage({
role: "user",
content: '<skill name="git" location="/home/me/.pi/skills/git/SKILL.md">\nReferences are relative to /home/me/.pi/skills/git.\n\nRun the standard git workflow.\n</skill>',
});

// Header is visible with the skill name and location
assert.match(html, /skill: git/);
assert.match(html, /\/home\/me\/\.pi\/skills\/git\/SKILL\.md/);
// The verbose body is collapsed by default
assert.doesNotMatch(html, /Run the standard git workflow/);
});

test("keeps user messages without skill blocks intact", () => {
const html = renderMessage({
role: "user",
content: "Please run the git workflow",
});

assert.match(html, /Please run the git workflow/);
});

test("keeps text around collapsed skill blocks", () => {
const html = renderMessage({
role: "user",
content: 'intro\n\n<skill name="git" location="/x/SKILL.md">\nSecret body text.\n</skill>\n\noutro',
});

assert.match(html, /intro/);
assert.match(html, /outro/);
assert.doesNotMatch(html, /Secret body text/);
});
93 changes: 92 additions & 1 deletion components/MessageView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { useI18n } from "@/hooks/useI18n";
import { parseCompactionSummary } from "@/lib/compaction-summary";
import { getAssistantErrorMessage, isEmptyThinkingBlock } from "@/lib/message-display";
import { parseUnifiedPatch, type SplitDiffCell } from "@/lib/patch";
import { splitSkillBlocks } from "@/lib/skill-block";
import type {
AgentMessage,
UserMessage,
Expand Down Expand Up @@ -137,6 +138,79 @@ export const MessageView = memo(function MessageView({ message, isStreaming, too
&& prev.sessionId === next.sessionId;
});

function SkillBlock({ name, location, content, cwd, onOpenFile }: {
name: string;
location: string;
content: string;
cwd?: string;
onOpenFile?: (filePath: string) => void;
}) {
const { t } = useI18n();
const [expanded, setExpanded] = useState(false);
const lineCount = content.split("\n").length;
return (
<div
style={{
border: "1px solid var(--border)",
borderRadius: 6,
overflow: "hidden",
fontSize: 13,
}}
>
<button
onClick={() => setExpanded((v) => !v)}
title={expanded ? t("i18n.collapse") : t("i18n.expand")}
style={{
display: "flex",
alignItems: "center",
gap: 6,
width: "100%",
padding: "6px 10px",
background: "var(--bg-panel)",
border: "none",
color: "var(--text-muted)",
cursor: "pointer",
fontSize: 12,
textAlign: "left",
}}
>
<span style={{ flexShrink: 0 }}>{expanded ? "▾" : "▸"}</span>
<span style={{ fontWeight: 600, color: "var(--text)", flexShrink: 0 }}>skill: {name}</span>
{location && (
<span
style={{
color: "var(--text-dim)",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
minWidth: 0,
flex: 1,
}}
>
{location}
</span>
)}
<span style={{ marginLeft: "auto", flexShrink: 0, color: "var(--text-dim)", whiteSpace: "nowrap" }}>
{lineCount} {t("chat.lines")} · {expanded ? t("i18n.collapse") : t("i18n.expand")}
</span>
</button>
{expanded && (
<div
style={{
padding: "8px 10px",
borderTop: "1px solid var(--border)",
maxHeight: 420,
overflowY: "auto",
background: "var(--bg)",
}}
>
<MarkdownBody className="markdown-user-message" cwd={cwd} onOpenFile={onOpenFile}>{content}</MarkdownBody>
</div>
)}
</div>
);
}

function UserMessageView({ message, cwd, onOpenFile, entryId, onFork, forking, onNavigate, prevAssistantEntryId, onEditContent }: {
message: UserMessage;
cwd?: string;
Expand Down Expand Up @@ -165,6 +239,9 @@ function UserMessageView({ message, cwd, onOpenFile, entryId, onFork, forking, o
? []
: message.content.filter((b): b is ImageContent => b.type === "image");

const segments = useMemo(() => splitSkillBlocks(content), [content]);
const hasSkillBlocks = segments.some((s) => s.type === "skill");

const time = formatTime(message.timestamp);
const canFork = !!entryId && !!onFork;
const canNavigate = !!prevAssistantEntryId && !!onNavigate;
Expand Down Expand Up @@ -222,7 +299,21 @@ function UserMessageView({ message, cwd, onOpenFile, entryId, onFork, forking, o
})}
</div>
)}
{content && <MarkdownBody className="markdown-user-message" cwd={cwd} onOpenFile={onOpenFile}>{content}</MarkdownBody>}
{content && (
hasSkillBlocks ? (
<div style={{ display: "flex", flexDirection: "column", gap: 4 }}>
{segments.map((seg, i) =>
seg.type === "skill" ? (
<SkillBlock key={i} name={seg.name} location={seg.location} content={seg.content} cwd={cwd} onOpenFile={onOpenFile} />
) : seg.text ? (
<MarkdownBody key={i} className="markdown-user-message" cwd={cwd} onOpenFile={onOpenFile}>{seg.text}</MarkdownBody>
) : null,
)}
</div>
) : (
<MarkdownBody className="markdown-user-message" cwd={cwd} onOpenFile={onOpenFile}>{content}</MarkdownBody>
)
)}
</div>

</div>
Expand Down
1 change: 1 addition & 0 deletions lib/i18n/messages/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -241,6 +241,7 @@ export const enLocale: LocalePlugin = {
"chat.commandCopy": "Copy the last assistant message",
"chat.compacted": "Compacted",
"chat.tokensSaved": "{saved} saved",
"chat.lines": "lines",
"i18n.close": "Close",
"i18n.copy": "Copy",
"i18n.copied": "Copied",
Expand Down
1 change: 1 addition & 0 deletions lib/i18n/messages/zh-CN.ts
Original file line number Diff line number Diff line change
Expand Up @@ -241,6 +241,7 @@ export const zhCNLocale: LocalePlugin = {
"chat.commandCopy": "复制最后一条助手消息",
"chat.compacted": "已压缩",
"chat.tokensSaved": "节省 {saved}",
"chat.lines": "行",
"i18n.close": "关闭",
"i18n.copy": "复制",
"i18n.copied": "已复制",
Expand Down
70 changes: 70 additions & 0 deletions lib/skill-block.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import assert from "node:assert/strict";
import test from "node:test";
import { createJiti } from "jiti";

const jiti = createJiti(import.meta.url, {
tsconfigPaths: true,
});
const { splitSkillBlocks } = await jiti.import("./skill-block.ts");

test("passes through text without skill blocks", () => {
assert.deepEqual(splitSkillBlocks("hello world"), [{ type: "text", text: "hello world" }]);
});

test("extracts a single skill block with name and location", () => {
const text = '<skill name="git" location="/home/me/.pi/skills/git/SKILL.md">\nReferences are relative to /home/me/.pi/skills/git.\n\nDo the thing.\n</skill>';
const segments = splitSkillBlocks(text);
assert.equal(segments.length, 1);
assert.equal(segments[0].type, "skill");
const skill = segments[0];
if (skill.type !== "skill") throw new Error("unreachable");
assert.equal(skill.name, "git");
assert.equal(skill.location, "/home/me/.pi/skills/git/SKILL.md");
assert.match(skill.content, /Do the thing\./);
});

test("keeps surrounding text segments in order", () => {
const text = 'intro\n\n<skill name="git" location="/x/SKILL.md">\nBody.\n</skill>\n\noutro';
const segments = splitSkillBlocks(text);
assert.equal(segments.length, 3);
assert.equal(segments[0].type, "text");
assert.match(segments[0].text, /intro/);
assert.equal(segments[1].type, "skill");
assert.equal(segments[2].type, "text");
assert.match(segments[2].text, /outro/);
});

test("extracts multiple skill blocks", () => {
const text = '<skill name="a" location="/a/SKILL.md">\nA.\n</skill>\n<skill name="b" location="/b/SKILL.md">\nB.\n</skill>';
const segments = splitSkillBlocks(text);
assert.equal(segments.length, 3);
assert.equal(segments[0].type, "skill");
assert.equal(segments[1].type, "text");
assert.equal(segments[2].type, "skill");
const names = segments.map((s) => (s.type === "skill" ? s.name : null));
assert.deepEqual(names, ["a", null, "b"]);
});

test("handles attributes in any order", () => {
const text = '<skill location="/x/SKILL.md" name="pdf">\nBody.\n</skill>';
const segments = splitSkillBlocks(text);
assert.equal(segments[0].type, "skill");
if (segments[0].type !== "skill") throw new Error("unreachable");
assert.equal(segments[0].name, "pdf");
assert.equal(segments[0].location, "/x/SKILL.md");
});

test("treats an unterminated <skill tag as plain text", () => {
const text = "see <skill name=\"x\"";
const segments = splitSkillBlocks(text);
assert.equal(segments.length, 1);
assert.equal(segments[0].type, "text");
assert.equal(segments[0].text, text);
});

test("returns an empty text segment for empty input", () => {
const segments = splitSkillBlocks("");
assert.equal(segments.length, 1);
assert.equal(segments[0].type, "text");
assert.equal(segments[0].text, "");
});
57 changes: 57 additions & 0 deletions lib/skill-block.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
/**
* Splits message text into <skill>...</skill> blocks (produced by the pi agent
* core when a `/skill:name` command is expanded) and plain text segments, so
* the UI can collapse the verbose skill content into a single header line.
*/

export interface SkillBlockSegment {
type: "skill";
name: string;
location: string;
/** Full block including the <skill>...</skill> wrapper. */
body: string;
/** Inner content (without the wrapper tags). */
content: string;
}

export interface TextSegment {
type: "text";
text: string;
}

export type MessageSegment = SkillBlockSegment | TextSegment;

const SKILL_BLOCK_RE = /<skill\b([^>]*)>([\s\S]*?)<\/skill>/g;

function parseSkillAttributes(attrs: string): { name: string; location: string } {
const name = /name="([^"]*)"/.exec(attrs)?.[1] ?? "";
const location = /location="([^"]*)"/.exec(attrs)?.[1] ?? "";
return { name, location };
}

export function splitSkillBlocks(text: string): MessageSegment[] {
if (!text.includes("<skill")) {
return [{ type: "text", text }];
}

const segments: MessageSegment[] = [];
let lastIndex = 0;
SKILL_BLOCK_RE.lastIndex = 0;

let match: RegExpExecArray | null;
while ((match = SKILL_BLOCK_RE.exec(text)) !== null) {
if (match.index > lastIndex) {
segments.push({ type: "text", text: text.slice(lastIndex, match.index) });
}
const { name, location } = parseSkillAttributes(match[1]);
const body = match[0];
segments.push({ type: "skill", name, location, body, content: match[2] });
lastIndex = match.index + body.length;
}

if (lastIndex < text.length) {
segments.push({ type: "text", text: text.slice(lastIndex) });
}

return segments;
}