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

@ -6,6 +6,7 @@
- `pi update` now lists each updated package with its npm version or git commit change, for example `npm:foo 1.2.0 -> 1.3.0`, and prints `Packages are already up to date` when nothing changed. When nothing changed but an update printed a warning, for example because npm left the old version installed after resolving stale registry metadata, it prints `No packages were updated, see the warnings above` instead; in offline mode it prints `Skipped package updates (offline mode)`. `PackageManager.update()` reports each changed package as a new `result` progress event whose `message` holds that line ([#11707](https://github.com/earendil-works/pi/pull/11707), [#11711](https://github.com/earendil-works/pi/pull/11711))
- Anthropic sessions now use message threads: each request sends only the new messages and the server continues from the previous reply, falling back to the full request when the conversation changed (for example after compaction or a tool list change). On by default for the `anthropic` provider on `api.anthropic.com`; set `compat.supportsMessageThreads` in `models.json` to enable it for gateways that forward the `thread` field, or to turn it off. Cache warming does not touch the session's thread ([#11545](https://github.com/earendil-works/pi/pull/11545))
- `pi.registerMarkdownTransformer()` takes an optional `{ messageTypes }` that selects the Markdown surfaces a transformer runs on. Besides user, assistant, and thinking Markdown, transformers can now opt into `custom` (custom messages and skill blocks), `summary` (branch and compaction summaries), and `changelog` (What's New, `/changelog`, the Extension Updates notice, and the Update Available note). Without options a transformer runs on user, assistant, and thinking Markdown as before. The built-in Mermaid renderer now also draws diagrams in expanded branch and compaction summaries
### Fixed

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)

View file

@ -114,8 +114,11 @@ export type {
LoadExtensionsResult,
LsToolCallEvent,
LsToolResultEvent,
MarkdownMessageType,
MarkdownTransformContext,
MarkdownTransformer,
MarkdownTransformerOptions,
MarkdownTransformerRegistration,
McpServersChangeEvent,
// Events - Message
MessageEndEvent,

View file

@ -29,7 +29,9 @@ import type {
ExtensionRuntime,
ExtensionVirtualModel,
LoadExtensionsResult,
MarkdownMessageType,
MarkdownTransformer,
MarkdownTransformerOptions,
MessageRenderer,
ProviderConfig,
RegisteredCommand,
@ -246,6 +248,8 @@ export function createExtensionRuntime(): ExtensionRuntime {
* Registration methods write to the extension object.
* Action methods delegate to the shared runtime.
*/
const DEFAULT_MARKDOWN_MESSAGE_TYPES: readonly MarkdownMessageType[] = ["user", "assistant", "assistant-thinking"];
function createExtensionAPI(
extension: Extension,
runtime: ExtensionRuntime,
@ -358,9 +362,12 @@ function createExtensionAPI(
extension.messageRenderers.set(customType, renderer as MessageRenderer);
},
registerMarkdownTransformer(transformer: MarkdownTransformer): void {
registerMarkdownTransformer(transformer: MarkdownTransformer, options?: MarkdownTransformerOptions): void {
assertActive();
extension.markdownTransformer = transformer;
extension.markdownTransformer = {
transformer,
messageTypes: new Set(options?.messageTypes ?? DEFAULT_MARKDOWN_MESSAGE_TYPES),
};
},
registerEntryRenderer<T>(customType: string, renderer: EntryRenderer<T>): void {

View file

@ -789,8 +789,15 @@ export class ExtensionRunner {
return undefined;
}
/** Markdown transformers in extension load order; each one leaves Markdown of unregistered surfaces unchanged. */
getMarkdownTransformers(): MarkdownTransformer[] {
return this.extensions.flatMap((ext) => (ext.markdownTransformer ? [ext.markdownTransformer] : []));
return this.extensions.flatMap((ext): MarkdownTransformer[] => {
if (!ext.markdownTransformer) return [];
const { transformer, messageTypes } = ext.markdownTransformer;
return [
(markdown, context) => (messageTypes.has(context.messageType) ? transformer(markdown, context) : markdown),
];
});
}
/** Renderers of calls to `toolName`: extension resolvers in load order, then `base`. */

View file

@ -1558,8 +1558,18 @@ export interface MessageRenderOptions extends KeybindingHints {
outputPad: number;
}
/**
* Markdown surface a transformer runs on.
* - `user`, `assistant`, `assistant-thinking`: transcript messages (the default types of a transformer)
* - `custom`: custom messages without a custom 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" and `/changelog`), the "Extension Updates" notice, and the release note of
* the "Update Available" notice
*/
export type MarkdownMessageType = "user" | "assistant" | "assistant-thinking" | "custom" | "summary" | "changelog";
export interface MarkdownTransformContext {
messageType: "user" | "assistant" | "assistant-thinking";
messageType: MarkdownMessageType;
isStreaming: boolean;
availableWidth: number;
/** Persisted session-entry id. Undefined for live messages or live rendering; available when rendering from persisted session entries. */
@ -1570,6 +1580,17 @@ export interface MarkdownTransformContext {
export type MarkdownTransformer = (markdown: string, context: MarkdownTransformContext) => string;
export interface MarkdownTransformerOptions {
/** Surfaces the transformer runs on. Default: `["user", "assistant", "assistant-thinking"]`. */
messageTypes?: readonly MarkdownMessageType[];
}
/** A registered Markdown transformer and the surfaces it runs on. */
export interface MarkdownTransformerRegistration {
transformer: MarkdownTransformer;
messageTypes: ReadonlySet<MarkdownMessageType>;
}
export interface EntryRenderOptions extends KeybindingHints {
expanded: boolean;
}
@ -1738,8 +1759,11 @@ export interface ExtensionAPI {
/** Register a custom renderer for CustomMessageEntry. */
registerMessageRenderer<T = unknown>(customType: string, renderer: MessageRenderer<T>): void;
/** Register a transformer for user and assistant Markdown before Pi renders it in the interactive transcript. */
registerMarkdownTransformer(transformer: MarkdownTransformer): void;
/**
* Register a transformer for Markdown before Pi renders it in interactive mode. By default it runs on user,
* assistant and thinking Markdown; `options.messageTypes` selects the surfaces instead.
*/
registerMarkdownTransformer(transformer: MarkdownTransformer, options?: MarkdownTransformerOptions): void;
/** Register a custom renderer for CustomEntry. Custom entries do not participate in LLM context. */
registerEntryRenderer<T = unknown>(customType: string, renderer: EntryRenderer<T>): void;
@ -2300,7 +2324,7 @@ export interface Extension {
tools: Map<string, RegisteredTool>;
messageRenderers: Map<string, MessageRenderer>;
toolRenderers?: ToolRendererResolver[];
markdownTransformer?: MarkdownTransformer;
markdownTransformer?: MarkdownTransformerRegistration;
entryRenderers?: Map<string, EntryRenderer>;
commands: Map<string, RegisteredCommand>;
flags: Map<string, ExtensionFlag>;

View file

@ -122,8 +122,11 @@ export type {
KeybindingsManager,
LoadExtensionsResult,
LsToolCallEvent,
MarkdownMessageType,
MarkdownTransformContext,
MarkdownTransformer,
MarkdownTransformerOptions,
MarkdownTransformerRegistration,
McpServersChangeEvent,
MessageEndEvent,
MessageEndEventResult,

View file

@ -1,7 +1,9 @@
import { Box, Container, Markdown, type MarkdownTheme, MouseRegion, Spacer, Text } from "@earendil-works/pi-tui";
import type { MarkdownTransformer } from "../../../core/extensions/types.ts";
import type { BranchSummaryMessage } from "../../../core/messages.ts";
import { getMarkdownTheme, theme } from "../theme/theme.ts";
import { keyText } from "./keybinding-hints.ts";
import { createMarkdownTransform } from "./markdown-transform.ts";
/**
* Component that renders a branch summary message with collapsed/expanded state.
@ -11,11 +13,24 @@ export class BranchSummaryMessageComponent extends Box {
private expanded = false;
private message: BranchSummaryMessage;
private markdownTheme: MarkdownTheme;
private markdownTransformers: readonly MarkdownTransformer[];
private messageId?: string;
private timestamp?: string;
constructor(message: BranchSummaryMessage, markdownTheme: MarkdownTheme = getMarkdownTheme(), outputPad = 1) {
constructor(
message: BranchSummaryMessage,
markdownTheme: MarkdownTheme = getMarkdownTheme(),
outputPad = 1,
markdownTransformers: readonly MarkdownTransformer[] = [],
messageId?: string,
timestamp?: string,
) {
super(outputPad, 1, (t) => theme.bg("customMessageBg", t));
this.message = message;
this.markdownTheme = markdownTheme;
this.markdownTransformers = markdownTransformers;
this.messageId = messageId;
this.timestamp = timestamp;
this.updateDisplay();
}
@ -43,10 +58,23 @@ export class BranchSummaryMessageComponent extends Box {
if (this.expanded) {
const header = "**Branch Summary**\n\n";
const transform = createMarkdownTransform(
"summary",
() => false,
this.markdownTransformers,
this.messageId,
this.timestamp,
);
content.addChild(
new Markdown(header + this.message.summary, 0, 0, this.markdownTheme, {
color: (text: string) => theme.fg("customMessageText", text),
}),
new Markdown(
this.message.summary,
0,
0,
this.markdownTheme,
{ color: (text: string) => theme.fg("customMessageText", text) },
// Transformers see only the summary content, not Pi's header.
{ transform: (markdown, width) => header + transform(markdown, width) },
),
);
} else {
content.addChild(

View file

@ -1,7 +1,9 @@
import { Box, Container, Markdown, type MarkdownTheme, MouseRegion, Spacer, Text } from "@earendil-works/pi-tui";
import type { MarkdownTransformer } from "../../../core/extensions/types.ts";
import type { CompactionSummaryMessage } from "../../../core/messages.ts";
import { getMarkdownTheme, theme } from "../theme/theme.ts";
import { keyText } from "./keybinding-hints.ts";
import { createMarkdownTransform } from "./markdown-transform.ts";
/**
* Component that renders a compaction message with collapsed/expanded state.
@ -11,11 +13,24 @@ export class CompactionSummaryMessageComponent extends Box {
private expanded = false;
private message: CompactionSummaryMessage;
private markdownTheme: MarkdownTheme;
private markdownTransformers: readonly MarkdownTransformer[];
private messageId?: string;
private timestamp?: string;
constructor(message: CompactionSummaryMessage, markdownTheme: MarkdownTheme = getMarkdownTheme(), outputPad = 1) {
constructor(
message: CompactionSummaryMessage,
markdownTheme: MarkdownTheme = getMarkdownTheme(),
outputPad = 1,
markdownTransformers: readonly MarkdownTransformer[] = [],
messageId?: string,
timestamp?: string,
) {
super(outputPad, 1, (t) => theme.bg("customMessageBg", t));
this.message = message;
this.markdownTheme = markdownTheme;
this.markdownTransformers = markdownTransformers;
this.messageId = messageId;
this.timestamp = timestamp;
this.updateDisplay();
}
@ -44,10 +59,23 @@ export class CompactionSummaryMessageComponent extends Box {
if (this.expanded) {
const header = `**Compacted from ${tokenStr} tokens**\n\n`;
const transform = createMarkdownTransform(
"summary",
() => false,
this.markdownTransformers,
this.messageId,
this.timestamp,
);
content.addChild(
new Markdown(header + this.message.summary, 0, 0, this.markdownTheme, {
color: (text: string) => theme.fg("customMessageText", text),
}),
new Markdown(
this.message.summary,
0,
0,
this.markdownTheme,
{ color: (text: string) => theme.fg("customMessageText", text) },
// Transformers see only the summary content, not Pi's header.
{ transform: (markdown, width) => header + transform(markdown, width) },
),
);
} else {
content.addChild(

View file

@ -1,10 +1,11 @@
import type { TextContent } from "@earendil-works/pi-ai";
import type { Component } from "@earendil-works/pi-tui";
import { Box, Container, Markdown, type MarkdownTheme, Spacer, Text } from "@earendil-works/pi-tui";
import type { MessageRenderer } from "../../../core/extensions/types.ts";
import type { MarkdownTransformer, MessageRenderer } from "../../../core/extensions/types.ts";
import type { CustomMessage } from "../../../core/messages.ts";
import { getMarkdownTheme, theme } from "../theme/theme.ts";
import { keyHint, keyText } from "./keybinding-hints.ts";
import { createMarkdownTransform } from "./markdown-transform.ts";
/**
* Component that renders a custom message entry from extensions.
@ -18,18 +19,27 @@ export class CustomMessageComponent extends Container {
private markdownTheme: MarkdownTheme;
private _expanded = false;
private outputPad: number;
private markdownTransformers: readonly MarkdownTransformer[];
private messageId?: string;
private timestamp?: string;
constructor(
message: CustomMessage<unknown>,
customRenderer?: MessageRenderer,
markdownTheme: MarkdownTheme = getMarkdownTheme(),
outputPad = 1,
markdownTransformers: readonly MarkdownTransformer[] = [],
messageId?: string,
timestamp?: string,
) {
super();
this.message = message;
this.customRenderer = customRenderer;
this.markdownTheme = markdownTheme;
this.outputPad = outputPad;
this.markdownTransformers = markdownTransformers;
this.messageId = messageId;
this.timestamp = timestamp;
this.addChild(new Spacer(1));
@ -107,9 +117,22 @@ export class CustomMessageComponent extends Container {
}
this.box.addChild(
new Markdown(text, 0, 0, this.markdownTheme, {
color: (text: string) => theme.fg("customMessageText", text),
}),
new Markdown(
text,
0,
0,
this.markdownTheme,
{ color: (text: string) => theme.fg("customMessageText", text) },
{
transform: createMarkdownTransform(
"custom",
() => false,
this.markdownTransformers,
this.messageId,
this.timestamp,
),
},
),
);
}
}

View file

@ -60,9 +60,10 @@ function themedLines(art: MermaidArt, theme: Theme): string[] {
export function createMermaidMarkdownTransformer(options: MermaidTransformerOptions): MarkdownTransformer {
return (markdown, context) => {
const mode = options.getMode();
// Diagrams render in user and assistant text and in model-written summaries, not in thinking or other surfaces.
if (
mode === "off" ||
context.messageType === "assistant-thinking" ||
(context.messageType !== "user" && context.messageType !== "assistant" && context.messageType !== "summary") ||
(context.isStreaming && mode !== "streaming")
) {
return markdown;

View file

@ -1,7 +1,9 @@
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.
@ -12,11 +14,24 @@ 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) {
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();
}
@ -43,10 +58,23 @@ export class SkillInvocationMessageComponent extends Box {
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(header + this.skillBlock.content, 0, 0, this.markdownTheme, {
color: (text: string) => theme.fg("customMessageText", text),
}),
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)

View file

@ -34,6 +34,7 @@ import {
CombinedAutocompleteProvider,
type Component,
Container,
type DefaultTextStyle,
fuzzyFilter,
getCapabilities,
hyperlink,
@ -153,6 +154,7 @@ import { ExtensionSelectorComponent } from "./components/extension-selector.ts";
import { FooterComponent, formatTokens } from "./components/footer.ts";
import { formatKeyText, keyDisplayText, keyHint, keyText, rawKeyHint } from "./components/keybinding-hints.ts";
import { LoginDialogComponent } from "./components/login-dialog.ts";
import { createMarkdownTransform } from "./components/markdown-transform.ts";
import { createMermaidMarkdownTransformer } from "./components/mermaid.ts";
import { ModelSelectorComponent } from "./components/model-selector.ts";
import {
@ -873,9 +875,7 @@ export class InteractiveMode {
} else {
this.chatContainer.addChild(new ThemedText(() => theme.bold(theme.fg("accent", "What's New")), 1, 0));
this.chatContainer.addChild(new Spacer(1));
this.chatContainer.addChild(
new Markdown(this.changelogMarkdown.trim(), 1, 0, this.getMarkdownThemeWithSettings()),
);
this.chatContainer.addChild(this.createChangelogMarkdown(this.changelogMarkdown.trim(), 1, 0));
this.chatContainer.addChild(new Spacer(1));
}
}
@ -894,7 +894,7 @@ export class InteractiveMode {
);
this.chatContainer.addChild(new Spacer(1));
const markdown = this.extensionChangelogUpdates.map((update) => update.markdown).join("\n\n");
this.chatContainer.addChild(new Markdown(markdown, 1, 0, this.getMarkdownThemeWithSettings()));
this.chatContainer.addChild(this.createChangelogMarkdown(markdown, 1, 0));
this.chatContainer.addChild(new Spacer(1));
}
}
@ -2346,6 +2346,18 @@ export class InteractiveMode {
return [this.mermaidMarkdownTransformer, ...this.session.extensionRunner.getMarkdownTransformers()];
}
/** Markdown of a changelog or release note that runs the Markdown transformers of the `changelog` type. */
private createChangelogMarkdown(
markdown: string,
paddingX: number,
paddingY: number,
defaultTextStyle?: DefaultTextStyle,
): Markdown {
return new Markdown(markdown, paddingX, paddingY, this.getMarkdownThemeWithSettings(), defaultTextStyle, {
transform: createMarkdownTransform("changelog", () => false, this.getMarkdownTransformers()),
});
}
/**
* Set up keyboard shortcuts registered by extensions.
*/
@ -3980,6 +3992,9 @@ export class InteractiveMode {
renderer,
this.getMarkdownThemeWithSettings(),
this.outputPad,
this.getMarkdownTransformers(),
options?.messageId,
options?.timestamp,
);
component.setExpanded(this.toolOutputExpanded);
this.chatContainer.addChild(component);
@ -3992,6 +4007,9 @@ export class InteractiveMode {
message,
this.getMarkdownThemeWithSettings(),
this.outputPad,
this.getMarkdownTransformers(),
options?.messageId,
options?.timestamp,
);
component.setExpanded(this.toolOutputExpanded);
this.chatContainer.addChild(component);
@ -4003,6 +4021,9 @@ export class InteractiveMode {
message,
this.getMarkdownThemeWithSettings(),
this.outputPad,
this.getMarkdownTransformers(),
options?.messageId,
options?.timestamp,
);
component.setExpanded(this.toolOutputExpanded);
this.chatContainer.addChild(component);
@ -4023,6 +4044,9 @@ export class InteractiveMode {
skillBlock,
this.getMarkdownThemeWithSettings(),
this.outputPad,
this.getMarkdownTransformers(),
options?.messageId,
options?.timestamp,
);
component.setExpanded(this.toolOutputExpanded);
this.chatContainer.addChild(component);
@ -4761,9 +4785,7 @@ export class InteractiveMode {
if (note) {
this.chatContainer.addChild(new Spacer(1));
this.chatContainer.addChild(
new Markdown(note, 1, 0, this.getMarkdownThemeWithSettings(), {
color: (text) => theme.fg("muted", text),
}),
this.createChangelogMarkdown(note, 1, 0, { color: (text) => theme.fg("muted", text) }),
);
this.chatContainer.addChild(new Spacer(1));
}
@ -6899,7 +6921,7 @@ export class InteractiveMode {
this.chatContainer.addChild(new DynamicBorder());
this.chatContainer.addChild(new ThemedText(() => theme.bold(theme.fg("accent", "What's New")), 1, 0));
this.chatContainer.addChild(new Spacer(1));
this.chatContainer.addChild(new Markdown(changelogMarkdown, 1, 1, this.getMarkdownThemeWithSettings()));
this.chatContainer.addChild(this.createChangelogMarkdown(changelogMarkdown, 1, 1));
this.chatContainer.addChild(new DynamicBorder());
this.ui.requestRender();
}

View file

@ -1,5 +1,6 @@
import type { Box, TuiMouseEvent } from "@earendil-works/pi-tui";
import { beforeAll, describe, expect, test } from "vitest";
import type { MarkdownTransformContext, MarkdownTransformer } from "../src/core/extensions/types.ts";
import { BranchSummaryMessageComponent } from "../src/modes/interactive/components/branch-summary-message.ts";
import { CompactionSummaryMessageComponent } from "../src/modes/interactive/components/compaction-summary-message.ts";
import { SkillInvocationMessageComponent } from "../src/modes/interactive/components/skill-invocation-message.ts";
@ -80,4 +81,74 @@ describe("collapsible message components", () => {
clickRow(component, "[skill]");
expect(renderText(component)).not.toContain("skill details");
});
describe("Markdown transformers", () => {
const seen: Array<{ markdown: string; context: MarkdownTransformContext }> = [];
const transformers: MarkdownTransformer[] = [
(markdown, context) => {
seen.push({ markdown, context });
return markdown.replace("details", "transformed");
},
];
test.each([
{
kind: "compaction summary",
messageType: "summary",
header: "Compacted from 1,234 tokens",
create: () =>
new CompactionSummaryMessageComponent(
{ role: "compactionSummary", summary: "details", tokensBefore: 1234, timestamp: Date.now() },
undefined,
1,
transformers,
"entry-1",
),
},
{
kind: "branch summary",
messageType: "summary",
header: "Branch Summary",
create: () =>
new BranchSummaryMessageComponent(
{ role: "branchSummary", summary: "details", fromId: "entry-0", timestamp: Date.now() },
undefined,
1,
transformers,
"entry-1",
),
},
{
kind: "skill",
messageType: "custom",
header: "example-skill",
create: () =>
new SkillInvocationMessageComponent(
{
name: "example-skill",
location: "/tmp/example-skill.md",
content: "details",
userMessage: undefined,
},
undefined,
1,
transformers,
"entry-1",
),
},
])("runs as $messageType on expanded $kind content without Pi's header", ({ messageType, header, create }) => {
seen.length = 0;
const component = create();
component.setExpanded(true);
const rendered = renderText(component);
expect(rendered).toContain(header);
expect(rendered).toContain("transformed");
expect(rendered).not.toContain("details");
expect(seen[0]).toMatchObject({
markdown: "details",
context: { messageType, messageId: "entry-1" },
});
});
});
});

View file

@ -1,6 +1,6 @@
import { Text } from "@earendil-works/pi-tui";
import { describe, expect, test } from "vitest";
import type { MessageRenderer, MessageRenderOptions } from "../src/core/extensions/types.ts";
import type { MarkdownTransformContext, MessageRenderer, MessageRenderOptions } from "../src/core/extensions/types.ts";
import type { CustomMessage } from "../src/core/messages.ts";
import { CustomMessageComponent } from "../src/modes/interactive/components/custom-message.ts";
import { initTheme } from "../src/modes/interactive/theme/theme.ts";
@ -41,4 +41,40 @@ describe("CustomMessageComponent", () => {
.some((line) => line.startsWith("custom")),
).toBe(true);
});
test("runs Markdown transformers on default-rendered content", () => {
initTheme("dark");
const contexts: MarkdownTransformContext[] = [];
const message: CustomMessage = {
role: "custom",
customType: "test",
content: "original",
display: true,
timestamp: Date.now(),
};
const component = new CustomMessageComponent(
message,
undefined,
undefined,
1,
[
(markdown, context) => {
contexts.push(context);
return markdown.replace("original", "transformed");
},
],
"entry-1",
"2025-01-15T10:30:00.000Z",
);
const rendered = component.render(40).map(stripAnsi).join("\n");
expect(rendered).toContain("transformed");
expect(rendered).not.toContain("original");
expect(contexts[0]).toMatchObject({
messageType: "custom",
isStreaming: false,
messageId: "entry-1",
timestamp: "2025-01-15T10:30:00.000Z",
});
});
});

View file

@ -21,6 +21,7 @@ import type {
ExtensionContextActions,
ExtensionFactory,
ExtensionUIContext,
MarkdownMessageType,
ProviderConfig,
} from "../src/core/extensions/types.ts";
import { KeybindingsManager, type KeyId } from "../src/core/keybindings.ts";
@ -721,6 +722,37 @@ describe("ExtensionRunner", () => {
expect(runner.getMarkdownTransformers()).toHaveLength(2);
});
it("runs Markdown transformers only on their message types", async () => {
fs.writeFileSync(
path.join(extensionsDir, "markdown-default.ts"),
`export default function(pi) { pi.registerMarkdownTransformer((markdown) => markdown + " default"); }`,
);
fs.writeFileSync(
path.join(extensionsDir, "markdown-opt-in.ts"),
`export default function(pi) {
pi.registerMarkdownTransformer((markdown) => markdown + " opt-in", {
messageTypes: ["assistant", "changelog"],
});
}`,
);
const result = await discoverAndLoadExtensions([], tempDir, tempDir);
const runner = new ExtensionRunner(result.extensions, result.runtime, tempDir, sessionManager, modelRegistry);
const transformers = runner.getMarkdownTransformers();
const apply = (messageType: MarkdownMessageType) =>
transformers.reduce(
(markdown, transformer) =>
transformer(markdown, { messageType, isStreaming: false, availableWidth: 80 }),
"text",
);
expect(apply("user")).toBe("text default");
expect(apply("assistant-thinking")).toBe("text default");
expect(apply("assistant")).toBe("text default opt-in");
expect(apply("changelog")).toBe("text opt-in");
expect(apply("custom")).toBe("text");
});
it("gets message renderer by type", async () => {
const extCode = `
export default function(pi) {

View file

@ -1,10 +1,15 @@
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, test } from "vitest";
import { Container } from "@earendil-works/pi-tui";
import { afterEach, beforeAll, beforeEach, describe, expect, test } from "vitest";
import { ExtensionRunner } from "../src/core/extensions/runner.ts";
import type { Extension, MarkdownMessageType, MarkdownTransformer } from "../src/core/extensions/types.ts";
import { InMemorySettingsStorage, SettingsManager } from "../src/core/settings-manager.ts";
import { getSourcePackageKey, type SourceInfo } from "../src/core/source-info.ts";
import { InteractiveMode } from "../src/modes/interactive/interactive-mode.ts";
import { initTheme } from "../src/modes/interactive/theme/theme.ts";
import { stripAnsi } from "../src/utils/ansi.ts";
type ExtensionChangelogUpdate = { name: string; version: string; markdown: string };
@ -17,9 +22,21 @@ type ExtensionChangelogContext = {
readPackageJson(packageRoot: string): { name?: string; version?: string } | undefined;
};
type StartupNoticesContext = {
startupNoticesShown: boolean;
changelogMarkdown: string | undefined;
extensionChangelogUpdates: ExtensionChangelogUpdate[];
version: string;
chatContainer: Container;
settingsManager: SettingsManager;
mermaidMarkdownTransformer: MarkdownTransformer;
session: { extensionRunner: { getMarkdownTransformers(): MarkdownTransformer[] } };
};
const prototype = InteractiveMode.prototype as unknown as {
getExtensionChangelogUpdates(this: ExtensionChangelogContext): ExtensionChangelogUpdate[];
readPackageJson(packageRoot: string): { name?: string; version?: string } | undefined;
showStartupNoticesIfNeeded(this: StartupNoticesContext): void;
};
const CHANGELOG = `# Changelog
@ -159,3 +176,61 @@ describe("InteractiveMode extension changelog", () => {
expect(settingsManager.getExtensionChangelogVersion(getSourcePackageKey(sourceInfo))).toBeUndefined();
});
});
describe("InteractiveMode startup notices", () => {
beforeAll(() => initTheme("dark"));
function extensionWithTransformer(
transformer: MarkdownTransformer,
messageTypes: readonly MarkdownMessageType[],
): Pick<Extension, "markdownTransformer"> {
return { markdownTransformer: { transformer, messageTypes: new Set(messageTypes) } };
}
function renderNotices(extensions: Pick<Extension, "markdownTransformer">[]): string {
// getMarkdownTransformers only reads the extensions' transformer registrations.
const runner = { extensions } as unknown as ExtensionRunner;
const context: StartupNoticesContext = {
startupNoticesShown: false,
changelogMarkdown: "## [2.0.0]\n\n- :warning: Pi change",
extensionChangelogUpdates: [
{
name: "pi-demo",
version: "1.2.0",
markdown: "## pi-demo [1.2.0]\n\n- :warning: Demo change",
},
],
version: "2.0.0",
chatContainer: new Container(),
settingsManager: SettingsManager.fromStorage(new InMemorySettingsStorage()),
mermaidMarkdownTransformer: (markdown) => markdown,
session: {
extensionRunner: {
getMarkdownTransformers: () => ExtensionRunner.prototype.getMarkdownTransformers.call(runner),
},
},
};
Object.setPrototypeOf(context, InteractiveMode.prototype);
prototype.showStartupNoticesIfNeeded.call(context);
return stripAnsi(context.chatContainer.render(120).join("\n"));
}
const replaceShortcodes: MarkdownTransformer = (markdown) => markdown.replaceAll(":warning:", "⚠");
test("runs changelog transformers on What's New and Extension Updates", () => {
const rendered = renderNotices([extensionWithTransformer(replaceShortcodes, ["changelog"])]);
expect(rendered).toContain("⚠ Pi change");
expect(rendered).toContain("⚠ Demo change");
expect(rendered).not.toContain(":warning:");
});
test("skips transformers registered for the default message types", () => {
const rendered = renderNotices([
extensionWithTransformer(replaceShortcodes, ["user", "assistant", "assistant-thinking"]),
]);
expect(rendered).toContain(":warning: Pi change");
expect(rendered).toContain(":warning: Demo change");
});
});

View file

@ -97,12 +97,16 @@ describe("Mermaid rendering", () => {
expect(rendered).not.toContain('dropped, expected a link: "also-invalid"');
});
it("respects rendering modes and skips thinking blocks", () => {
it("respects rendering modes and skips thinking blocks and non-transcript surfaces", () => {
const markdown = "```mermaid\nflowchart LR\n A --> B\n```";
expect(transformMermaid(markdown, { mode: "off" })).toBe(markdown);
expect(transformMermaid(markdown, { mode: "final", isStreaming: true })).toBe(markdown);
expect(transformMermaid(markdown, { mode: "final" })).not.toContain("```mermaid");
expect(transformMermaid(markdown, { messageType: "assistant-thinking" })).toBe(markdown);
expect(transformMermaid(markdown, { messageType: "user" })).not.toContain("```mermaid");
expect(transformMermaid(markdown, { messageType: "summary" })).not.toContain("```mermaid");
expect(transformMermaid(markdown, { messageType: "custom" })).toBe(markdown);
expect(transformMermaid(markdown, { messageType: "changelog" })).toBe(markdown);
});
});