From 4da2bd7b32af692a4408a3d80904b9bb95ae17b5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Petr=20Po=C5=99=C3=ADzek?= <33246262+Rhiz3K@users.noreply.github.com> Date: Thu, 22 Oct 2026 16:27:03 +0200 Subject: [PATCH 1/3] fix: render markdown in the Thinking block --- src/frontend/AiThinking.tsx | 13 ++++++-- tests/frontend/AiThinking.test.tsx | 53 ++++++++++++++++++++++++++++++ 2 files changed, 64 insertions(+), 2 deletions(-) create mode 100644 tests/frontend/AiThinking.test.tsx diff --git a/src/frontend/AiThinking.tsx b/src/frontend/AiThinking.tsx index cc6a164..cca42f0 100644 --- a/src/frontend/AiThinking.tsx +++ b/src/frontend/AiThinking.tsx @@ -1,6 +1,8 @@ import { useState } from "react"; import { useTranslation } from "@termix-ssh/plugin-sdk/frontend"; import { Brain, ChevronDown, ChevronRight, Loader2 } from "lucide-react"; +import ReactMarkdown from "react-markdown"; +import remarkGfm from "remark-gfm"; /** * What the model thought before the text that follows it. Collapsed, so it @@ -34,8 +36,15 @@ export function AiThinking({ {t(active ? "assistant.thinkingActive" : "assistant.thinking")} {open && ( -
- {content} +
+ + {content} +
)}
diff --git a/tests/frontend/AiThinking.test.tsx b/tests/frontend/AiThinking.test.tsx new file mode 100644 index 0000000..3c13264 --- /dev/null +++ b/tests/frontend/AiThinking.test.tsx @@ -0,0 +1,53 @@ +import { afterEach, expect, it, vi } from "vitest"; +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; + +vi.mock("@termix-ssh/plugin-sdk/frontend", async (original) => ({ + ...(await original()), + useTranslation: () => ({ + t: (key: string, options?: Record) => + options ? `${key}:${Object.values(options).join(",")}` : key, + }), +})); +import { AiThinking } from "../../src/frontend/AiThinking"; + +afterEach(cleanup); + +const open = () => + fireEvent.click(screen.getByRole("button", { name: "assistant.thinking" })); + +it("renders the thinking as markdown once opened", () => { + const { container } = render(); + open(); + + expect(screen.getByText("Evaluating plugins").tagName).toBe("STRONG"); + expect(container.textContent).not.toContain("**"); +}); + +it("never turns raw HTML into elements", () => { + const { container } = render( + , + ); + open(); + + expect(container.querySelector("img")).toBeNull(); + expect(container.querySelector("script")).toBeNull(); + expect((window as { __x?: number }).__x).toBeUndefined(); +}); + +it("does not load markdown images", () => { + const { container } = render( + , + ); + open(); + + expect(container.querySelector("img")).toBeNull(); + expect(container.textContent).toContain("Before"); +}); + +it("is collapsed by default", () => { + render(); + + const button = screen.getByRole("button", { name: "assistant.thinking" }); + expect(button.getAttribute("aria-expanded")).toBe("false"); + expect(screen.queryByText("Evaluating plugins")).toBeNull(); +}); From 9fadeb2f84e9ef93cb8f98c74b777bb53393cf13 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Petr=20Po=C5=99=C3=ADzek?= <33246262+Rhiz3K@users.noreply.github.com> Date: Thu, 22 Oct 2026 16:45:36 +0200 Subject: [PATCH 2/3] fix: never load markdown images in assistant replies --- src/frontend/AiMessage.tsx | 5 ++-- src/frontend/AiThinking.tsx | 12 ++------ src/frontend/markdown.tsx | 15 ++++++++++ tests/frontend/AiMessage.test.tsx | 48 +++++++++++++++++++++++++++++++ 4 files changed, 67 insertions(+), 13 deletions(-) create mode 100644 src/frontend/markdown.tsx create mode 100644 tests/frontend/AiMessage.test.tsx diff --git a/src/frontend/AiMessage.tsx b/src/frontend/AiMessage.tsx index 71b2ad1..98f7a6c 100644 --- a/src/frontend/AiMessage.tsx +++ b/src/frontend/AiMessage.tsx @@ -1,5 +1,4 @@ -import ReactMarkdown from "react-markdown"; -import remarkGfm from "remark-gfm"; +import { SafeMarkdown } from "./markdown"; interface AiMessageProps { role: "user" | "assistant"; @@ -17,7 +16,7 @@ export function AiMessage({ role, content }: AiMessageProps) { return (
- {content} + {content}
); } diff --git a/src/frontend/AiThinking.tsx b/src/frontend/AiThinking.tsx index cca42f0..37e4ece 100644 --- a/src/frontend/AiThinking.tsx +++ b/src/frontend/AiThinking.tsx @@ -1,8 +1,7 @@ import { useState } from "react"; import { useTranslation } from "@termix-ssh/plugin-sdk/frontend"; import { Brain, ChevronDown, ChevronRight, Loader2 } from "lucide-react"; -import ReactMarkdown from "react-markdown"; -import remarkGfm from "remark-gfm"; +import { SafeMarkdown } from "./markdown"; /** * What the model thought before the text that follows it. Collapsed, so it @@ -37,14 +36,7 @@ export function AiThinking({ {open && (
- - {content} - + {content}
)} diff --git a/src/frontend/markdown.tsx b/src/frontend/markdown.tsx new file mode 100644 index 0000000..dd2d9b4 --- /dev/null +++ b/src/frontend/markdown.tsx @@ -0,0 +1,15 @@ +import ReactMarkdown from "react-markdown"; +import remarkGfm from "remark-gfm"; + +/** + * Markdown from the model. Raw HTML stays text (no rehype-raw), and images + * are dropped: model text can echo output from hosts or tools, so never let + * it load a remote image (and leak data in the URL) just by being shown. + */ +export function SafeMarkdown({ children }: { children: string }) { + return ( + + {children} + + ); +} diff --git a/tests/frontend/AiMessage.test.tsx b/tests/frontend/AiMessage.test.tsx new file mode 100644 index 0000000..dc704fa --- /dev/null +++ b/tests/frontend/AiMessage.test.tsx @@ -0,0 +1,48 @@ +import { afterEach, expect, it } from "vitest"; +import { cleanup, render, screen } from "@testing-library/react"; +import { AiMessage } from "../../src/frontend/AiMessage"; + +afterEach(cleanup); + +it("renders assistant replies as markdown", () => { + const { container } = render( + , + ); + + expect(screen.getByText("Done").tagName).toBe("STRONG"); + expect(container.textContent).not.toContain("**"); +}); + +it("never turns raw HTML into elements", () => { + const { container } = render( + , + ); + + expect(container.querySelector("img")).toBeNull(); + expect(container.querySelector("script")).toBeNull(); + expect((window as { __y?: number }).__y).toBeUndefined(); +}); + +it("does not load markdown images", () => { + const { container } = render( + , + ); + + expect(container.querySelector("img")).toBeNull(); + expect(container.textContent).toContain("Before"); +}); + +it("shows user messages as plain text", () => { + const { container } = render( + , + ); + + expect(container.querySelector("strong")).toBeNull(); + expect(container.textContent).toBe("**not bold**"); +}); From 82ae1567c99b1bae559c94253293b06eabb94727 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Petr=20Po=C5=99=C3=ADzek?= <33246262+Rhiz3K@users.noreply.github.com> Date: Thu, 22 Oct 2026 17:17:53 +0200 Subject: [PATCH 3/3] style: give markdown tables cell padding, borders and horizontal scroll --- src/frontend/markdown.tsx | 27 +++++++++++++++++++++++++-- tests/frontend/AiMessage.test.tsx | 24 ++++++++++++++++++++++++ 2 files changed, 49 insertions(+), 2 deletions(-) diff --git a/src/frontend/markdown.tsx b/src/frontend/markdown.tsx index dd2d9b4..6443273 100644 --- a/src/frontend/markdown.tsx +++ b/src/frontend/markdown.tsx @@ -1,6 +1,25 @@ -import ReactMarkdown from "react-markdown"; +import ReactMarkdown, { type Components } from "react-markdown"; import remarkGfm from "remark-gfm"; +// Wide tables scroll inside their own box instead of stretching the panel. +// `node` is react-markdown's hast node; keep it off the DOM. +const components: Components = { + table: ({ node: _node, ...props }) => ( +
+ + + ), + th: ({ node: _node, ...props }) => ( +
+ ), + td: ({ node: _node, ...props }) => ( + + ), +}; + /** * Markdown from the model. Raw HTML stays text (no rehype-raw), and images * are dropped: model text can echo output from hosts or tools, so never let @@ -8,7 +27,11 @@ import remarkGfm from "remark-gfm"; */ export function SafeMarkdown({ children }: { children: string }) { return ( - + {children} ); diff --git a/tests/frontend/AiMessage.test.tsx b/tests/frontend/AiMessage.test.tsx index dc704fa..b3f1949 100644 --- a/tests/frontend/AiMessage.test.tsx +++ b/tests/frontend/AiMessage.test.tsx @@ -38,6 +38,30 @@ it("does not load markdown images", () => { expect(container.textContent).toContain("Before"); }); +it("renders GFM tables with cell borders in a scrollable wrapper", () => { + const { container } = render( + , + ); + + const table = container.querySelector("table"); + expect(table).not.toBeNull(); + expect(table!.parentElement!.classList).toContain("overflow-x-auto"); + + const ths = container.querySelectorAll("th"); + const tds = container.querySelectorAll("td"); + expect(ths).toHaveLength(2); + expect(tds).toHaveLength(4); + for (const cell of [...ths, ...tds]) { + expect(cell.classList).toContain("px-2"); + expect(cell.classList).toContain("border"); + expect(cell.classList).toContain("border-border"); + expect(cell.hasAttribute("node")).toBe(false); + } +}); + it("shows user messages as plain text", () => { const { container } = render( ,