diff --git a/components/MessageView.test.mjs b/components/MessageView.test.mjs
index def67b6ce..bca5140ca 100644
--- a/components/MessageView.test.mjs
+++ b/components/MessageView.test.mjs
@@ -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: '\nReferences are relative to /home/me/.pi/skills/git.\n\nRun the standard git workflow.\n',
+ });
+
+ // 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\nSecret body text.\n\n\noutro',
+ });
+
+ assert.match(html, /intro/);
+ assert.match(html, /outro/);
+ assert.doesNotMatch(html, /Secret body text/);
+});
diff --git a/components/MessageView.tsx b/components/MessageView.tsx
index d73bee37f..f733b9614 100644
--- a/components/MessageView.tsx
+++ b/components/MessageView.tsx
@@ -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,
@@ -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 (
+
+
+ {expanded && (
+
+ {content}
+
+ )}
+
+ );
+}
+
function UserMessageView({ message, cwd, onOpenFile, entryId, onFork, forking, onNavigate, prevAssistantEntryId, onEditContent }: {
message: UserMessage;
cwd?: string;
@@ -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;
@@ -222,7 +299,21 @@ function UserMessageView({ message, cwd, onOpenFile, entryId, onFork, forking, o
})}
)}
- {content && {content}}
+ {content && (
+ hasSkillBlocks ? (
+
+ {segments.map((seg, i) =>
+ seg.type === "skill" ? (
+
+ ) : seg.text ? (
+ {seg.text}
+ ) : null,
+ )}
+
+ ) : (
+ {content}
+ )
+ )}
diff --git a/lib/i18n/messages/en.ts b/lib/i18n/messages/en.ts
index 156cb961c..3b92c4390 100644
--- a/lib/i18n/messages/en.ts
+++ b/lib/i18n/messages/en.ts
@@ -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",
diff --git a/lib/i18n/messages/zh-CN.ts b/lib/i18n/messages/zh-CN.ts
index 7854f8142..06c97fc7b 100644
--- a/lib/i18n/messages/zh-CN.ts
+++ b/lib/i18n/messages/zh-CN.ts
@@ -241,6 +241,7 @@ export const zhCNLocale: LocalePlugin = {
"chat.commandCopy": "复制最后一条助手消息",
"chat.compacted": "已压缩",
"chat.tokensSaved": "节省 {saved}",
+ "chat.lines": "行",
"i18n.close": "关闭",
"i18n.copy": "复制",
"i18n.copied": "已复制",
diff --git a/lib/skill-block.test.mjs b/lib/skill-block.test.mjs
new file mode 100644
index 000000000..75dc945d3
--- /dev/null
+++ b/lib/skill-block.test.mjs
@@ -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 = '\nReferences are relative to /home/me/.pi/skills/git.\n\nDo the thing.\n';
+ 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\nBody.\n\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 = '\nA.\n\n\nB.\n';
+ 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 = '\nBody.\n';
+ 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 {
+ const text = "see {
+ const segments = splitSkillBlocks("");
+ assert.equal(segments.length, 1);
+ assert.equal(segments[0].type, "text");
+ assert.equal(segments[0].text, "");
+});
diff --git a/lib/skill-block.ts b/lib/skill-block.ts
new file mode 100644
index 000000000..24eec419c
--- /dev/null
+++ b/lib/skill-block.ts
@@ -0,0 +1,57 @@
+/**
+ * Splits message text into ... 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 ... 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 = /]*)>([\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(" 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;
+}