From eebd5d90caaabc79259d988efa0996b073e9b918 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Petr=20Hou=C5=A1ka?= Date: Tue, 3 Nov 2026 23:10:47 +0100 Subject: [PATCH] feat(coding-agent): let Markdown transformers opt into more surfaces (#11756) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 `` into `", "→"); }); + +// 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) diff --git a/packages/coding-agent/src/core/extensions/index.ts b/packages/coding-agent/src/core/extensions/index.ts index 99c0044cd..2970288e4 100644 --- a/packages/coding-agent/src/core/extensions/index.ts +++ b/packages/coding-agent/src/core/extensions/index.ts @@ -114,8 +114,11 @@ export type { LoadExtensionsResult, LsToolCallEvent, LsToolResultEvent, + MarkdownMessageType, MarkdownTransformContext, MarkdownTransformer, + MarkdownTransformerOptions, + MarkdownTransformerRegistration, McpServersChangeEvent, // Events - Message MessageEndEvent, diff --git a/packages/coding-agent/src/core/extensions/loader.ts b/packages/coding-agent/src/core/extensions/loader.ts index d82270d13..91c85ead4 100644 --- a/packages/coding-agent/src/core/extensions/loader.ts +++ b/packages/coding-agent/src/core/extensions/loader.ts @@ -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(customType: string, renderer: EntryRenderer): void { diff --git a/packages/coding-agent/src/core/extensions/runner.ts b/packages/coding-agent/src/core/extensions/runner.ts index 77712e677..bf2c63808 100644 --- a/packages/coding-agent/src/core/extensions/runner.ts +++ b/packages/coding-agent/src/core/extensions/runner.ts @@ -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`. */ diff --git a/packages/coding-agent/src/core/extensions/types.ts b/packages/coding-agent/src/core/extensions/types.ts index 098f7fb0d..142451e4d 100644 --- a/packages/coding-agent/src/core/extensions/types.ts +++ b/packages/coding-agent/src/core/extensions/types.ts @@ -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; +} + export interface EntryRenderOptions extends KeybindingHints { expanded: boolean; } @@ -1738,8 +1759,11 @@ export interface ExtensionAPI { /** Register a custom renderer for CustomMessageEntry. */ registerMessageRenderer(customType: string, renderer: MessageRenderer): 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(customType: string, renderer: EntryRenderer): void; @@ -2300,7 +2324,7 @@ export interface Extension { tools: Map; messageRenderers: Map; toolRenderers?: ToolRendererResolver[]; - markdownTransformer?: MarkdownTransformer; + markdownTransformer?: MarkdownTransformerRegistration; entryRenderers?: Map; commands: Map; flags: Map; diff --git a/packages/coding-agent/src/index.ts b/packages/coding-agent/src/index.ts index 7e25c2cb8..bfa256ab8 100644 --- a/packages/coding-agent/src/index.ts +++ b/packages/coding-agent/src/index.ts @@ -122,8 +122,11 @@ export type { KeybindingsManager, LoadExtensionsResult, LsToolCallEvent, + MarkdownMessageType, MarkdownTransformContext, MarkdownTransformer, + MarkdownTransformerOptions, + MarkdownTransformerRegistration, McpServersChangeEvent, MessageEndEvent, MessageEndEventResult, diff --git a/packages/coding-agent/src/modes/interactive/components/branch-summary-message.ts b/packages/coding-agent/src/modes/interactive/components/branch-summary-message.ts index f0915bcf7..cb4264898 100644 --- a/packages/coding-agent/src/modes/interactive/components/branch-summary-message.ts +++ b/packages/coding-agent/src/modes/interactive/components/branch-summary-message.ts @@ -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( diff --git a/packages/coding-agent/src/modes/interactive/components/compaction-summary-message.ts b/packages/coding-agent/src/modes/interactive/components/compaction-summary-message.ts index 883b6f250..8b6ebb224 100644 --- a/packages/coding-agent/src/modes/interactive/components/compaction-summary-message.ts +++ b/packages/coding-agent/src/modes/interactive/components/compaction-summary-message.ts @@ -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( diff --git a/packages/coding-agent/src/modes/interactive/components/custom-message.ts b/packages/coding-agent/src/modes/interactive/components/custom-message.ts index 6dcddf402..a49df0bc8 100644 --- a/packages/coding-agent/src/modes/interactive/components/custom-message.ts +++ b/packages/coding-agent/src/modes/interactive/components/custom-message.ts @@ -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, 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, + ), + }, + ), ); } } diff --git a/packages/coding-agent/src/modes/interactive/components/mermaid.ts b/packages/coding-agent/src/modes/interactive/components/mermaid.ts index a8d5e3cdf..c605fbac7 100644 --- a/packages/coding-agent/src/modes/interactive/components/mermaid.ts +++ b/packages/coding-agent/src/modes/interactive/components/mermaid.ts @@ -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; 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 index 55acf6928..9ad309563 100644 --- a/packages/coding-agent/src/modes/interactive/components/skill-invocation-message.ts +++ b/packages/coding-agent/src/modes/interactive/components/skill-invocation-message.ts @@ -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) diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index 1af1ad6d3..08467497d 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -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(); } diff --git a/packages/coding-agent/test/collapsible-message-components.test.ts b/packages/coding-agent/test/collapsible-message-components.test.ts index 4ad651b70..6d2a8bcd6 100644 --- a/packages/coding-agent/test/collapsible-message-components.test.ts +++ b/packages/coding-agent/test/collapsible-message-components.test.ts @@ -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" }, + }); + }); + }); }); diff --git a/packages/coding-agent/test/custom-message.test.ts b/packages/coding-agent/test/custom-message.test.ts index 3077a6288..3c5163188 100644 --- a/packages/coding-agent/test/custom-message.test.ts +++ b/packages/coding-agent/test/custom-message.test.ts @@ -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", + }); + }); }); diff --git a/packages/coding-agent/test/extensions-runner.test.ts b/packages/coding-agent/test/extensions-runner.test.ts index 7c4a5f428..3ddb1c4d8 100644 --- a/packages/coding-agent/test/extensions-runner.test.ts +++ b/packages/coding-agent/test/extensions-runner.test.ts @@ -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) { diff --git a/packages/coding-agent/test/interactive-mode-extension-changelog.test.ts b/packages/coding-agent/test/interactive-mode-extension-changelog.test.ts index 853be1ec8..995953c48 100644 --- a/packages/coding-agent/test/interactive-mode-extension-changelog.test.ts +++ b/packages/coding-agent/test/interactive-mode-extension-changelog.test.ts @@ -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 { + return { markdownTransformer: { transformer, messageTypes: new Set(messageTypes) } }; + } + + function renderNotices(extensions: Pick[]): 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"); + }); +}); diff --git a/packages/coding-agent/test/mermaid.test.ts b/packages/coding-agent/test/mermaid.test.ts index 332bf1f30..6c145926b 100644 --- a/packages/coding-agent/test/mermaid.test.ts +++ b/packages/coding-agent/test/mermaid.test.ts @@ -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); }); });