Merge PR #4001: feat(coding-agent): opt-in Markdown read previews (@oldschoola)
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user