From d1f279971cbb62c7af279e47edba1b7b26e952a8 Mon Sep 17 00:00:00 2001 From: oldschoola Date: Tue, 30 Jun 2026 20:40:49 -0700 Subject: [PATCH] feat(coding-agent): add opt-in Markdown read previews Add the read.renderMarkdown setting (off by default) to render Markdown read results as formatted terminal Markdown previews instead of raw source. Centralize Markdown detection in isMarkdownPath() (now covering .md, .markdown, .mdx, .mdc, .mkd, .mdown) and use it consistently across the read tool and all internal-URL protocols (local, memory, skill, ssh, vault) so the preview renderer recognizes Markdown files uniformly. Markdown read results stay verbatim for the model; only the TUI rendering changes. --- packages/coding-agent/CHANGELOG.md | 8 ++ .../src/config/settings-schema.ts | 11 +++ .../src/internal-urls/local-protocol.ts | 3 +- .../src/internal-urls/memory-protocol.ts | 4 +- .../src/internal-urls/skill-protocol.ts | 4 +- .../src/internal-urls/ssh-protocol.ts | 3 +- .../src/internal-urls/vault-protocol.ts | 3 +- .../coding-agent/src/modes/theme/theme.ts | 2 +- packages/coding-agent/src/tools/read.ts | 49 ++++++++--- .../coding-agent/src/utils/lang-from-path.ts | 7 ++ .../coding-agent/test/read-summary.test.ts | 20 +++++ .../test/tools/read-renderer.test.ts | 82 +++++++++++++++++++ 12 files changed, 176 insertions(+), 20 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 8690c9aeb..db8002298 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,6 +2,14 @@ ## [Unreleased] +### Added + +- Added the opt-in `read.renderMarkdown` setting for formatted Markdown read previews, disabled by default. + +### Fixed + +- Fixed Markdown file read metadata so the opt-in Markdown preview renderer can recognize local and URI-backed Markdown files consistently. + ## [16.2.9] - 2026-06-30 ### Breaking Changes diff --git a/packages/coding-agent/src/config/settings-schema.ts b/packages/coding-agent/src/config/settings-schema.ts index 9d92a5711..a8db5111e 100644 --- a/packages/coding-agent/src/config/settings-schema.ts +++ b/packages/coding-agent/src/config/settings-schema.ts @@ -2923,6 +2923,17 @@ export const SETTINGS_SCHEMA = { }, }, + "read.renderMarkdown": { + type: "boolean", + default: false, + ui: { + tab: "files", + group: "Reading", + label: "Markdown Previews", + description: "Render Markdown read results as formatted terminal Markdown previews instead of raw source", + }, + }, + "read.summarize.enabled": { type: "boolean", default: true, diff --git a/packages/coding-agent/src/internal-urls/local-protocol.ts b/packages/coding-agent/src/internal-urls/local-protocol.ts index 6a0daafdd..27a1bd752 100644 --- a/packages/coding-agent/src/internal-urls/local-protocol.ts +++ b/packages/coding-agent/src/internal-urls/local-protocol.ts @@ -3,6 +3,7 @@ import * as os from "node:os"; import * as path from "node:path"; import { isEnoent } from "@oh-my-pi/pi-utils"; import { AgentRegistry } from "../registry/agent-registry"; +import { isMarkdownPath } from "../utils/lang-from-path"; import { buildDirectoryResource } from "./filesystem-resource"; import { parseInternalUrl } from "./parse"; import { validateRelativePath } from "./skill-protocol"; @@ -43,8 +44,8 @@ function shortLocalRoot(options: LocalProtocolOptions): string { } function getContentType(filePath: string): InternalResource["contentType"] { + if (isMarkdownPath(filePath)) return "text/markdown"; const ext = path.extname(filePath).toLowerCase(); - if (ext === ".md") return "text/markdown"; if (ext === ".json") return "application/json"; return "text/plain"; } diff --git a/packages/coding-agent/src/internal-urls/memory-protocol.ts b/packages/coding-agent/src/internal-urls/memory-protocol.ts index 8e6b6e49f..dc18e6716 100644 --- a/packages/coding-agent/src/internal-urls/memory-protocol.ts +++ b/packages/coding-agent/src/internal-urls/memory-protocol.ts @@ -3,6 +3,7 @@ import * as path from "node:path"; import { getAgentDir, isEnoent } from "@oh-my-pi/pi-utils"; import { getMemoryRoot } from "../memories"; import { AgentRegistry } from "../registry/agent-registry"; +import { isMarkdownPath } from "../utils/lang-from-path"; import { buildDirectoryResource } from "./filesystem-resource"; import { validateRelativePath } from "./skill-protocol"; import type { InternalResource, InternalUrl, ProtocolHandler, UrlCompletion } from "./types"; @@ -111,8 +112,7 @@ async function tryResolveInRoot(url: InternalUrl, memoryRoot: string): Promise { try { const bridgeText = await bridgePromise; const bridgeResult = this.#buildInMemoryMultiRangeResult(bridgeText, ranges, { - details: { resolvedPath: absolutePath, suffixResolution }, + details: markMarkdownContentType({ resolvedPath: absolutePath, suffixResolution }, absolutePath), sourcePath: absolutePath, entityLabel: "file", raw: rawSelector, @@ -1702,10 +1719,13 @@ export class ReadTool implements AgentTool { const archive = await openArchive(resolvedArchivePath.absolutePath); throwIfAborted(signal); - const details: ReadToolDetails = { - resolvedPath: resolvedArchivePath.absolutePath, - suffixResolution: resolvedArchivePath.suffixResolution, - }; + const details: ReadToolDetails = markMarkdownContentType( + { + resolvedPath: resolvedArchivePath.absolutePath, + suffixResolution: resolvedArchivePath.suffixResolution, + }, + resolvedArchivePath.archiveSubPath, + ); let archiveSubPath = resolvedArchivePath.archiveSubPath; let sel = parsedSel; @@ -2304,14 +2324,14 @@ export class ReadTool implements AgentTool { // because only `truncateHead` was being applied. if (isMultiRange(parsed) && parsed.kind === "lines") { return this.#buildInMemoryMultiRangeResult(renderedContent, parsed.ranges, { - details: { resolvedPath: absolutePath }, + details: { resolvedPath: absolutePath, contentType: "text/markdown" }, sourcePath: absolutePath, entityLabel: "document", }); } const { offset, limit } = selToOffsetLimit(parsed); return this.#buildInMemoryTextResult(renderedContent, offset, limit, { - details: { resolvedPath: absolutePath }, + details: { resolvedPath: absolutePath, contentType: "text/markdown" }, sourcePath: absolutePath, entityLabel: "document", raw: isRawSelector(parsed), @@ -2344,7 +2364,7 @@ export class ReadTool implements AgentTool { if ( parsed.kind === "none" && this.session.settings.get("read.summarize.enabled") && - (this.session.settings.get("read.summarize.prose") || !PROSE_SUMMARY_EXTENSIONS.has(ext)) + (this.session.settings.get("read.summarize.prose") || !isProseSummaryPath(absolutePath)) ) { const summary = await this.#trySummarize(absolutePath, fileSize, signal); if (summary?.parsed && summary.elided) { @@ -2404,7 +2424,10 @@ export class ReadTool implements AgentTool { try { const bridgeText = await bridgePromise; const bridgeResult = this.#buildInMemoryTextResult(bridgeText, offset, limit, { - details: { resolvedPath: absolutePath, suffixResolution }, + details: markMarkdownContentType( + { resolvedPath: absolutePath, suffixResolution }, + absolutePath, + ), sourcePath: absolutePath, entityLabel: "file", raw: isRawSelector(parsed), @@ -2691,6 +2714,7 @@ export class ReadTool implements AgentTool { } } + markMarkdownContentType(details, absolutePath); if (suffixResolution) { details.suffixResolution = suffixResolution; // Inline resolution notice into first text block so the model sees the actual path @@ -3216,7 +3240,8 @@ export const readToolRenderer = { title += ` ${uiTheme.fg("warning", `(⚠ ${n} conflict${n === 1 ? "" : "s"})`)}`; } const rawRequested = args?.raw === true || isRawSelector(parseSel(renderPath.sel)); - const isMarkdown = details?.contentType === "text/markdown" && !rawRequested; + const markdownPreviewEnabled = isSettingsInitialized() && settings.get("read.renderMarkdown"); + const isMarkdown = markdownPreviewEnabled && details?.contentType === "text/markdown" && !rawRequested; let cachedWidth: number | undefined; let cachedExpanded: boolean | undefined; let cachedLines: string[] | undefined; diff --git a/packages/coding-agent/src/utils/lang-from-path.ts b/packages/coding-agent/src/utils/lang-from-path.ts index 6f5629ad0..c4ba4141a 100644 --- a/packages/coding-agent/src/utils/lang-from-path.ts +++ b/packages/coding-agent/src/utils/lang-from-path.ts @@ -102,6 +102,9 @@ const EXTENSION_LANG: Record = { md: ["markdown", "markdown"], markdown: ["markdown", "markdown"], mdx: ["markdown", "markdown"], + mdc: ["markdown", "markdown"], + mkd: ["markdown", "markdown"], + mdown: ["markdown", "markdown"], rst: ["restructuredtext", "restructuredtext"], adoc: ["asciidoc", "asciidoc"], tex: ["latex", "latex"], @@ -217,6 +220,10 @@ export function getLanguageFromPath(filePath: string): string | undefined { return undefined; } +export function isMarkdownPath(filePath: string): boolean { + return getLanguageFromPath(filePath) === "markdown"; +} + /** * LSP language identifier; falls back to `plaintext`. */ diff --git a/packages/coding-agent/test/read-summary.test.ts b/packages/coding-agent/test/read-summary.test.ts index a573fcd0f..128c68c15 100644 --- a/packages/coding-agent/test/read-summary.test.ts +++ b/packages/coding-agent/test/read-summary.test.ts @@ -100,6 +100,26 @@ describe("read summary", () => { expect(proseResult.details?.summary?.elidedSpans).toBe(1); }); + it("marks local Markdown-like extensions as markdown while preserving model-facing source text", async () => { + const markdown = "# Heading\n\nSome **bold** text.\n"; + const extensions = ["md", "markdown", "mdx", "mdc", "mkd", "mdown"] as const; + const tool = new ReadTool(createSession(tmpDir)); + + for (const extension of extensions) { + const fixture = path.join(tmpDir, `fixture.${extension}`); + await fs.writeFile(fixture, markdown); + + const result = await tool.execute(`read-summary-markdown-${extension}`, { path: fixture }); + const text = textOutput(result); + + expect(result.details?.contentType).toBe("text/markdown"); + expect(result.details?.displayContent?.text).toBe(markdown); + expect(text.split("\n")[0]).toMatch(new RegExp(`^\\[fixture\\.${extension}#[0-9A-F]{4}\\]$`)); + expect(text).toContain("1:# Heading"); + expect(text).toContain("3:Some **bold** text."); + } + }); + it("does not truncate summarized output", async () => { const fixture = path.join(tmpDir, "many.ts"); const source = Array.from( diff --git a/packages/coding-agent/test/tools/read-renderer.test.ts b/packages/coding-agent/test/tools/read-renderer.test.ts index 14f3f1516..aac7aed7d 100644 --- a/packages/coding-agent/test/tools/read-renderer.test.ts +++ b/packages/coding-agent/test/tools/read-renderer.test.ts @@ -25,6 +25,7 @@ beforeAll(async () => { afterEach(() => { settings.clearOverride("tui.hyperlinks"); + settings.clearOverride("read.renderMarkdown"); }); afterAll(() => { @@ -112,6 +113,87 @@ describe("readToolRenderer hyperlinks", () => { }); }); +describe("readToolRenderer markdown content", () => { + it("keeps text/markdown details raw unless markdown rendering is enabled", 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("**bold**"); + }); + + it("renders text/markdown details through the markdown renderer", async () => { + settings.override("read.renderMarkdown", true); + 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 raw markdown selector reads in the code renderer", async () => { + settings.override("read.renderMarkdown", true); + 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() {} } as unknown as TUI;