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 cc6a164..37e4ece 100644
--- a/src/frontend/AiThinking.tsx
+++ b/src/frontend/AiThinking.tsx
@@ -1,6 +1,7 @@
import { useState } from "react";
import { useTranslation } from "@termix-ssh/plugin-sdk/frontend";
import { Brain, ChevronDown, ChevronRight, Loader2 } from "lucide-react";
+import { SafeMarkdown } from "./markdown";
/**
* What the model thought before the text that follows it. Collapsed, so it
@@ -34,8 +35,8 @@ export function AiThinking({
{t(active ? "assistant.thinkingActive" : "assistant.thinking")}
{open && (
-
- {content}
+
+ {content}
)}
diff --git a/src/frontend/markdown.tsx b/src/frontend/markdown.tsx
new file mode 100644
index 0000000..6443273
--- /dev/null
+++ b/src/frontend/markdown.tsx
@@ -0,0 +1,38 @@
+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
+ * 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..b3f1949
--- /dev/null
+++ b/tests/frontend/AiMessage.test.tsx
@@ -0,0 +1,72 @@
+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("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(
+ ,
+ );
+
+ expect(container.querySelector("strong")).toBeNull();
+ expect(container.textContent).toBe("**not bold**");
+});
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