diff --git a/packages/app/src/components/chat/ChatMessage.vue b/packages/app/src/components/chat/ChatMessage.vue
index af8f8b39..55d9d757 100644
--- a/packages/app/src/components/chat/ChatMessage.vue
+++ b/packages/app/src/components/chat/ChatMessage.vue
@@ -200,6 +200,15 @@
/>
+
+
+
+
{
return extractRecipes(props.message.content)
})
+const inlineTables = computed(() => {
+ if (isUser.value) return []
+ return extractTables(props.message.content)
+})
+
const inlineEvents = computed(() => {
if (isUser.value) return []
return extractEvents(props.message.content)
diff --git a/packages/app/src/components/renderers/InteractiveTable.vue b/packages/app/src/components/renderers/InteractiveTable.vue
new file mode 100644
index 00000000..5ff44d03
--- /dev/null
+++ b/packages/app/src/components/renderers/InteractiveTable.vue
@@ -0,0 +1,134 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+ |
+
+ {{ header }}
+
+ {{ sortDir === 'asc' ? '↑' : '↓' }}
+
+
+ |
+
+
+
+
+ |
+ {{ cell }}
+ |
+
+
+
+
+
+
+ No matching rows
+
+
+
+ {{ filteredRows.length }} row{{ filteredRows.length === 1 ? '' : 's' }}
+ (filtered from {{ rows.length }})
+
+
+
+
+
diff --git a/packages/app/src/composables/useTableExtractor.ts b/packages/app/src/composables/useTableExtractor.ts
new file mode 100644
index 00000000..9c8de362
--- /dev/null
+++ b/packages/app/src/composables/useTableExtractor.ts
@@ -0,0 +1,49 @@
+/**
+ * Extract markdown tables from text into structured data
+ * for interactive table rendering.
+ */
+
+export interface TableData {
+ headers: string[]
+ rows: string[][]
+ raw: string
+}
+
+// Matches a markdown table: header row, separator row, data rows
+const TABLE_RE = /^(\|[^\n]+\|)\n(\|[\s:|-]+\|)\n((?:\|[^\n]+\|\n?)+)/gm
+
+export function extractTables(text: string): TableData[] {
+ const results: TableData[] = []
+ let m: RegExpExecArray | null
+ const re = new RegExp(TABLE_RE.source, TABLE_RE.flags)
+
+ while ((m = re.exec(text)) !== null) {
+ const headerLine = m[1]
+ const dataBlock = m[3]
+
+ const headers = parseRow(headerLine)
+ const rows = dataBlock
+ .trim()
+ .split('\n')
+ .map(parseRow)
+ .filter((r) => r.length > 0)
+
+ if (headers.length > 0 && rows.length > 0) {
+ results.push({ headers, rows, raw: m[0] })
+ }
+ }
+
+ return results
+}
+
+function parseRow(line: string): string[] {
+ return line
+ .replace(/^\|/, '')
+ .replace(/\|$/, '')
+ .split('|')
+ .map((cell) => cell.trim())
+}
+
+export function hasTables(text: string): boolean {
+ return TABLE_RE.test(text)
+}