9d5f158e23
Gated the read.renderMarkdown opt-in at read time (details tagging) instead of inside the renderer. The renderer gate silently flipped every protocol-supplied text/markdown read (skill://, pr://, issue://, history://, rule://, omp://, agent://, vault://, local://, memory://, ssh://) from the formatted markdown cell to the raw code cell when the setting was off, which regressed default TUI behavior. Local file tagging now happens only when the setting is enabled, so the default render path is byte-identical to the pre-setting behavior while opt-in previews still work end-to-end. Also inlined the tautological isMarkdownContentPath wrapper, pinned the widened prose-summary bypass (.mdx stays verbatim when prose summaries are off) with a test, and documented it under Changed in the changelog.
225 lines
7.3 KiB
TypeScript
225 lines
7.3 KiB
TypeScript
import { afterAll, afterEach, beforeAll, describe, expect, it } from "bun:test";
|
|
import * as path from "node:path";
|
|
import * as url from "node:url";
|
|
import { resetSettingsForTest, Settings, settings } from "@oh-my-pi/pi-coding-agent/config/settings";
|
|
import { ToolExecutionComponent } from "@oh-my-pi/pi-coding-agent/modes/components/tool-execution";
|
|
import { theme as activeTheme, getThemeByName, initTheme } from "@oh-my-pi/pi-coding-agent/modes/theme/theme";
|
|
import { readToolRenderer } from "@oh-my-pi/pi-coding-agent/tools/read";
|
|
import type { TUI } from "@oh-my-pi/pi-tui";
|
|
|
|
function extractLinkUris(text: string): string[] {
|
|
return [...text.matchAll(/\x1b\]8;[^;]*;([^\x1b]+)\x1b\\/g)].map(match => match[1]!);
|
|
}
|
|
|
|
function extractLinkTexts(text: string): string[] {
|
|
return [...text.matchAll(/\x1b\]8;[^;]*;[^\x1b]+\x1b\\([\s\S]*?)\x1b\]8;;\x1b\\/g)].map(match =>
|
|
Bun.stripANSI(match[1]!),
|
|
);
|
|
}
|
|
|
|
beforeAll(async () => {
|
|
await initTheme();
|
|
resetSettingsForTest();
|
|
await Settings.init({ inMemory: true });
|
|
});
|
|
|
|
afterEach(() => {
|
|
settings.clearOverride("tui.hyperlinks");
|
|
});
|
|
|
|
afterAll(() => {
|
|
resetSettingsForTest();
|
|
});
|
|
|
|
describe("readToolRenderer hyperlinks", () => {
|
|
it("links local-style read titles to the resolved filesystem path and selected line", async () => {
|
|
settings.override("tui.hyperlinks", "always");
|
|
const theme = await getThemeByName("dark");
|
|
expect(theme).toBeDefined();
|
|
|
|
const handoffPath = path.resolve("/tmp/omp-local/handoff.md");
|
|
const component = readToolRenderer.renderResult(
|
|
{
|
|
content: [{ type: "text", text: "second line" }],
|
|
details: {
|
|
resolvedPath: handoffPath,
|
|
displayContent: { text: "second line", startLine: 2 },
|
|
contentType: "text/plain",
|
|
},
|
|
},
|
|
{ expanded: false, isPartial: false },
|
|
theme!,
|
|
{ path: "local://handoff.md:2" },
|
|
);
|
|
|
|
const rendered = component.render(200).join("\n");
|
|
expect(rendered).toContain("local://handoff.md");
|
|
expect(rendered).toContain(":2");
|
|
const handoffUri = new URL(url.pathToFileURL(path.resolve(handoffPath)).href);
|
|
handoffUri.searchParams.set("line", "2");
|
|
expect(extractLinkUris(rendered)).toContain(handoffUri.href);
|
|
expect(extractLinkTexts(rendered)).toContain("local://handoff.md");
|
|
expect(extractLinkTexts(rendered)).not.toContain("local://handoff.md:2");
|
|
});
|
|
|
|
it("links absolute read call paths to file URIs with selector lines", async () => {
|
|
settings.override("tui.hyperlinks", "always");
|
|
const theme = await getThemeByName("dark");
|
|
expect(theme).toBeDefined();
|
|
|
|
const examplePath = path.resolve("/tmp/omp-read/example.ts");
|
|
const component = readToolRenderer.renderCall(
|
|
{ path: `${examplePath}:10-12` },
|
|
{ expanded: false, isPartial: false },
|
|
theme!,
|
|
);
|
|
|
|
const rendered = component.render(200).join("\n");
|
|
expect(Bun.stripANSI(rendered)).toContain(`${examplePath}:10-12`);
|
|
const exampleUri = new URL(url.pathToFileURL(path.resolve(examplePath)).href);
|
|
exampleUri.searchParams.set("line", "10");
|
|
expect(extractLinkUris(rendered)).toContain(exampleUri.href);
|
|
expect(extractLinkTexts(rendered)).toContain(examplePath);
|
|
expect(extractLinkTexts(rendered)).not.toContain(`${examplePath}:10-12`);
|
|
});
|
|
|
|
it("links HTTP read result headers to the final URL", async () => {
|
|
settings.override("tui.hyperlinks", "always");
|
|
const theme = await getThemeByName("dark");
|
|
expect(theme).toBeDefined();
|
|
|
|
const component = readToolRenderer.renderResult(
|
|
{
|
|
content: [{ type: "text", text: "---\n\nhello" }],
|
|
details: {
|
|
kind: "url",
|
|
url: "http://example.com/start",
|
|
finalUrl: "http://example.com/final",
|
|
contentType: "text/plain",
|
|
method: "fetch",
|
|
truncated: false,
|
|
notes: [],
|
|
},
|
|
} as never,
|
|
{ expanded: false, isPartial: false },
|
|
theme!,
|
|
{ path: "http://example.com/start" },
|
|
);
|
|
|
|
const rendered = component.render(200).join("\n");
|
|
expect(rendered).toContain("example.com /final");
|
|
expect(extractLinkUris(rendered)).toContain("http://example.com/final");
|
|
});
|
|
});
|
|
|
|
describe("readToolRenderer markdown content", () => {
|
|
it("renders text/markdown details through the markdown renderer", async () => {
|
|
const theme = await getThemeByName("dark");
|
|
expect(theme).toBeDefined();
|
|
|
|
const component = readToolRenderer.renderResult(
|
|
{
|
|
content: [{ type: "text", text: "[notes.md#ABCD]\n1:# Heading\n2:\n3:This is **bold** text." }],
|
|
details: {
|
|
displayContent: { text: "# Heading\n\nThis is **bold** text.", startLine: 1 },
|
|
contentType: "text/markdown",
|
|
},
|
|
},
|
|
{ expanded: true, isPartial: false },
|
|
theme!,
|
|
{ path: "notes.md" },
|
|
);
|
|
|
|
const stripped = component
|
|
.render(100)
|
|
.map(line => Bun.stripANSI(line))
|
|
.join("\n");
|
|
expect(stripped).toContain("Heading");
|
|
expect(stripped).toContain("This is bold text.");
|
|
expect(stripped).not.toContain("# Heading");
|
|
expect(stripped).not.toContain("**bold**");
|
|
});
|
|
|
|
it("keeps untagged markdown source in the code renderer", async () => {
|
|
const theme = await getThemeByName("dark");
|
|
expect(theme).toBeDefined();
|
|
|
|
const component = readToolRenderer.renderResult(
|
|
{
|
|
content: [{ type: "text", text: "[notes.md#ABCD]\n1:# Heading\n2:\n3:This is **bold** text." }],
|
|
details: {
|
|
displayContent: { text: "# Heading\n\nThis is **bold** text.", startLine: 1 },
|
|
},
|
|
},
|
|
{ expanded: true, isPartial: false },
|
|
theme!,
|
|
{ path: "notes.md" },
|
|
);
|
|
|
|
const stripped = component
|
|
.render(100)
|
|
.map(line => Bun.stripANSI(line))
|
|
.join("\n");
|
|
expect(stripped).toContain("# Heading");
|
|
expect(stripped).toContain("**bold**");
|
|
});
|
|
|
|
it("keeps raw markdown selector reads in the code renderer", async () => {
|
|
const theme = await getThemeByName("dark");
|
|
expect(theme).toBeDefined();
|
|
|
|
const component = readToolRenderer.renderResult(
|
|
{
|
|
content: [{ type: "text", text: "# Heading\n\nThis is **bold** text." }],
|
|
details: {
|
|
displayContent: { text: "# Heading\n\nThis is **bold** text.", startLine: 1 },
|
|
contentType: "text/markdown",
|
|
},
|
|
},
|
|
{ expanded: true, isPartial: false },
|
|
theme!,
|
|
{ path: "notes.md:raw" },
|
|
);
|
|
|
|
const stripped = component
|
|
.render(100)
|
|
.map(line => Bun.stripANSI(line))
|
|
.join("\n");
|
|
expect(stripped).toContain("# Heading");
|
|
expect(stripped).toContain("**bold**");
|
|
});
|
|
});
|
|
|
|
describe("read ToolExecutionComponent framing", () => {
|
|
it("renders framed read results inside the standard tool container padding", () => {
|
|
const uiStub = { requestRender() {}, requestComponentRender() {} } as unknown as TUI;
|
|
const component = new ToolExecutionComponent("read", { path: "src/example.ts" }, {}, undefined, uiStub);
|
|
component.updateResult(
|
|
{
|
|
content: [{ type: "text", text: "export const x = 1;" }],
|
|
details: {
|
|
displayContent: { text: "export const x = 1;", startLine: 1 },
|
|
contentType: "text/plain",
|
|
},
|
|
},
|
|
false,
|
|
);
|
|
|
|
try {
|
|
const lines = component.render(80).map(line => Bun.stripANSI(line));
|
|
const topBorderIndex = lines.findIndex(
|
|
line => line.includes(activeTheme.boxRound.topLeft) && line.includes("Read"),
|
|
);
|
|
const bottomBorderIndex = lines.findIndex(
|
|
(line, index) => index > topBorderIndex && line.includes(activeTheme.boxRound.bottomLeft),
|
|
);
|
|
|
|
expect(topBorderIndex).toBeGreaterThanOrEqual(0);
|
|
expect(lines[topBorderIndex + 1]).toContain("export const x = 1;");
|
|
expect(bottomBorderIndex).toBeGreaterThan(topBorderIndex);
|
|
} finally {
|
|
component.stopAnimation();
|
|
}
|
|
});
|
|
});
|