mirror of
https://github.com/badlogic/pi-mono.git
synced 2026-11-05 23:37:25 +00:00
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:
parent
a7a75a9dc6
commit
eebd5d90ca
18 changed files with 448 additions and 41 deletions
|
|
@ -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" },
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue