diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 9c2a9bbd..c6e4b440 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -10,6 +10,7 @@ ### Added +- Skill invocation messages are now collapsible in chat output, showing collapsed by default with skill name and expand hint ([#894](https://github.com/badlogic/pi-mono/issues/894)) - Header values in `models.json` now support environment variables and shell commands, matching `apiKey` resolution ([#909](https://github.com/badlogic/pi-mono/issues/909)) - `markdown.codeBlockIndent` setting to customize code block indentation in rendered output - Extension package management with `pi install`, `pi remove`, `pi update`, and `pi list` commands ([#645](https://github.com/badlogic/pi-mono/issues/645)) diff --git a/packages/coding-agent/src/core/agent-session.ts b/packages/coding-agent/src/core/agent-session.ts index 2da0677d..78a87e15 100644 --- a/packages/coding-agent/src/core/agent-session.ts +++ b/packages/coding-agent/src/core/agent-session.ts @@ -70,6 +70,33 @@ import { buildSystemPrompt } from "./system-prompt.js"; import type { BashOperations } from "./tools/bash.js"; import { createAllTools } from "./tools/index.js"; +// ============================================================================ +// Skill Block Parsing +// ============================================================================ + +/** Parsed skill block from a user message */ +export interface ParsedSkillBlock { + name: string; + location: string; + content: string; + userMessage: string | undefined; +} + +/** + * Parse a skill block from message text. + * Returns null if the text doesn't contain a skill block. + */ +export function parseSkillBlock(text: string): ParsedSkillBlock | null { + const match = text.match(/^\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, + }; +} + /** Session-specific events that extend the core AgentEvent */ export type AgentSessionEvent = | AgentEvent @@ -796,9 +823,8 @@ export class AgentSession { try { const content = readFileSync(skill.filePath, "utf-8"); const body = stripFrontmatter(content).trim(); - const header = `Skill location: ${skill.filePath}\nReferences are relative to ${skill.baseDir}.`; - const skillMessage = `${header}\n\n${body}`; - return args ? `${skillMessage}\n\n---\n\nUser: ${args}` : skillMessage; + const skillBlock = `\nReferences are relative to ${skill.baseDir}.\n\n${body}\n`; + return args ? `${skillBlock}\n\n${args}` : skillBlock; } catch (err) { // Emit error like extension commands do this._extensionRunner?.emitError({ diff --git a/packages/coding-agent/src/index.ts b/packages/coding-agent/src/index.ts index ecf08a77..66ee85b7 100644 --- a/packages/coding-agent/src/index.ts +++ b/packages/coding-agent/src/index.ts @@ -8,7 +8,9 @@ export { type AgentSessionEvent, type AgentSessionEventListener, type ModelCycleResult, + type ParsedSkillBlock, type PromptOptions, + parseSkillBlock, type SessionStats, } from "./core/agent-session.js"; // Auth and model registry @@ -260,6 +262,7 @@ export { type SettingsConfig, SettingsSelectorComponent, ShowImagesSelectorComponent, + SkillInvocationMessageComponent, ThemeSelectorComponent, ThinkingSelectorComponent, ToolExecutionComponent, diff --git a/packages/coding-agent/src/modes/interactive/components/index.ts b/packages/coding-agent/src/modes/interactive/components/index.ts index 2676e2cd..a148f27d 100644 --- a/packages/coding-agent/src/modes/interactive/components/index.ts +++ b/packages/coding-agent/src/modes/interactive/components/index.ts @@ -21,6 +21,7 @@ export { type ModelsCallbacks, type ModelsConfig, ScopedModelsSelectorComponent export { SessionSelectorComponent } from "./session-selector.js"; export { type SettingsCallbacks, type SettingsConfig, SettingsSelectorComponent } from "./settings-selector.js"; export { ShowImagesSelectorComponent } from "./show-images-selector.js"; +export { SkillInvocationMessageComponent } from "./skill-invocation-message.js"; export { ThemeSelectorComponent } from "./theme-selector.js"; export { ThinkingSelectorComponent } from "./thinking-selector.js"; export { ToolExecutionComponent, type ToolExecutionOptions } from "./tool-execution.js"; diff --git a/packages/coding-agent/src/modes/interactive/components/skill-invocation-message.ts b/packages/coding-agent/src/modes/interactive/components/skill-invocation-message.ts new file mode 100644 index 00000000..47732e67 --- /dev/null +++ b/packages/coding-agent/src/modes/interactive/components/skill-invocation-message.ts @@ -0,0 +1,55 @@ +import { Box, Markdown, type MarkdownTheme, Text } from "@mariozechner/pi-tui"; +import type { ParsedSkillBlock } from "../../../core/agent-session.js"; +import { getMarkdownTheme, theme } from "../theme/theme.js"; +import { editorKey } from "./keybinding-hints.js"; + +/** + * Component that renders a skill invocation message with collapsed/expanded state. + * Uses same background color as custom messages for visual consistency. + * Only renders the skill block itself - user message is rendered separately. + */ +export class SkillInvocationMessageComponent extends Box { + private expanded = false; + private skillBlock: ParsedSkillBlock; + private markdownTheme: MarkdownTheme; + + constructor(skillBlock: ParsedSkillBlock, markdownTheme: MarkdownTheme = getMarkdownTheme()) { + super(1, 1, (t) => theme.bg("customMessageBg", t)); + this.skillBlock = skillBlock; + this.markdownTheme = markdownTheme; + this.updateDisplay(); + } + + setExpanded(expanded: boolean): void { + this.expanded = expanded; + this.updateDisplay(); + } + + override invalidate(): void { + super.invalidate(); + this.updateDisplay(); + } + + private updateDisplay(): void { + this.clear(); + + if (this.expanded) { + // Expanded: label + skill name header + full content + const label = theme.fg("customMessageLabel", `\x1b[1m[skill]\x1b[22m`); + this.addChild(new Text(label, 0, 0)); + const header = `**${this.skillBlock.name}**\n\n`; + this.addChild( + new Markdown(header + this.skillBlock.content, 0, 0, this.markdownTheme, { + color: (text: string) => theme.fg("customMessageText", text), + }), + ); + } else { + // Collapsed: single line - [skill] name (hint to expand) + const line = + theme.fg("customMessageLabel", `\x1b[1m[skill]\x1b[22m `) + + theme.fg("customMessageText", this.skillBlock.name) + + theme.fg("dim", ` (${editorKey("expandTools")} to expand)`); + this.addChild(new Text(line, 0, 0)); + } + } +} diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index ab4455d8..ecd1708b 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -52,7 +52,7 @@ import { isBunRuntime, VERSION, } from "../../config.js"; -import type { AgentSession, AgentSessionEvent } from "../../core/agent-session.js"; +import { type AgentSession, type AgentSessionEvent, parseSkillBlock } from "../../core/agent-session.js"; import type { CompactionResult } from "../../core/compaction/index.js"; import type { ExtensionContext, @@ -91,6 +91,7 @@ import { OAuthSelectorComponent } from "./components/oauth-selector.js"; import { ScopedModelsSelectorComponent } from "./components/scoped-models-selector.js"; import { SessionSelectorComponent } from "./components/session-selector.js"; import { SettingsSelectorComponent } from "./components/settings-selector.js"; +import { SkillInvocationMessageComponent } from "./components/skill-invocation-message.js"; import { ToolExecutionComponent } from "./components/tool-execution.js"; import { TreeSelectorComponent } from "./components/tree-selector.js"; import { UserMessageComponent } from "./components/user-message.js"; @@ -2030,8 +2031,28 @@ export class InteractiveMode { case "user": { const textContent = this.getUserMessageText(message); if (textContent) { - const userComponent = new UserMessageComponent(textContent, this.getMarkdownThemeWithSettings()); - this.chatContainer.addChild(userComponent); + const skillBlock = parseSkillBlock(textContent); + if (skillBlock) { + // Render skill block (collapsible) + this.chatContainer.addChild(new Spacer(1)); + const component = new SkillInvocationMessageComponent( + skillBlock, + this.getMarkdownThemeWithSettings(), + ); + component.setExpanded(this.toolOutputExpanded); + this.chatContainer.addChild(component); + // Render user message separately if present + if (skillBlock.userMessage) { + const userComponent = new UserMessageComponent( + skillBlock.userMessage, + this.getMarkdownThemeWithSettings(), + ); + this.chatContainer.addChild(userComponent); + } + } else { + const userComponent = new UserMessageComponent(textContent, this.getMarkdownThemeWithSettings()); + this.chatContainer.addChild(userComponent); + } if (options?.populateHistory) { this.editor.addToHistory?.(textContent); }