Merge PR #4001: feat(coding-agent): opt-in Markdown read previews (@oldschoola)

This commit is contained in:
can1357
2026-07-23 17:30:33 +02:00
12 changed files with 173 additions and 20 deletions
+5
View File
@@ -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
@@ -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,
@@ -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";
}
@@ -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<I
}
const content = await Bun.file(realTargetPath).text();
const ext = path.extname(realTargetPath).toLowerCase();
const contentType: InternalResource["contentType"] = ext === ".md" ? "text/markdown" : "text/plain";
const contentType: InternalResource["contentType"] = isMarkdownPath(realTargetPath) ? "text/markdown" : "text/plain";
return {
url: url.href,
@@ -12,12 +12,12 @@ import * as fs from "node:fs/promises";
import * as path from "node:path";
import { isEnoent } from "@oh-my-pi/pi-utils";
import { getActiveSkills } from "../extensibility/skills";
import { isMarkdownPath } from "../utils/lang-from-path";
import { buildDirectoryResource } from "./filesystem-resource";
import type { InternalResource, InternalUrl, ProtocolHandler, ResolveContext, UrlCompletion } from "./types";
function getContentType(filePath: string): InternalResource["contentType"] {
const ext = path.extname(filePath).toLowerCase();
if (ext === ".md") return "text/markdown";
if (isMarkdownPath(filePath)) return "text/markdown";
return "text/plain";
}
@@ -30,6 +30,7 @@ import {
statRemotePath,
writeRemoteFile,
} from "../ssh/file-transfer";
import { isMarkdownPath } from "../utils/lang-from-path";
import type {
InternalResource,
InternalUrl,
@@ -44,11 +45,11 @@ const SSH_TEXT_MAX_BYTES = 1024 * 1024;
/** POSIX-aware content type from the last path segment's extension. */
function contentTypeFor(remotePath: string): InternalResource["contentType"] {
if (isMarkdownPath(remotePath)) return "text/markdown";
const slash = remotePath.lastIndexOf("/");
const base = slash === -1 ? remotePath : remotePath.slice(slash + 1);
const dot = base.lastIndexOf(".");
const ext = dot <= 0 ? "" : base.slice(dot).toLowerCase();
if (ext === ".md") return "text/markdown";
if (ext === ".json") return "application/json";
return "text/plain";
}
@@ -3,6 +3,7 @@ import * as path from "node:path";
import { $which, isEnoent } from "@oh-my-pi/pi-utils";
import { isSettingsInitialized, settings } from "../config/settings";
import { getDefault } from "../config/settings-schema";
import { isMarkdownPath } from "../utils/lang-from-path";
import { parseInternalUrl } from "./parse";
import { validateRelativePath } from "./skill-protocol";
import type { InternalResource, InternalUrl, ProtocolHandler, ResolveContext, WriteContext } from "./types";
@@ -114,8 +115,8 @@ function toVaultValidationError(error: unknown): Error {
}
function getContentType(filePath: string): 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";
}
@@ -20,7 +20,7 @@ import { defaultThemes } from "./defaults";
import lightThemeJson from "./light.json" with { type: "json" };
import { resolveMermaidAscii } from "./mermaid-cache";
export { getLanguageFromPath } from "../../utils/lang-from-path";
export { getLanguageFromPath, isMarkdownPath } from "../../utils/lang-from-path";
// ============================================================================
// Symbol Presets
+37 -12
View File
@@ -25,6 +25,7 @@ import {
} from "@oh-my-pi/pi-utils";
import { type } from "arktype";
import { LRUCache } from "lru-cache/raw";
import { isSettingsInitialized, settings } from "../config/settings";
import {
canonicalSnapshotKey,
getFileSnapshotStore,
@@ -40,7 +41,7 @@ import { InternalUrlRouter, resolveLocalUrlToFile, resolveLocalUrlToPath } from
import { type ResolvedArtifactFile, resolveArtifactFile } from "../internal-urls/artifact-protocol";
import { parseInternalUrl } from "../internal-urls/parse";
import type { InternalUrl } from "../internal-urls/types";
import { getLanguageFromPath, type Theme } from "../modes/theme/theme";
import { getLanguageFromPath, isMarkdownPath, type Theme } from "../modes/theme/theme";
import readDescription from "../prompts/tools/read.md" with { type: "text" };
import type { ToolSession } from "../sdk";
import {
@@ -160,7 +161,16 @@ const MAX_ARTIFACT_RAW_INLINE_BYTES = DEFAULT_MAX_BYTES;
* on disk is unchanged. Shared with the streaming sink path so one setting
* covers `bash`/`ssh`/`python`/`js eval` and `read` uniformly.
*/
const PROSE_SUMMARY_EXTENSIONS = new Set([".md", ".txt"]);
const PROSE_SUMMARY_EXTENSIONS = new Set([".txt"]);
function isMarkdownContentPath(filePath: string): boolean {
return isMarkdownPath(filePath);
}
function isProseSummaryPath(filePath: string): boolean {
return isMarkdownContentPath(filePath) || PROSE_SUMMARY_EXTENSIONS.has(path.extname(filePath).toLowerCase());
}
// Remote mount path prefix (sshfs mounts) - skip fuzzy matching to avoid hangs
const REMOTE_MOUNT_PREFIX = getRemoteDir() + path.sep;
@@ -780,6 +790,13 @@ export interface ReadToolDetails {
displayReadTargets?: string[];
}
function markMarkdownContentType(details: ReadToolDetails, filePath: string): ReadToolDetails {
if (!details.contentType && isMarkdownContentPath(filePath)) {
details.contentType = "text/markdown";
}
return details;
}
type ReadParams = ReadToolInput;
/** Parsed representation of a path-embedded selector. */
@@ -1598,7 +1615,7 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
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<typeof readSchema, ReadToolDetails> {
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<typeof readSchema, ReadToolDetails> {
// 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<typeof readSchema, ReadToolDetails> {
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<typeof readSchema, ReadToolDetails> {
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<typeof readSchema, ReadToolDetails> {
}
}
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;
@@ -102,6 +102,9 @@ const EXTENSION_LANG: Record<string, readonly [string, string]> = {
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`.
*/
@@ -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(
@@ -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;