setIsHovering(true)}
onMouseLeave={() => setIsHovering(false)}
style={{ position: "relative" }}>
{
+ if (!/[[*_~`|]/.test(text)) {
+ return text;
+ }
+
+ const processedHtml = preserveHtmlTags(text);
+
+ let processed: ReactNode | ReactNode[] = processedHtml;
+
+ processed = processLinks(processed, keyOffset);
+ processed = processBold(processed, keyOffset);
+ processed = processItalic(processed, keyOffset);
+ processed = processStrikethrough(processed, keyOffset);
+ processed = processInlineCode(processed, keyOffset);
+ processed = processSpoiler(processed, keyOffset);
+
+ return processed;
+};
+
+const preserveHtmlTags = (text: string): string => {
+ return text.replace(/<([a-z][a-z0-9]*)\b[^>]*>(.*?)<\/\1>/gi, match => {
+ return match;
+ });
+};
+
+const processLinks = (
+ text: string | ReactNode | ReactNode[],
+ keyOffset: number
+): ReactNode | ReactNode[] => {
+ if (typeof text !== "string") {
+ if (Array.isArray(text)) {
+ return text.map((item, index) =>
+ typeof item === "string" ? processLinks(item, keyOffset + index * 100) : item
+ );
+ }
+ return text;
+ }
+
+ const linkRegex = /\[([^\]]+)\]\(([^)]+)\)/g;
+ let lastIndex = 0;
+ const result: ReactNode[] = [];
+ let match;
+ let matchIndex = 0;
+
+ while ((match = linkRegex.exec(text)) !== null) {
+ if (match.index > lastIndex) {
+ result.push(text.substring(lastIndex, match.index));
+ }
+
+ const linkText = match[1];
+ const linkUrl = match[2];
+
+ const processedLinkText = parseInlineMarkdown(linkText, keyOffset + 1000 + matchIndex);
+
+ result.push(
+
+ {processedLinkText}
+
+ );
+
+ lastIndex = linkRegex.lastIndex;
+ matchIndex++;
+ }
+
+ if (lastIndex < text.length) {
+ result.push(text.substring(lastIndex));
+ }
+
+ return result.length === 1 && typeof result[0] === "string" ? result[0] : result;
+};
+
+const processBold = (
+ text: string | ReactNode | ReactNode[],
+ keyOffset: number
+): ReactNode | ReactNode[] => {
+ if (typeof text !== "string") {
+ if (Array.isArray(text)) {
+ return text.map((item, index) =>
+ typeof item === "string" ? processBold(item, keyOffset + index * 100) : item
+ );
+ }
+ return text;
+ }
+
+ const boldRegex = /\*\*(.*?)\*\*/g;
+ let lastIndex = 0;
+ const result: ReactNode[] = [];
+ let match;
+ let matchIndex = 0;
+
+ while ((match = boldRegex.exec(text)) !== null) {
+ if (match.index > lastIndex) {
+ result.push(text.substring(lastIndex, match.index));
+ }
+
+ const boldContent = match[1];
+
+ const processedBoldContent = parseInlineMarkdown(boldContent, keyOffset + 2000 + matchIndex);
+
+ result.push(
{processedBoldContent});
+
+ lastIndex = boldRegex.lastIndex;
+ matchIndex++;
+ }
+
+ if (lastIndex < text.length) {
+ result.push(text.substring(lastIndex));
+ }
+
+
+ return result.length === 1 && typeof result[0] === "string" ? result[0] : result;
+};
+
+const processItalic = (
+ text: string | ReactNode | ReactNode[],
+ keyOffset: number
+): ReactNode | ReactNode[] => {
+ if (typeof text !== "string") {
+ if (Array.isArray(text)) {
+ return text.map((item, index) =>
+ typeof item === "string" ? processItalic(item, keyOffset + index * 100) : item
+ );
+ }
+ return text;
+ }
+
+ const italicRegex = /([*_])(.*?)\1/g;
+ let lastIndex = 0;
+ const result: ReactNode[] = [];
+ let match;
+ let matchIndex = 0;
+
+ while ((match = italicRegex.exec(text)) !== null) {
+ if (match.index > lastIndex) {
+ result.push(text.substring(lastIndex, match.index));
+ }
+
+ const italicContent = match[2];
+
+ const processedItalicContent = parseInlineMarkdown(
+ italicContent,
+ keyOffset + 4000 + matchIndex
+ );
+
+ result.push(
{processedItalicContent});
+
+ lastIndex = italicRegex.lastIndex;
+ matchIndex++;
+ }
+
+ if (lastIndex < text.length) {
+ result.push(text.substring(lastIndex));
+ }
+
+ return result.length === 1 && typeof result[0] === "string" ? result[0] : result;
+};
+
+const processStrikethrough = (
+ text: string | ReactNode | ReactNode[],
+ keyOffset: number
+): ReactNode | ReactNode[] => {
+ if (typeof text !== "string") {
+ if (Array.isArray(text)) {
+ return text.map((item, index) =>
+ typeof item === "string" ? processStrikethrough(item, keyOffset + index * 100) : item
+ );
+ }
+ return text;
+ }
+
+ const strikeRegex = /~~(.*?)~~/g;
+ let lastIndex = 0;
+ const result: ReactNode[] = [];
+ let match;
+ let matchIndex = 0;
+
+ while ((match = strikeRegex.exec(text)) !== null) {
+ if (match.index > lastIndex) {
+ result.push(text.substring(lastIndex, match.index));
+ }
+
+ const strikeContent = match[1];
+
+ const processedStrikeContent = parseInlineMarkdown(
+ strikeContent,
+ keyOffset + 5000 + matchIndex
+ );
+
+ result.push(
+
+ {processedStrikeContent}
+
+ );
+
+ lastIndex = strikeRegex.lastIndex;
+ matchIndex++;
+ }
+
+ if (lastIndex < text.length) {
+ result.push(text.substring(lastIndex));
+ }
+
+ return result.length === 1 && typeof result[0] === "string" ? result[0] : result;
+};
+
+const processInlineCode = (
+ text: string | ReactNode | ReactNode[],
+ keyOffset: number
+): ReactNode | ReactNode[] => {
+ if (typeof text !== "string") {
+ if (Array.isArray(text)) {
+ return text.map((item, index) =>
+ typeof item === "string" ? processInlineCode(item, keyOffset + index * 100) : item
+ );
+ }
+ return text;
+ }
+
+ const codeRegex = /`([^`]+)`/g;
+ let lastIndex = 0;
+ const result: ReactNode[] = [];
+ let match;
+ let matchIndex = 0;
+
+ while ((match = codeRegex.exec(text)) !== null) {
+ if (match.index > lastIndex) {
+ result.push(text.substring(lastIndex, match.index));
+ }
+
+ const codeContent = match[1];
+
+ result.push(
+
+ {codeContent}
+
+ );
+
+ lastIndex = codeRegex.lastIndex;
+ matchIndex++;
+ }
+
+ if (lastIndex < text.length) {
+ result.push(text.substring(lastIndex));
+ }
+
+ return result.length === 1 && typeof result[0] === "string" ? result[0] : result;
+};
+
+const processSpoiler = (
+ text: string | ReactNode | ReactNode[],
+ keyOffset: number
+): ReactNode | ReactNode[] => {
+ if (typeof text !== "string") {
+ if (Array.isArray(text)) {
+ return text.map((item, index) =>
+ typeof item === "string" ? processSpoiler(item, keyOffset + index * 100) : item
+ );
+ }
+ return text;
+ }
+
+ const spoilerRegex = /\|\|(.*?)\|\|/g;
+ let lastIndex = 0;
+ const result: ReactNode[] = [];
+ let match;
+ let matchIndex = 0;
+
+ while ((match = spoilerRegex.exec(text)) !== null) {
+ if (match.index > lastIndex) {
+ result.push(text.substring(lastIndex, match.index));
+ }
+
+ const spoilerContent = match[1];
+
+ const processedSpoilerContent = parseInlineMarkdown(
+ spoilerContent,
+ keyOffset + 6000 + matchIndex
+ );
+
+ result.push(
+
+ {processedSpoilerContent}
+
+ );
+
+ lastIndex = spoilerRegex.lastIndex;
+ matchIndex++;
+ }
+
+ if (lastIndex < text.length) {
+ result.push(text.substring(lastIndex));
+ }
+
+ return result.length === 1 && typeof result[0] === "string" ? result[0] : result;
+};
\ No newline at end of file
diff --git a/webview-ui/src/components/common/TableParser.tsx b/webview-ui/src/components/common/TableParser.tsx
new file mode 100644
index 00000000000..c67e2f3a0f4
--- /dev/null
+++ b/webview-ui/src/components/common/TableParser.tsx
@@ -0,0 +1,123 @@
+import { parseInlineMarkdown } from "./InlineParser";
+
+const parseTableHeaderCells = (headerRow: string) => {
+ return headerRow
+ .split("|")
+ .slice(1, -1)
+ .map(cell => cell.trim().replace(/^:[-]+:$|^:[-]+|[-]+:$/g, '').trim());
+};
+
+const parseTableDataRows = (dataRows: string[], maxRows: number = 50) => {
+ const rows = [];
+ try {
+ for (let i = 0; i < dataRows.length; i++) {
+ const cells = dataRows[i]
+ .split("|")
+ .slice(1, -1)
+ .map(cell => cell.trim());
+
+ if (cells.length > 0) {
+ rows.push(cells);
+ }
+
+ if (rows.length >= maxRows) {
+ break;
+ }
+ }
+ } catch (_error) {
+ return [];
+ }
+ return rows;
+};
+
+export const renderTableHeader = (headerCells: string[], keyPrefix: string) => {
+ return (
+
+
+ {headerCells.map((cell, idx) => (
+ |
+ {parseInlineMarkdown(cell, idx)}
+ |
+ ))}
+
+
+ );
+};
+
+export const renderTableBody = (rows: string[][], keyPrefix: string) => {
+ return (
+
+ {rows.map((row, rowIdx) => (
+
+ {row.map((cell, cellIdx) => (
+ |
+ {parseInlineMarkdown(cell, cellIdx + rowIdx * 100)}
+ |
+ ))}
+
+ ))}
+
+ );
+};
+
+const isValidTable = (separatorRow: string, headerCells: string[], rows: string[][]) => {
+ if (!separatorRow || !separatorRow.includes("|") || !separatorRow.match(/[-:]/)) {
+ return false;
+ }
+
+ if (headerCells.length === 0 || rows.some(row => row.length === 0)) {
+ return false;
+ }
+
+ return true;
+};
+
+export const parseTable = (tableText: string, keyPrefix: string) => {
+ try {
+ const lines = tableText.trim().split("\n").filter(line => line.trim() !== '');
+
+ const tableStartIndex = lines.findIndex(line => line.trim().startsWith('|'));
+ if (tableStartIndex === -1) {
+ return null;
+ }
+
+ const tableLines = lines.slice(tableStartIndex);
+
+ if (tableLines.length < 2) {
+ return null;
+ }
+
+ const headerRow = tableLines[0];
+ const separatorRow = tableLines[1];
+ const dataRows = tableLines.slice(2).filter(line => {
+ return !line.trim().match(/^[\s|:-]+$/);
+ });
+
+ const headerCells = parseTableHeaderCells(headerRow);
+ const rows = parseTableDataRows(dataRows);
+
+ if (!isValidTable(separatorRow, headerCells, rows)) {
+ return null;
+ }
+
+ return (
+
+
+ {renderTableHeader(headerCells, keyPrefix)}
+ {renderTableBody(rows, keyPrefix)}
+
+
+ );
+ } catch (_error) {
+ return null;
+ }
+};
diff --git a/webview-ui/src/components/common/__tests__/MarkdownTableIntegration.spec.tsx b/webview-ui/src/components/common/__tests__/MarkdownTableIntegration.spec.tsx
new file mode 100644
index 00000000000..3027b80c9b7
--- /dev/null
+++ b/webview-ui/src/components/common/__tests__/MarkdownTableIntegration.spec.tsx
@@ -0,0 +1,140 @@
+import { render } from "@testing-library/react";
+import { Markdown } from "../../chat/Markdown";
+import { vi } from "vitest";
+
+vi.mock("../MarkdownBlock", () => ({
+ __esModule: true,
+ default: vi.fn(({ markdown }) => (
+
{markdown}
+ )),
+}));
+
+vi.mock("@src/context/ExtensionStateContext", () => ({
+ useExtensionState: () => ({
+ theme: "dark",
+ }),
+}));
+
+describe("Markdown Table Integration", () => {
+ it("renders table in markdown", () => {
+ const md = `Text before
+| Header |
+|--------|
+| Cell |
+Text after`;
+ const { container } = render(
);
+ expect(container.querySelector("table")).toBeInTheDocument();
+ expect(container.querySelector("th")?.textContent).toBe("Header");
+ expect(container.querySelector("td")?.textContent).toBe("Cell");
+ const markdownBlocks = container.querySelectorAll('[data-testid="mock-markdown-block"]');
+ expect(markdownBlocks.length).toBe(2);
+ expect(markdownBlocks[0]?.textContent).toContain("Text before");
+ expect(markdownBlocks[1]?.textContent).toContain("Text after");
+ });
+
+ it("handles multiple tables", () => {
+ const md = `| Table 1 |\n|---------|\n| Cell |\n\n| Table 2 |\n|---------|\n| Cell |`;
+ const { container } = render(
);
+ expect(container.querySelectorAll("table").length).toBe(2);
+ expect(container.querySelectorAll("th")[0]?.textContent).toBe("Table 1");
+ expect(container.querySelectorAll("th")[1]?.textContent).toBe("Table 2");
+ });
+
+ it("handles markdown text interspersed with tables", () => {
+ const md = `This is some **bold** text.
+
+| Header A | Header B |
+|----------|----------|
+| Value A1 | Value B1 |
+
+And here is some *italic* text after the table.
+
+| Header C |
+|----------|
+| Value C1 |`;
+ const { container } = render(
);
+ expect(container.querySelectorAll("table").length).toBe(2);
+ const markdownBlocks = container.querySelectorAll('[data-testid="mock-markdown-block"]');
+ expect(markdownBlocks.length).toBe(2);
+ expect(markdownBlocks[0]?.textContent).toContain("This is some **bold** text.");
+ expect(markdownBlocks[1]?.textContent).toContain("And here is some *italic* text after the table.");
+ });
+
+ it("handles tables at the beginning of the markdown", () => {
+ const md = `| Start Header |
+|--------------|
+| Start Cell |
+Text after table.`;
+ const { container } = render(
);
+ expect(container.querySelector("table")).toBeInTheDocument();
+ expect(container.querySelector("th")?.textContent).toBe("Start Header");
+ const markdownBlocks = container.querySelectorAll('[data-testid="mock-markdown-block"]');
+ expect(markdownBlocks.length).toBe(1);
+ expect(markdownBlocks[0]?.textContent).toContain("Text after table.");
+ });
+
+ it("handles tables at the end of the markdown", () => {
+ const md = `Text before table.
+| End Header |
+|------------|
+| End Cell |`;
+ const { container } = render(
);
+ expect(container.querySelector("table")).toBeInTheDocument();
+ expect(container.querySelector("th")?.textContent).toBe("End Header");
+ const markdownBlocks = container.querySelectorAll('[data-testid="mock-markdown-block"]');
+ expect(markdownBlocks.length).toBe(1);
+ expect(markdownBlocks[0]?.textContent).toContain("Text before table.");
+ });
+
+ it("handles markdown with no tables", () => {
+ const md = `Just some plain **markdown** text.`;
+ const { container } = render(
);
+ expect(container.querySelector("table")).not.toBeInTheDocument();
+ const markdownBlocks = container.querySelectorAll('[data-testid="mock-markdown-block"]');
+ expect(markdownBlocks.length).toBe(1);
+ expect(markdownBlocks[0]?.textContent).toContain("Just some plain **markdown** text.");
+ });
+
+ it("handles empty markdown string", () => {
+ const { container } = render(
);
+ expect(container.innerHTML).toBe("");
+ });
+
+ it("handles markdown with only a table", () => {
+ const md = `| Only Table |
+|------------|
+| Cell |`;
+ const { container } = render(
);
+ expect(container.querySelector("table")).toBeInTheDocument();
+ expect(container.querySelector("th")?.textContent).toBe("Only Table");
+ });
+
+ it("handles bold markdown in table cells", () => {
+ const md = `| Header |\n|--------|\n| Cell with **bold** text |`;
+ const { container } = render(
);
+ const cellContent = container.querySelector("td");
+ expect(cellContent?.innerHTML).toContain("
bold");
+ });
+
+ it("handles italic markdown in table cells", () => {
+ const md = `| Header |\n|--------|\n| Cell with _italic_ text |`;
+ const { container } = render(
);
+ const cellContent = container.querySelector("td");
+ expect(cellContent?.innerHTML).toContain("
italic");
+ });
+
+ it("handles code markdown in table cells", () => {
+ const md = `| Header |\n|--------|\n| Cell with \`code\` snippet |`;
+ const { container } = render(
);
+ const cellContent = container.querySelector("td");
+ expect(cellContent?.querySelector("code")?.textContent).toContain("code");
+ });
+
+ it("handles link markdown in table cells", () => {
+ const md = `| Header |\n|--------|\n| Cell with a [link](http://example.com) |`;
+ const { container } = render(
);
+ const cellContent = container.querySelector("td");
+ expect(cellContent?.innerHTML).toContain("
");
+ });
+});
\ No newline at end of file
diff --git a/webview-ui/src/components/common/__tests__/TableParser.spec.tsx b/webview-ui/src/components/common/__tests__/TableParser.spec.tsx
new file mode 100644
index 00000000000..f6e2ceab159
--- /dev/null
+++ b/webview-ui/src/components/common/__tests__/TableParser.spec.tsx
@@ -0,0 +1,120 @@
+import { render } from "@testing-library/react";
+import { parseTable } from "../TableParser";
+import { parseInlineMarkdown } from "../InlineParser";
+
+describe("TableParser", () => {
+ it("parses simple table", () => {
+ const table = `| Header 1 | Header 2 |
+|----------|----------|
+| Cell 1 | Cell 2 |`;
+ const { container } = render(parseTable(table, "test-table")!);
+ expect(container.querySelector("table")).toBeInTheDocument();
+ expect(container.querySelectorAll("th").length).toBe(2);
+ expect(container.querySelector("th")?.textContent).toBe("Header 1");
+ expect(container.querySelectorAll("td").length).toBe(2);
+ expect(container.querySelector("td")?.textContent).toBe("Cell 1");
+ });
+
+ it("handles invalid tables", () => {
+ expect(parseTable("invalid content", "test")).toBeNull();
+ expect(parseTable("| Header |\n", "test")).toBeNull();
+ expect(parseTable("| Header |\n|---|", "test")).not.toBeNull();
+ });
+
+
+ it("handles markdown in cells", () => {
+ const table = `| Header |
+|--------|
+| **Bold** and _italic_ |`;
+ const { container } = render(parseTable(table, "markdown-table")!);
+ expect(container.querySelector("strong")).toBeInTheDocument();
+ expect(container.querySelector("em")).toBeInTheDocument();
+ expect(container.querySelector("strong")?.textContent).toBe("Bold");
+ expect(container.querySelector("em")?.textContent).toBe("italic");
+ });
+
+ it("handles empty cells", () => {
+ const table = `| H1 | H2 |
+|----|----|
+| C1 | |`;
+ const { container } = render(parseTable(table, "empty-cell-table")!);
+ expect(container.querySelectorAll("td")[1].textContent).toBe("");
+ });
+
+ it("handles tables with extra spaces", () => {
+ const table = `| Header 1 | Header 2 |
+|------------|------------|
+| Cell 1 | Cell 2 |`;
+ const { container } = render(parseTable(table, "spaced-table")!);
+ expect(container.querySelector("th")?.textContent).toBe("Header 1");
+ expect(container.querySelector("td")?.textContent).toBe("Cell 1");
+ });
+
+ it("handles tables with alignment markers", () => {
+ const table = `| Left | Center | Right |
+|:-----|:------:|------:|
+| L | C | R |`;
+ const { container } = render(parseTable(table, "alignment-table")!);
+ expect(container.querySelectorAll("th").length).toBe(3);
+ expect(container.querySelectorAll("td").length).toBe(3);
+ });
+
+ it("returns null for tables with too few lines", () => {
+ expect(parseTable("| Header |", "short-table")).toBeNull();
+ expect(parseTable("", "empty-table")).toBeNull();
+ });
+
+ it("handles multiple data rows", () => {
+ const table = `| H1 | H2 |
+|----|----|
+| R1C1 | R1C2 |
+| R2C1 | R2C2 |`;
+ const { container } = render(parseTable(table, "multi-row-table")!);
+ expect(container.querySelectorAll("tr").length).toBe(3); // 1 header + 2 data rows
+ expect(container.querySelectorAll("td").length).toBe(4);
+ });
+
+ it("handles tables with no data rows", () => {
+ const table = `| H1 | H2 |
+|----|----|`;
+ const { container } = render(parseTable(table, "no-data-table")!);
+ expect(container.querySelectorAll("tr").length).toBe(1); // Only header row
+ expect(container.querySelectorAll("td").length).toBe(0);
+ });
+
+ it("parses bold markdown", () => {
+ const text = "Cell with **bold** text.";
+ const result = parseInlineMarkdown(text, 0);
+ const container = render({result}
).container;
+ expect(container.innerHTML).toContain("bold");
+ });
+
+ it("parses italic markdown", () => {
+ const text = "Cell with _italic_ text.";
+ const result = parseInlineMarkdown(text, 0);
+ const container = render({result}
).container;
+ expect(container.innerHTML).toContain("italic");
+ });
+
+ it("parses italic markdown with single asterisks", () => {
+ const text = "Cell with *italic* text.";
+ const result = parseInlineMarkdown(text, 0);
+ const container = render({result}
).container;
+ expect(container.innerHTML).toContain("italic");
+ });
+
+ it("parses code markdown", () => {
+ const text = "Cell with `code` snippet.";
+ const result = parseInlineMarkdown(text, 0);
+ const container = render({result}
).container;
+ expect(container.querySelector("code")?.textContent).toContain("code");
+ });
+
+ it("parses link markdown", () => {
+ const text = "Cell with a [link](http://example.com).";
+ const result = parseInlineMarkdown(text, 0);
+ const container = render({result}
).container;
+ expect(container.innerHTML).toContain("");
+ });
+});
\ No newline at end of file