Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion apps/web/src/components/chat/MessagesTimeline.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import { ThreadFindTimelineContext } from "./ThreadFindProvider";
import { shallow } from "zustand/vanilla/shallow";
import { shouldPreserveAssistantLineBreaks } from "@t3tools/shared/markdownPipeline";
import {
remarkKeepWindowsPaths,
shouldPreserveAssistantLineBreaks,
} from "@t3tools/shared/markdownPipeline";
import { MarkdownFindContext, findRevealRef } from "./markdownFindContext";
import { useChatCanvas } from "./ChatCanvasContext";
import { WorkLogBlock, WorkLogButton, WorkLogDetails, WorkLogList, WorkLogRow } from "./WorkLog";
Expand Down Expand Up @@ -3807,6 +3810,7 @@ function LiveWorkEntryTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "
<ReactMarkdown
remarkPlugins={[
remarkGfm,
remarkKeepWindowsPaths,
[remarkThoughtPreview, row.active ? "Thinking" : "Thought"],
]}
>
Expand Down Expand Up @@ -5437,6 +5441,7 @@ function WorkEntryLogRow(props: WorkEntryRowProps) {
<ReactMarkdown
remarkPlugins={[
remarkGfm,
remarkKeepWindowsPaths,
[
remarkThoughtPreview,
workEntry.toolLifecycleStatus === "inProgress" ? "Thinking" : "Thought",
Expand Down
28 changes: 28 additions & 0 deletions packages/shared/src/markdownPipeline.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,3 +27,31 @@ it("preserves in-app thread links through the shared Markdown sanitizer", () =>
});
expect(JSON.stringify(tree)).not.toContain("javascript:");
});

it("keeps backslashes that CommonMark would read as escapes in prose Windows paths", () => {
const processor = unified()
.use(remarkParse)
.use(CHAT_MARKDOWN_REMARK_PLUGINS)
.use(remarkRehype, { allowDangerousHtml: true })
.use(CHAT_MARKDOWN_REHYPE_PLUGINS);
type Node = { type: string; value?: string; tagName?: string; children?: Node[] };
const text = (node: Node): string =>
node.type === "text"
? (node.value ?? "")
: node.tagName === "code"
? `<code>${(node.children ?? []).map(text).join("")}</code>`
: (node.children ?? []).map(text).join("");
const render = (markdown: string) => text(processor.runSync(processor.parse(markdown)) as Node);

expect(
render(String.raw`Saved to C:\Users\me\.t3\_build and (C:\.scratch) and C:/work\.config.`),
).toBe(String.raw`Saved to C:\Users\me\.t3\_build and (C:\.scratch) and C:/work\.config.`);
// Paths render as written, so one an agent already escaped keeps its doubled backslashes.
expect(render(String.raw`Escaped C:\\Users\\me`)).toBe(String.raw`Escaped C:\\Users\\me`);
expect(render(String.raw`See C:\x and \*this\* and Note:\*that\*.`)).toBe(
String.raw`See C:\x and *this* and Note:*that*.`,
);
expect(render(String.raw`${"`"}C:\me\.t3${"`"} and C:\me\.t3`)).toBe(
String.raw`<code>C:\me\.t3</code> and C:\me\.t3`,
);
});
32 changes: 26 additions & 6 deletions packages/shared/src/markdownPipeline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -158,7 +158,7 @@ const CHAT_MARKDOWN_SANITIZE_SCHEMA = {

export const CHAT_MARKDOWN_REMARK_PLUGINS: PluggableList = [
remarkGfm,
remarkKeepWindowsPathDestinations,
remarkKeepWindowsPaths,
remarkGithubAlerts,
remarkNormalizeListItemIndentation,
remarkCodexDirectives,
Expand All @@ -168,7 +168,7 @@ export const CHAT_MARKDOWN_REMARK_PLUGINS: PluggableList = [

export const CHAT_MARKDOWN_REMARK_PLUGINS_WITH_BREAKS: PluggableList = [
remarkGfm,
remarkKeepWindowsPathDestinations,
remarkKeepWindowsPaths,
remarkGithubAlerts,
remarkNormalizeListItemIndentation,
remarkCodexDirectives,
Expand Down Expand Up @@ -255,17 +255,37 @@ function keepWindowsPathDestination(this: DestinationCompileContext, token: unkn
node.url = isWindowsAbsolutePath(authored) && !authored.includes("&") ? authored : decoded;
}

// A drive path that has not reached whitespace yet: `C:`, `C:\Users\me`, `C:/work`.
const UNFINISHED_DRIVE_PATH = /(?:^|\W)[A-Za-z]:(?:[\\/]\S*)?$/;

interface EscapeCompileContext {
readonly stack: ReadonlyArray<{
readonly type: string;
children?: Array<{ type: string; value?: string }>;
}>;
}

function keepWindowsPathEscape(this: EscapeCompileContext) {
const text = this.stack.at(-1)?.children?.at(-1);
if (text?.type !== "text" || text.value === undefined) return;
const before = text.value.slice(0, -1);
if (UNFINISHED_DRIVE_PATH.test(before)) text.value = `${before}\\${text.value.slice(-1)}`;
}

/**
* CommonMark reads the `\.` in `C:\me\.t3\shot.png` as an escape, even in a link
* destination. Every backslash in a Windows path is a separator, so link, image, and
* definition destinations that are Windows paths keep the text as written.
* CommonMark reads the `\.` in `C:\me\.t3\shot.png` as an escape. Every backslash in a
* Windows path is a separator, so link, image, and definition destinations that are
* Windows paths keep the text as written, and so do escapes inside drive paths in prose.
* Prose paths end at whitespace: past that, nothing tells a path from a sentence, and
* `C:\x and \*this\*` must still read `*this*`.
*/
function remarkKeepWindowsPathDestinations(this: Processor) {
export function remarkKeepWindowsPaths(this: Processor) {
const data = this.data();
(data.fromMarkdownExtensions ??= []).push({
exit: {
resourceDestinationString: keepWindowsPathDestination,
definitionDestinationString: keepWindowsPathDestination,
characterEscape: keepWindowsPathEscape,
},
});
}
Expand Down
Loading