pi-mono/packages/coding-agent/src/modes/interactive/components/skill-invocation-message.ts
Petr Houška eebd5d90ca
feat(coding-agent): let Markdown transformers opt into more surfaces (#11756)
Problem: `pi.registerMarkdownTransformer()` only runs on user, assistant
and thinking Markdown. Pi renders other Markdown that extensions cannot
reach: custom messages, skill blocks, branch and compaction summaries,
and the changelog notices (What's New, `/changelog`, Extension Updates,
the Update Available note). For example, a transformer cannot rewrite
text in an extension's changelog shown under "Extension Updates",
because that call site passes no transform.

Running every transformer everywhere would break existing ones. The
documented example only skips thinking blocks and replaces `-->` with
`→`; on a changelog it would turn `<!-- note -->` into `<!-- note →`.

Solution: an optional `{ messageTypes }` lists the surfaces a
transformer runs on. Without options it runs on the same three types as
before.

| `messageType` | Surfaces |
|---|---|
| `user`, `assistant`, `assistant-thinking` | transcript messages
(default) |
| `custom` (new) | custom messages without a message renderer; expanded
skill blocks |
| `summary` (new) | expanded branch and compaction summaries |
| `changelog` (new) | What's New, `/changelog`, Extension Updates, the
Update Available note |

```typescript
pi.registerMarkdownTransformer((markdown) => markdown.replaceAll("⚠️", "⚠"), {
  messageTypes: ["changelog"],
});
```

Details:
- Filtering happens in one place,
`ExtensionRunner.getMarkdownTransformers()`: each transformer is wrapped
so Markdown of unlisted types passes through unchanged. Components still
take a plain list of transformers.
- Skill blocks and summaries pass only their content to transformers; pi
adds its bold header after the transform.
- Custom, skill and summary messages now get `messageId` and `timestamp`
when loaded from the session. Changelog notices have no session entry,
so both are `undefined`.
- The built-in Mermaid transformer renders in user and assistant text
and now also in summaries, which are model-written like assistant text.
It still skips thinking, custom messages and changelogs.
- `/hotkeys` does not run transformers.
- New exports: `MarkdownMessageType`, `MarkdownTransformerOptions`,
`MarkdownTransformerRegistration`.

Tests: runner type filtering (default vs. opted in), custom message,
skill and summary components (type, header excluded, `messageId`),
Mermaid on the new types, and startup notices running `changelog`
transformers on What's New and Extension Updates while default
transformers are skipped.

Related: #11710 (HTML comments in Extension Updates). #11755 already
fixed it in the renderer; this PR lets extensions transform those
notices in general.
2026-11-03 23:10:47 +01:00

96 lines
3 KiB
TypeScript

import { Box, Container, Markdown, type MarkdownTheme, MouseRegion, Text } from "@earendil-works/pi-tui";
import type { ParsedSkillBlock } from "../../../core/agent-session.ts";
import type { MarkdownTransformer } from "../../../core/extensions/types.ts";
import { getMarkdownTheme, theme } from "../theme/theme.ts";
import { keyText } from "./keybinding-hints.ts";
import { createMarkdownTransform } from "./markdown-transform.ts";
/**
* 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;
private markdownTransformers: readonly MarkdownTransformer[];
private messageId?: string;
private timestamp?: string;
constructor(
skillBlock: ParsedSkillBlock,
markdownTheme: MarkdownTheme = getMarkdownTheme(),
outputPad = 1,
markdownTransformers: readonly MarkdownTransformer[] = [],
messageId?: string,
timestamp?: string,
) {
super(outputPad, 1, (t) => theme.bg("customMessageBg", t));
this.skillBlock = skillBlock;
this.markdownTheme = markdownTheme;
this.markdownTransformers = markdownTransformers;
this.messageId = messageId;
this.timestamp = timestamp;
this.updateDisplay();
}
setExpanded(expanded: boolean): void {
this.expanded = expanded;
this.updateDisplay();
}
setOutputPad(outputPad: number): void {
this.setPaddingX(outputPad);
}
override invalidate(): void {
super.invalidate();
this.updateDisplay();
}
private updateDisplay(): void {
this.clear();
const content = new Container();
if (this.expanded) {
// Expanded: label + skill name header + full content
const label = theme.fg("customMessageLabel", `\x1b[1m[skill]\x1b[22m`);
content.addChild(new Text(label, 0, 0));
const header = `**${this.skillBlock.name}**\n\n`;
const transform = createMarkdownTransform(
"custom",
() => false,
this.markdownTransformers,
this.messageId,
this.timestamp,
);
content.addChild(
new Markdown(
this.skillBlock.content,
0,
0,
this.markdownTheme,
{ color: (text: string) => theme.fg("customMessageText", text) },
// Transformers see only the skill content, not Pi's header.
{ transform: (markdown, width) => header + transform(markdown, width) },
),
);
} 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", ` (${keyText("app.tools.expand")} to expand)`);
content.addChild(new Text(line, 0, 0));
}
this.addChild(
new MouseRegion(content, (event) => {
if (event.type !== "click" || event.button !== "left") return undefined;
this.setExpanded(!this.expanded);
return { handled: true };
}),
);
}
}