diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index d4f839eef..42be04bd9 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -1342,6 +1342,11 @@ - Fixed the incomplete-todo reminder drifting to the bottom of the screen and piling up as dozens of duplicate copies in native scrollback. The reminder rendered in a dedicated anchored live-region container (`todoReminderContainer`) pinned above the editor, so it re-rendered in place every frame and — being taller than the viewport on short terminals while the subagent/job HUD churned below it — had its top rows committed to scrollback again on each reflow. It is now committed once into the transcript as a regular block (the same path TTSR notifications use), so it stays anchored in history where it fired. - Fixed subagent frontmatter `thinkingLevel` being overridden by `modelRoles.task` model suffixes. ([#3915](https://github.com/can1357/oh-my-pi/issues/3915)) - Fixed Ruff LSP auto-detection for Windows Python virtualenvs by checking `.venv/Scripts`, `venv/Scripts`, and `.env/Scripts` before falling back to PATH. ([#3916](https://github.com/can1357/oh-my-pi/issues/3916)) +- 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 diff --git a/packages/coding-agent/src/config/settings-schema.ts b/packages/coding-agent/src/config/settings-schema.ts index 495e77bd4..63b3d5e2e 100644 --- a/packages/coding-agent/src/config/settings-schema.ts +++ b/packages/coding-agent/src/config/settings-schema.ts @@ -3108,6 +3108,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 6b948ec4c..789ae82b4 100644 --- a/packages/coding-agent/src/internal-urls/memory-protocol.ts +++ b/packages/coding-agent/src/internal-urls/memory-protocol.ts @@ -4,6 +4,7 @@ import { getAgentDir, isEnoent } from "@oh-my-pi/pi-utils"; import { getMemoryRoot } from "../memories"; import { getMnemopiSessionState, type MnemopiScopedMemoryHit, type MnemopiSessionState } from "../mnemopi/state"; 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, ResolveContext, UrlCompletion } from "./types"; @@ -117,8 +118,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, @@ -1781,10 +1798,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; @@ -2419,14 +2439,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), @@ -2459,7 +2479,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) { @@ -2519,7 +2539,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), @@ -2818,6 +2841,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 @@ -3551,7 +3575,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 babab0ba0..ccd40dca0 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() {}, requestComponentRender() {} } as unknown as TUI;