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.
This commit is contained in:
Petr Houška 2026-11-03 23:10:47 +01:00 • committed by GitHub
parent a7a75a9dc6
commit eebd5d90ca
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
18 changed files with 448 additions and 41 deletions

View file

@ -1806,16 +1806,25 @@ mode and would not execute if sent via `prompt`.
Register a custom TUI renderer for custom messages with your `customType`. Custom messages are created with `pi.sendMessage()` and participate in LLM context. See [Custom UI](#custom-ui).
### pi.registerMarkdownTransformer(transformer)
### pi.registerMarkdownTransformer(transformer, options?)
Register a transformer for the Markdown in normal user text, assistant text, and thinking blocks. Transformers run in extension load order, and each transformer receives the Markdown returned by the previous transformer. After the chain finishes, Pi renders the transformed content with its built-in renderer.
Register a transformer for Markdown before Pi renders it in interactive mode. Transformers run in extension load order, and each transformer receives the Markdown returned by the previous transformer. After the chain finishes, Pi renders the transformed content with its built-in renderer.
By default a transformer runs on normal user text, assistant text, and thinking blocks. `options.messageTypes` lists the surfaces it runs on instead:
| `messageType` | Surface |
|---------------|---------|
| `"user"`, `"assistant"`, `"assistant-thinking"` | Transcript messages (default) |
| `"custom"` | Custom messages from `pi.sendMessage()` without a registered message renderer, and expanded skill blocks (their content, without Pi's header) |
| `"summary"` | Expanded branch and compaction summaries (the summary, without Pi's header) |
| `"changelog"` | Pi's changelog ("What's New" at startup and `/changelog`), the startup "Extension Updates" notice, and the release note of the "Update Available" notice |
The transformer receives the Markdown string and a context with:
- `messageType` — `"user"`, `"assistant"`, or `"assistant-thinking"`
- `isStreaming` — `true` for partial assistant updates; `false` for user, finalized assistant, and restored messages
- `messageType` — one of the surfaces above
- `isStreaming` — `true` for partial assistant updates; `false` otherwise
- `availableWidth` — exact terminal columns available for the transformed Markdown content
- `messageId`, `timestamp` — session-entry ID and ISO timestamp when rendering from persisted session entries; `undefined` for live messages
- `messageId`, `timestamp` — session-entry ID and ISO timestamp when rendering from persisted session entries; `undefined` for live messages and notices
Return the transformed Markdown:
@ -1824,9 +1833,14 @@ pi.registerMarkdownTransformer((markdown, { messageType, isStreaming }) => {
if (isStreaming || messageType === "assistant-thinking") return markdown;
return markdown.replaceAll("-->", "→");
});
// Show :warning: shortcodes in changelogs as a symbol.
pi.registerMarkdownTransformer((markdown) => markdown.replaceAll(":warning:", "⚠"), {
messageTypes: ["changelog"],
});
```
If a transformer throws, Pi keeps the Markdown produced so far and continues with the next transformer. The hook is display-only: the original message remains unchanged in the session and model context. It runs for new user messages, assistant streaming updates, restored session messages, and terminal width changes, so transformers should remain synchronous and inexpensive.
If a transformer throws, Pi keeps the Markdown produced so far and continues with the next transformer. The hook is display-only: the original message remains unchanged in the session and model context. It runs for new messages, assistant streaming updates, restored session messages, and terminal width changes, so transformers should remain synchronous and inexpensive. An extension has one transformer; registering again replaces it.
### pi.registerEntryRenderer(customType, renderer)