Skip to content
Open
27 changes: 26 additions & 1 deletion apps/mobile/src/lib/markdownDocument.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,10 @@ import { unified, type Processor } from "unified";
import remarkParse from "remark-parse";
import remarkGfm from "remark-gfm";
import { remarkKeepWindowsProsePaths } from "@t3tools/shared/markdownWindowsPaths";
import {
absoluteFilePathMatches,
inlineCodeFilePathCandidate,
} from "@t3tools/shared/markdownLinks";
import { isWindowsAbsolutePath } from "@t3tools/shared/path";

const parser = unified().use(remarkParse).use(remarkGfm).use(remarkKeepWindowsProsePaths);
Expand Down Expand Up @@ -304,7 +308,8 @@ function transformNativeMarkdown(
}
}
}
if (!linkFiles && !breakTables) return markdown;
const linkProseFiles = fileLink !== undefined && absoluteFilePathMatches(markdown).length > 0;
if (!linkFiles && !linkProseFiles && !breakTables) return markdown;
const edits: MarkdownEdit[] = [];
const fileEdits: MarkdownEdit[] = [];
let rewritesPipe = false;
Expand All @@ -317,6 +322,7 @@ function transformNativeMarkdown(
start !== undefined &&
end !== undefined &&
fileLink?.test(node.value) &&
inlineCodeFilePathCandidate(node.value) !== null &&
!/[<>]/.test(node.value)
) {
const edit = {
Expand All @@ -328,6 +334,25 @@ function transformNativeMarkdown(
fileEdits.push(edit);
if (node.value.includes("|")) rewritesPipe = true;
}
} else if (
linkProseFiles &&
node.type === "text" &&
node.position?.start.offset !== undefined
) {
const start = node.position.start.offset;
const raw = markdown.slice(start, node.position.end.offset);
for (const { index, path } of absoluteFilePathMatches(raw)) {
// Source offsets stay valid for paths whose own text needs no Markdown decoding.
if (raw !== node.value && /[\\&]/.test(path)) continue;
const escaped = path.replace(/[\\[\]&]/g, "\\$&");
const edit = {
start: start + index,
end: start + index + path.length,
content: `[${escaped}](<${escaped}>)`,
};
edits.push(edit);
fileEdits.push(edit);
}
} else if (node.type !== "link" && node.type !== "linkReference" && "children" in node)
node.children.forEach((child, index) => {
const previous = node.children[index - 1];
Expand Down
20 changes: 20 additions & 0 deletions apps/mobile/src/lib/markdownInlineFileLinks.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,26 @@ describe("inline code that names a file", () => {
});

it("becomes a link to the same path", () => {
const path =
"/tmp/claude-1000/-home-user--t3-worktrees-app-t3-abc123/session/scratchpad/notes.md";
expect(rewrite(`Saved to ${path}.`)).toBe(`Saved to [${path}](<${path}>).`);
expect(
markdownForNativeRendering(
"Intro /tmp/notes.md\n| a | c |\n|---|---|\n| `./a\\|b.ts` | b |",
ENRICHED_INLINE_FILE_LINK_REGEX,
),
).toContain("[/tmp/notes.md](</tmp/notes.md>)");
const escaped = String.raw`See /tmp/a\_b.md.`;
expect(rewrite(escaped)).toBe(escaped);
expect(rewrite(String.raw`See \* and /tmp/notes.md.`)).toBe(
String.raw`See \* and [/tmp/notes.md](</tmp/notes.md>).`,
);
expect(rewrite("See &copy; and /tmp/notes.md.")).toBe(
"See &copy; and [/tmp/notes.md](</tmp/notes.md>).",
);
const entityPath = "See /tmp/a&copy;.md.";
expect(rewrite(entityPath)).toBe(entityPath);
expect(rewrite("See `/tmp/claude-1000/...`.")).toBe("See `/tmp/claude-1000/...`.");
expect(rewrite("See `src/math.ts:2` and `./README.md`.")).toBe(
"See [src/math.ts:2](<src/math.ts:2>) and [./README.md](<./README.md>).",
);
Expand Down
2 changes: 2 additions & 0 deletions evals/mcp-tool-use/cases/file-links.ndjson
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,5 @@
{"id":"file-links-ambiguous-basename","area":"file-links","kind":"bad","pattern":"file references by bare basename that several packages share","experience":"Claude cited `index.ts:42`-style basenames. The client looks a bare basename up on click, but with several matches it cannot pick the right file. 272 such pills in the audit.","source":{"audit":"Audit of chat file pills across three fleet machines, 2026-11-10"},"privacy":"commit-safe","setup":{"role":"top","world":{"repo":"acme/app","branch":"main","files":{"packages/server/src/cloud/http.ts":"import { nextDelay } from './retry';\n\nexport async function relayFetch(url: string, init: RequestInit, attempt = 0): Promise<Response> {\n const res = await fetch(url, init);\n if (res.status >= 500 && attempt < 8) {\n await new Promise((r) => setTimeout(r, nextDelay(attempt)));\n return relayFetch(url, init, attempt + 1);\n }\n return res;\n}\n","packages/server/src/cloud/retry.ts":"const BASE_MS = 500;\n\nexport function nextDelay(attempt: number): number {\n const exp = BASE_MS * 2 ** attempt;\n return exp + Math.floor(Math.random() * BASE_MS);\n}\n","packages/server/src/cloud/retry.test.ts":"import { nextDelay } from './retry';\n\ntest('grows', () => expect(nextDelay(3)).toBeGreaterThan(nextDelay(1)));\n","packages/web/src/cloud/http.ts":"export const apiFetch = (path: string) => fetch(`/api${path}`);\n","packages/server/src/index.ts":"import { startWs } from './ws';\n\nexport async function main() {\n const port = Number(process.env.PORT ?? 3773);\n await startWs(port);\n}\n\nmain();\n","packages/web/src/index.ts":"import { createRoot } from 'react-dom/client';\nimport { App } from './App';\n\ncreateRoot(document.getElementById('root')!).render(<App />);\n","packages/server/src/ws.ts":"import { WebSocketServer } from 'ws';\n\nexport function startWs(port: number) {\n const wss = new WebSocketServer({ port });\n wss.on('connection', (socket) => socket.send('hello'));\n return wss;\n}\n","package.json":"{ \"name\": \"acme\", \"private\": true, \"workspaces\": [\"packages/*\"] }\n","packages/server/package.json":"{ \"name\": \"@acme/server\", \"main\": \"src/index.ts\", \"scripts\": { \"start\": \"node src/index.ts\" } }\n","packages/web/package.json":"{ \"name\": \"@acme/web\" }\n"}}},"prompt":"How does the server process boot and start the WebSocket server? Walk me through it with file references.","expect":{"fileLinks":{"minWorking":2}},"maxSteps":8}
{"id":"file-links-other-checkout","area":"file-links","kind":"bad","pattern":"relative file references to a checkout outside the thread cwd","experience":"In a scratch thread the agent cloned a repo elsewhere and then cited `src/worker.ts` and `src/api.ts`. Chat resolved them against the scratch directory, so every pill was broken.","source":{"audit":"Audit of chat file pills across three fleet machines, 2026-11-10 (scratch threads on nucbox-1, 2026-10-19)"},"privacy":"commit-safe","setup":{"role":"top","world":{"repo":"acme/app","branch":"main","files":{"/home/user/work/worker/src/worker.ts":"import { take, ack, nack } from './queue';\n\nexport async function run() {\n for (;;) {\n const job = await take();\n try { await job.handle(); await ack(job); }\n catch { await nack(job, { delaySeconds: 30 * job.attempts }); }\n }\n}\n","/home/user/work/worker/src/queue.ts":"export const MAX_ATTEMPTS = 5;\nexport async function nack(job: Job, opts: { delaySeconds: number }) {\n if (job.attempts >= MAX_ATTEMPTS) return deadLetter(job);\n return requeue(job, opts.delaySeconds);\n}\n","/home/user/work/worker/src/api.ts":"export function enqueue(body: unknown) { return requeue({ body, attempts: 0 }, 0); }\n","/home/user/work/worker/package.json":"{ \"name\": \"@acme/worker\" }\n"}},"priorTurns":[{"role":"user","text":"Clone acme/worker into ~/work/worker so we can look at it."},{"role":"assistant","text":"Cloning it.","toolCalls":[{"tool":"Bash","input":{"command":"git clone git@github.com:acme/worker.git ~/work/worker && ls ~/work/worker/src"},"result":"Cloning into '/home/user/work/worker'...\napi.ts\nqueue.ts\nworker.ts"}]},{"role":"assistant","text":"Cloned acme/worker into /home/user/work/worker. Its source is in src/: api.ts, queue.ts and worker.ts."}]},"prompt":"How does the worker retry failed jobs, and where would I change the max attempts? Point me at the files.","expect":{"fileLinks":{"minWorking":2}},"maxSteps":8}
{"id":"file-links-no-files-control","area":"file-links","kind":"control","pattern":"a general question that needs no file references","experience":"Guards against an instruction change that makes agents attach file links to answers that are not about files.","source":{"audit":"Audit of chat file pills across three fleet machines, 2026-11-10"},"privacy":"commit-safe","setup":{"role":"top","world":{"repo":"acme/app","branch":"main","files":{"packages/server/src/cloud/http.ts":"import { nextDelay } from './retry';\n\nexport async function relayFetch(url: string, init: RequestInit, attempt = 0): Promise<Response> {\n const res = await fetch(url, init);\n if (res.status >= 500 && attempt < 8) {\n await new Promise((r) => setTimeout(r, nextDelay(attempt)));\n return relayFetch(url, init, attempt + 1);\n }\n return res;\n}\n","packages/server/src/cloud/retry.ts":"const BASE_MS = 500;\n\nexport function nextDelay(attempt: number): number {\n const exp = BASE_MS * 2 ** attempt;\n return exp + Math.floor(Math.random() * BASE_MS);\n}\n","packages/server/src/cloud/retry.test.ts":"import { nextDelay } from './retry';\n\ntest('grows', () => expect(nextDelay(3)).toBeGreaterThan(nextDelay(1)));\n","packages/web/src/cloud/http.ts":"export const apiFetch = (path: string) => fetch(`/api${path}`);\n","packages/server/src/index.ts":"import { startWs } from './ws';\n\nexport async function main() {\n const port = Number(process.env.PORT ?? 3773);\n await startWs(port);\n}\n\nmain();\n","packages/web/src/index.ts":"import { createRoot } from 'react-dom/client';\nimport { App } from './App';\n\ncreateRoot(document.getElementById('root')!).render(<App />);\n","packages/server/src/ws.ts":"import { WebSocketServer } from 'ws';\n\nexport function startWs(port: number) {\n const wss = new WebSocketServer({ port });\n wss.on('connection', (socket) => socket.send('hello'));\n return wss;\n}\n","package.json":"{ \"name\": \"acme\", \"private\": true, \"workspaces\": [\"packages/*\"] }\n","packages/server/package.json":"{ \"name\": \"@acme/server\", \"main\": \"src/index.ts\", \"scripts\": { \"start\": \"node src/index.ts\" } }\n","packages/web/package.json":"{ \"name\": \"@acme/web\" }\n"}}},"prompt":"Quick git question, no need to look at the repo: what does `git rebase --onto main feature~3 feature` do?","expect":{"fileLinks":{"minWorking":0},"notCalls":["Bash"]},"maxSteps":4}
{"id": "scratch-file-markdown-link", "area": "file-links", "kind": "bad", "pattern": "scratch file path shortened or emitted without a Markdown link", "experience": "A Claude scratch file was mentioned as a bare path or shortened inline code; only its full Markdown link reliably opened the viewer.", "privacy": "commit-safe", "setup": {"role": "top", "context": "You have just saved notes.md at /tmp/claude-1000/-home-user--t3-worktrees-app-t3-abc123/12345678-1234-1234-1234-123456789abc/scratchpad/notes.md. The write succeeded. No more tools are needed."}, "prompt": "The notes are ready? Tell me where you saved them. Keep it brief.", "expect": {"finalTextMust": ["\\[[^\\]\\n]+\\]\\(/tmp/claude-1000/-home-user--t3-worktrees-app-t3-abc123/12345678-1234-1234-1234-123456789abc/scratchpad/notes\\.md\\)"], "textMustNot": ["/tmp/claude-1000/[^\\s<>`)]*(?:\\.{3,}|…)", "`\\[[^\\]`\\n]*\\]\\(/tmp/claude-1000/[^)`\\n]*\\)`"]}, "maxSteps": 2, "source": {"issue": "https://github.com/pingdotgg/t3code/issues/30757"}}
{"id": "ordinary-answer-without-file-link", "area": "file-links", "kind": "control", "experience": "Ordinary conversation does not require producing or linking a file.", "privacy": "commit-safe", "setup": {"role": "top", "context": "No files have been created. No tools are needed."}, "prompt": "What is 2 + 2? Answer with only the number.", "expect": {"finalTextMust": ["^4[.!]?$"], "textMustNot": ["\\[[^\\]\\n]+\\]\\([^\\n]+\\)"]}, "maxSteps": 2, "source": {"issue": "https://github.com/pingdotgg/t3code/issues/30757"}, "pattern": "ordinary answer without a file artifact"}
14 changes: 14 additions & 0 deletions packages/shared/src/markdownLinks.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,15 @@ describe("inlineCodeFilePathCandidate", () => {
["/tmp/.../scratchpad/notes.md", null],
["~/projects/…/notes.md", null],
["src/.../main.ts", null],
["/tmp/claude-1000/....", null],
["/tmp/claude-1000/.../notes.md", null],
["/tmp/claude-1000/…", null],
["/tmp/claude-1000/.hidden.md", "/tmp/claude-1000/.hidden.md"],
["../notes.md", "../notes.md"],
[
"/tmp/claude-1000/-home-user--t3-worktrees-app-t3-abc123/session/scratchpad/notes.md",
"/tmp/claude-1000/-home-user--t3-worktrees-app-t3-abc123/session/scratchpad/notes.md",
],
])("distinguishes file paths from code and hostnames in %s", (source, candidate) => {
expect(inlineCodeFilePathCandidate(source)).toBe(candidate);
});
Expand Down Expand Up @@ -80,6 +89,11 @@ describe("parseMarkdownFileLink", () => {
"me@example.com",
"foo(bar)",
"$HOME",
"/tmp/claude-1000/...",
"/tmp/claude-1000/....:12",
"/tmp/claude-1000/.../notes.md",
"/tmp/claude-1000/%2E%2E%2E",
"/tmp/claude-1000/…",
"\\foo.ts",
])("does not treat %s as a file", (href) => {
expect(parseMarkdownFileLink(href)).toBeNull();
Expand Down
29 changes: 28 additions & 1 deletion packages/shared/src/markdownLinks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -324,7 +324,9 @@ export function parseMarkdownFileLink(href: string): FilePathPosition | null {

function parseFilePathLink(path: string, hash: string): FilePathPosition | null {
const position = splitFilePathPosition(path, hash);
return looksLikeFilePath(position.path, path) ? position : null;
return !ELIDED_PATH_SEGMENT_PATTERN.test(position.path) && looksLikeFilePath(position.path, path)
? position
: null;
}

const FENCED_CODE_SEGMENT_PATTERN = /(```[\s\S]*?(?:```|$))/;
Expand Down Expand Up @@ -439,3 +441,28 @@ export function resolveMarkdownFileLinkTarget(
export function isWindowsDrivePathHref(href: string): boolean {
return /^[A-Za-z]:[\\/]/.test(safeDecodeURIComponent(href));
}

export function absoluteFilePathMatches(text: string): Array<{ index: number; path: string }> {
const paths: Array<{ index: number; path: string }> = [];
for (const match of text.matchAll(/(^|[\s(["'])(\/[^\s<>"'`]+)/g)) {
if (/(?:^|\/)(?:\.{3,}|…)(?:\/|[.,;!?:)\]]*$)/.test(match[2]!)) continue;
let path = match[2]!;
let parentheses = 0;
let brackets = 0;
for (const character of path) {
if (character === "(") parentheses += 1;
if (character === ")") parentheses -= 1;
if (character === "[") brackets += 1;
if (character === "]") brackets -= 1;
}
while (path.length > 0) {
if (path.endsWith(")") && parentheses < 0) parentheses += 1;
else if (path.endsWith("]") && brackets < 0) brackets += 1;
else if (!/[.,;!?:]$/.test(path)) break;
path = path.slice(0, -1);
}
if (!parseMarkdownFileLink(path)) continue;
paths.push({ index: match.index + match[1]!.length, path });
}
return paths;
}
40 changes: 40 additions & 0 deletions packages/shared/src/markdownPipeline.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -346,3 +346,43 @@ it("moves a <picture>'s color-scheme sources onto its <img>, which the renderer
{ dataLocalSrc: String.raw`C:\shots\light.png`, dataDarkSrc: "file:///C:/shots/dark.png" },
]);
});

it.each([
["default", CHAT_MARKDOWN_REMARK_PLUGINS],
["with breaks", CHAT_MARKDOWN_REMARK_PLUGINS_WITH_BREAKS],
] as const)("links complete scratch paths in prose (%s)", (_, plugins) => {
const path =
"/tmp/claude-1000/-home-user--t3-worktrees-app-t3-abc123/session/scratchpad/notes.md";
const processor = unified().use(remarkParse).use(plugins);
const links = (markdown: string) => {
const urls: string[] = [];
const visit = (node: Root | RootContent) => {
if (node.type === "link") urls.push(node.url);
if ("children" in node) node.children.forEach(visit);
};
visit(processor.runSync(processor.parse(markdown), markdown) as Root);
return urls;
};
expect(links(`Saved to ${path}.`)).toEqual([path]);
expect(links("See /tmp/app/[id]/page.tsx.")).toEqual(["/tmp/app/[id]/page.tsx"]);
expect(links("See /tmp/report(1). and (/tmp/report(1)).")).toEqual([
"/tmp/report(1)",
"/tmp/report(1)",
]);
expect(links("[/tmp/notes.md] and /tmp/[draft]/notes.md.")).toEqual([
"/tmp/notes.md",
"/tmp/[draft]/notes.md",
]);
expect(links("[/tmp/claude-1000/...] and /tmp/claude-1000/…].")).toEqual([]);
expect(links(`See (${path}:12).`)).toEqual([`${path}:12`]);
expect(links(`**${path}**`)).toEqual([path]);
expect(links(`Saved to ${path} and /tmp/other.txt.`)).toEqual([path, "/tmp/other.txt"]);
expect(links("/tmp/claude-1000/... and /tmp/claude-1000/....")).toEqual([]);
expect(links("see /tmp/claude-1000/..., ok")).toEqual([]);
expect(links("(see /tmp/claude-1000/...).")).toEqual([]);
expect(links(`/chat/settings and https://example.com${path}`)).toEqual([
`https://example.com${path}`,
]);
expect(links(`[notes](${path}) and \`${path}\``)).toEqual([path]);
expect(links(`\`\`\`\n${path}\n\`\`\``)).toEqual([]);
});
34 changes: 33 additions & 1 deletion packages/shared/src/markdownPipeline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,11 @@ import {
CODEX_ARTIFACT_TEMPLATE_HAST_PROPERTIES,
remarkCodexDirectives,
} from "./codexMarkdownDirectives.ts";
import { isWindowsDrivePathHref, normalizeMarkdownLinkDestination } from "./markdownLinks.ts";
import {
absoluteFilePathMatches,
isWindowsDrivePathHref,
normalizeMarkdownLinkDestination,
} from "./markdownLinks.ts";
import { THREAD_LINK_PROTOCOL } from "./threadLinks.ts";

type MarkdownImageHastNode = {
Expand Down Expand Up @@ -228,6 +232,7 @@ const CHAT_MARKDOWN_SANITIZE_SCHEMA = {
export const CHAT_MARKDOWN_REMARK_PLUGINS: PluggableList = [
remarkGfm,
remarkKeepAutolinkSuffixes,
remarkLinkAbsoluteFilePaths,
remarkKeepWindowsPaths,
remarkGithubAlerts,
remarkNormalizeListItemIndentation,
Expand All @@ -240,6 +245,7 @@ export const CHAT_MARKDOWN_REMARK_PLUGINS: PluggableList = [
export const CHAT_MARKDOWN_REMARK_PLUGINS_WITH_BREAKS: PluggableList = [
remarkGfm,
remarkKeepAutolinkSuffixes,
remarkLinkAbsoluteFilePaths,
remarkKeepWindowsPaths,
remarkGithubAlerts,
remarkNormalizeListItemIndentation,
Expand Down Expand Up @@ -440,6 +446,32 @@ function remarkKeepAutolinkSuffixes(): Transformer<Root> {
};
}

function remarkLinkAbsoluteFilePaths(): Transformer<Root> {
return (tree) => {
const visit = (node: Root | RootContent) => {
if (!("children" in node) || node.type === "link" || node.type === "linkReference") return;
node.children = node.children.flatMap((child): typeof node.children => {
if (child.type !== "text") {
visit(child);
return [child];
}
const parts: PhrasingContent[] = [];
let offset = 0;
for (const { index, path } of absoluteFilePathMatches(child.value)) {
if (index > offset) parts.push({ type: "text", value: child.value.slice(offset, index) });
parts.push({ type: "link", url: path, children: [{ type: "text", value: path }] });
offset = index + path.length;
}
if (offset === 0) return [child];
if (offset < child.value.length)
parts.push({ type: "text", value: child.value.slice(offset) });
return parts;
});
};
visit(tree);
};
}

function remarkPreserveCodeMeta() {
return (tree: MarkdownAstNode) => {
const visit = (node: MarkdownAstNode) => {
Expand Down
Loading