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; +}