@tooee/renderers 0.1.11 → 0.1.14

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/dist/CodeView.d.ts +3 -7
  2. package/dist/CodeView.d.ts.map +1 -1
  3. package/dist/CodeView.js +5 -31
  4. package/dist/CodeView.js.map +1 -1
  5. package/dist/CommandPalette.d.ts.map +1 -1
  6. package/dist/CommandPalette.js +1 -23
  7. package/dist/CommandPalette.js.map +1 -1
  8. package/dist/DecorationLayer.d.ts +14 -0
  9. package/dist/DecorationLayer.d.ts.map +1 -0
  10. package/dist/DecorationLayer.js +2 -0
  11. package/dist/DecorationLayer.js.map +1 -0
  12. package/dist/MarkdownView.d.ts +16 -9
  13. package/dist/MarkdownView.d.ts.map +1 -1
  14. package/dist/MarkdownView.js +256 -107
  15. package/dist/MarkdownView.js.map +1 -1
  16. package/dist/RowDocumentRenderable.d.ts +14 -26
  17. package/dist/RowDocumentRenderable.d.ts.map +1 -1
  18. package/dist/RowDocumentRenderable.js +74 -78
  19. package/dist/RowDocumentRenderable.js.map +1 -1
  20. package/dist/Table.d.ts +5 -9
  21. package/dist/Table.d.ts.map +1 -1
  22. package/dist/Table.js +17 -46
  23. package/dist/Table.js.map +1 -1
  24. package/dist/index.d.ts +7 -3
  25. package/dist/index.d.ts.map +1 -1
  26. package/dist/index.js +4 -2
  27. package/dist/index.js.map +1 -1
  28. package/dist/parsers.d.ts.map +1 -1
  29. package/dist/parsers.js.map +1 -1
  30. package/dist/useGutterPalette.d.ts +3 -0
  31. package/dist/useGutterPalette.d.ts.map +1 -0
  32. package/dist/useGutterPalette.js +10 -0
  33. package/dist/useGutterPalette.js.map +1 -0
  34. package/package.json +18 -16
  35. package/src/CodeView.tsx +11 -54
  36. package/src/CommandPalette.tsx +1 -25
  37. package/src/DecorationLayer.ts +11 -0
  38. package/src/MarkdownView.tsx +382 -164
  39. package/src/RowDocumentRenderable.ts +91 -135
  40. package/src/Table.tsx +35 -71
  41. package/src/index.ts +6 -8
  42. package/src/parsers.ts +4 -1
  43. package/src/useGutterPalette.ts +15 -0
@@ -1,73 +1,161 @@
1
1
  import { marked, type Token, type Tokens } from "marked"
2
- import { useEffect, useMemo, useRef, type ReactNode, type RefObject } from "react"
2
+ import { useMemo, type ReactNode, type RefObject } from "react"
3
3
  import { useTheme, type ResolvedTheme } from "@tooee/themes"
4
- import { bold as boldChunk } from "@opentui/core"
5
- import type { SyntaxStyle, TextTableContent, TextTableCellContent } from "@opentui/core"
6
- import type { RowDocumentRenderable, RowDocumentPalette, RowDocumentDecorations } from "./RowDocumentRenderable.js"
4
+ import {
5
+ bold as boldChunk,
6
+ italic as italicChunk,
7
+ underline as underlineChunk,
8
+ parseColor,
9
+ } from "@opentui/core"
10
+ import type {
11
+ SyntaxStyle,
12
+ TextTableContent,
13
+ TextTableCellContent,
14
+ TextChunk,
15
+ } from "@opentui/core"
16
+ import type { MarkState } from "@tooee/marks"
17
+ import type { RowDocumentRenderable } from "./RowDocumentRenderable.js"
18
+ import { useGutterPalette } from "./useGutterPalette.js"
7
19
  import "./row-document.js"
8
20
  import "./text-table.js"
9
21
 
22
+ // ---------------------------------------------------------------------------
23
+ // Types
24
+ // ---------------------------------------------------------------------------
25
+
10
26
  interface MarkdownViewProps {
11
27
  content: string
12
28
  showLineNumbers?: boolean
13
- activeBlock?: number
14
- selectedBlocks?: { start: number; end: number }
15
- matchingBlocks?: Set<number>
16
- currentMatchBlock?: number
17
- toggledBlocks?: Set<number>
29
+ marks?: MarkState
18
30
  docRef?: RefObject<RowDocumentRenderable | null>
19
31
  }
20
32
 
21
- export function MarkdownView({
22
- content,
23
- showLineNumbers = true,
24
- activeBlock,
25
- selectedBlocks,
26
- matchingBlocks,
27
- currentMatchBlock,
28
- toggledBlocks,
29
- docRef,
30
- }: MarkdownViewProps) {
31
- const { theme, syntax } = useTheme()
32
- const internalRef = useRef<RowDocumentRenderable>(null)
33
- const effectiveRef = docRef ?? internalRef
34
- const tokens = marked.lexer(content)
35
- const blocks = tokens.filter((t) => t.type !== "space")
36
-
37
- const palette: RowDocumentPalette = {
38
- gutterFg: theme.textMuted,
39
- gutterBg: theme.backgroundElement,
40
- cursorBg: theme.cursorLine,
41
- selectionBg: theme.selection,
42
- matchBg: theme.warning,
43
- currentMatchBg: theme.primary,
44
- toggledBg: theme.backgroundPanel,
45
- cursorSignFg: theme.primary,
46
- matchSignFg: theme.warning,
47
- currentMatchSignFg: theme.primary,
33
+ /**
34
+ * A flattened block — all blocks exist at the top level with an indent.
35
+ * Nested structures (lists containing code blocks, etc.) are flattened
36
+ * into sibling blocks with appropriate indentation.
37
+ */
38
+ export interface FlatBlock {
39
+ token: Token
40
+ indent: number
41
+ bullet?: string // "- " or "1. " for list item lines
42
+ checked?: boolean // undefined = not a checkbox, true/false = checkbox state
43
+ }
44
+
45
+ // ---------------------------------------------------------------------------
46
+ // Token flattening
47
+ // ---------------------------------------------------------------------------
48
+
49
+ export function flattenTokens(tokens: Token[]): FlatBlock[] {
50
+ const result: FlatBlock[] = []
51
+ flattenTokenList(tokens, 0, result)
52
+ return result
53
+ }
54
+
55
+ function flattenTokenList(tokens: Token[], indent: number, result: FlatBlock[]): void {
56
+ for (const token of tokens) {
57
+ if (token.type === "space") continue
58
+
59
+ if (token.type === "list") {
60
+ const list = token as Tokens.List
61
+ for (let i = 0; i < list.items.length; i++) {
62
+ const item = list.items[i]
63
+ const bullet = list.ordered ? `${i + (list.start || 1)}. ` : "- "
64
+ flattenListItem(item, indent, bullet, result)
65
+ }
66
+ } else {
67
+ result.push({ token, indent })
68
+ }
48
69
  }
70
+ }
71
+
72
+ function flattenListItem(
73
+ item: Tokens.ListItem,
74
+ indent: number,
75
+ bullet: string,
76
+ result: FlatBlock[],
77
+ ): void {
78
+ const checked = item.checked != null ? item.checked : undefined
79
+ const childTokens = item.tokens || []
80
+ let bulletUsed = false
81
+
82
+ for (const token of childTokens) {
83
+ if (token.type === "space" || token.type === "checkbox") continue
49
84
 
50
- useEffect(() => {
51
- const decorations: RowDocumentDecorations = {
52
- cursorRow: activeBlock,
53
- selection: selectedBlocks ? { start: selectedBlocks.start, end: selectedBlocks.end } : null,
54
- matchingRows: matchingBlocks,
55
- currentMatchRow: currentMatchBlock,
56
- toggledRows: toggledBlocks,
85
+ if (token.type === "text" || token.type === "paragraph") {
86
+ // Inline content attach the bullet to the first one
87
+ result.push({
88
+ token,
89
+ indent,
90
+ bullet: bulletUsed ? undefined : bullet,
91
+ checked: bulletUsed ? undefined : checked,
92
+ })
93
+ bulletUsed = true
94
+ } else if (token.type === "list") {
95
+ // Emit bullet line if nothing preceded this nested list
96
+ if (!bulletUsed) {
97
+ result.push({
98
+ token: { type: "text", raw: "", text: "", tokens: [] } as unknown as Token,
99
+ indent,
100
+ bullet,
101
+ checked,
102
+ })
103
+ bulletUsed = true
104
+ }
105
+ // Nested list — increase indent to align with content after bullet
106
+ const list = token as Tokens.List
107
+ for (let i = 0; i < list.items.length; i++) {
108
+ const subItem = list.items[i]
109
+ const subBullet = list.ordered ? `${i + (list.start || 1)}. ` : "- "
110
+ flattenListItem(subItem, indent + bullet.length, subBullet, result)
111
+ }
112
+ } else {
113
+ // Block content (code, table, blockquote, hr, etc.)
114
+ if (!bulletUsed) {
115
+ result.push({
116
+ token: { type: "text", raw: "", text: "", tokens: [] } as unknown as Token,
117
+ indent,
118
+ bullet,
119
+ checked,
120
+ })
121
+ bulletUsed = true
122
+ }
123
+ // Emit the block indented to align with content after the bullet
124
+ result.push({ token, indent: indent + bullet.length })
57
125
  }
58
- effectiveRef.current?.setDecorations(decorations)
59
- }, [activeBlock, selectedBlocks, matchingBlocks, currentMatchBlock, toggledBlocks])
126
+ }
127
+
128
+ // List item had no content tokens — still emit the bullet
129
+ if (!bulletUsed) {
130
+ result.push({
131
+ token: { type: "text", raw: "", text: "", tokens: [] } as unknown as Token,
132
+ indent,
133
+ bullet,
134
+ checked,
135
+ })
136
+ }
137
+ }
60
138
 
61
- const blockElements = blocks.map((token, index) => (
62
- <TokenRenderer key={index} token={token} theme={theme} syntax={syntax} />
139
+ // ---------------------------------------------------------------------------
140
+ // Component
141
+ // ---------------------------------------------------------------------------
142
+
143
+ export function MarkdownView({ content, showLineNumbers = true, marks, docRef }: MarkdownViewProps) {
144
+ const { theme, syntax } = useTheme()
145
+ const palette = useGutterPalette()
146
+ const tokens = marked.lexer(content)
147
+ const blocks = flattenTokens(tokens)
148
+
149
+ const blockElements = blocks.map((block, index) => (
150
+ <FlatBlockRenderer key={index} block={block} theme={theme} syntax={syntax} />
63
151
  ))
64
152
 
65
153
  return (
66
154
  <row-document
67
- ref={effectiveRef}
68
- key={theme.textMuted + theme.backgroundElement}
155
+ ref={docRef}
69
156
  showLineNumbers={showLineNumbers}
70
157
  palette={palette}
158
+ decorations={marks?.sets}
71
159
  signColumnWidth={1}
72
160
  style={{ flexGrow: 1 }}
73
161
  >
@@ -76,32 +164,50 @@ export function MarkdownView({
76
164
  )
77
165
  }
78
166
 
79
- function TokenRenderer({
80
- token,
167
+ // ---------------------------------------------------------------------------
168
+ // Block renderer (flat)
169
+ // ---------------------------------------------------------------------------
170
+
171
+ function FlatBlockRenderer({
172
+ block,
81
173
  theme,
82
174
  syntax,
83
175
  }: {
84
- token: Token
176
+ block: FlatBlock
85
177
  theme: ResolvedTheme
86
178
  syntax: SyntaxStyle
87
179
  }): ReactNode {
180
+ const { token, indent, bullet } = block
181
+
182
+ // List item line (has bullet)
183
+ if (bullet !== undefined) {
184
+ return <ListLineRenderer block={block} theme={theme} />
185
+ }
186
+
187
+ // Regular block token
88
188
  switch (token.type) {
89
189
  case "heading":
90
- return <HeadingRenderer token={token as Tokens.Heading} theme={theme} />
190
+ return <HeadingRenderer token={token as Tokens.Heading} theme={theme} indent={indent} />
91
191
  case "paragraph":
92
- return <ParagraphRenderer token={token as Tokens.Paragraph} theme={theme} />
192
+ return <ParagraphRenderer token={token as Tokens.Paragraph} theme={theme} indent={indent} />
93
193
  case "code":
94
- return <CodeBlockRenderer token={token as Tokens.Code} theme={theme} syntax={syntax} />
194
+ return (
195
+ <CodeBlockRenderer
196
+ token={token as Tokens.Code}
197
+ theme={theme}
198
+ syntax={syntax}
199
+ indent={indent}
200
+ />
201
+ )
95
202
  case "blockquote":
96
- return <BlockquoteRenderer token={token as Tokens.Blockquote} theme={theme} />
97
- case "list":
98
- return <ListRenderer token={token as Tokens.List} theme={theme} />
203
+ return (
204
+ <BlockquoteRenderer token={token as Tokens.Blockquote} theme={theme} indent={indent} />
205
+ )
99
206
  case "table":
100
- return <MarkdownTableRenderer token={token as Tokens.Table} />
207
+ return <MarkdownTableRenderer token={token as Tokens.Table} indent={indent} />
101
208
  case "hr":
102
- return <HorizontalRule theme={theme} />
209
+ return <HorizontalRule theme={theme} indent={indent} />
103
210
  case "space":
104
- return null
105
211
  case "html":
106
212
  return null
107
213
  default:
@@ -113,7 +219,7 @@ function TokenRenderer({
113
219
  fg: theme.markdownText,
114
220
  marginBottom: 1,
115
221
  marginTop: 0,
116
- marginLeft: 1,
222
+ marginLeft: 1 + indent,
117
223
  marginRight: 1,
118
224
  }}
119
225
  />
@@ -123,7 +229,52 @@ function TokenRenderer({
123
229
  }
124
230
  }
125
231
 
126
- function HeadingRenderer({ token, theme }: { token: Tokens.Heading; theme: ResolvedTheme }) {
232
+ // ---------------------------------------------------------------------------
233
+ // List line renderer
234
+ // ---------------------------------------------------------------------------
235
+
236
+ function ListLineRenderer({ block, theme }: { block: FlatBlock; theme: ResolvedTheme }) {
237
+ const { token, indent, bullet, checked } = block
238
+ const checkboxPrefix = checked !== undefined ? (checked ? "[x] " : "[ ] ") : ""
239
+
240
+ // Get inline tokens from the text/paragraph token
241
+ const inlineTokens: Token[] =
242
+ "tokens" in token && Array.isArray(token.tokens) ? token.tokens : []
243
+
244
+ const hasText = "text" in token && typeof token.text === "string" && token.text.length > 0
245
+ const hasContent = inlineTokens.length > 0 || hasText
246
+
247
+ return (
248
+ <box style={{ marginLeft: 1 + indent, marginRight: 1 }}>
249
+ <text style={{ fg: theme.markdownText }}>
250
+ <span fg={theme.markdownListItem}>{bullet}</span>
251
+ {checkboxPrefix !== "" && (
252
+ <span fg={checked ? theme.accent : theme.textMuted}>{checkboxPrefix}</span>
253
+ )}
254
+ {hasContent &&
255
+ (inlineTokens.length > 0 ? (
256
+ <InlineTokens tokens={inlineTokens} theme={theme} />
257
+ ) : hasText ? (
258
+ ("text" in token ? (token as { text: string }).text : "")
259
+ ) : null)}
260
+ </text>
261
+ </box>
262
+ )
263
+ }
264
+
265
+ // ---------------------------------------------------------------------------
266
+ // Block renderers
267
+ // ---------------------------------------------------------------------------
268
+
269
+ function HeadingRenderer({
270
+ token,
271
+ theme,
272
+ indent,
273
+ }: {
274
+ token: Tokens.Heading
275
+ theme: ResolvedTheme
276
+ indent: number
277
+ }) {
127
278
  const headingColors: Record<number, string> = {
128
279
  1: theme.markdownHeading,
129
280
  2: theme.secondary,
@@ -142,21 +293,29 @@ function HeadingRenderer({ token, theme }: { token: Tokens.Heading; theme: Resol
142
293
  6: "###### ",
143
294
  }
144
295
 
145
- const headingText = getPlainText(token.tokens || [])
146
-
147
296
  return (
148
- <box style={{ marginTop: 1, marginBottom: 1 }}>
297
+ <box style={{ marginTop: 1, marginBottom: 1, marginLeft: indent }}>
149
298
  <text style={{ fg: headingColors[token.depth] || theme.text }}>
150
299
  <span fg={theme.textMuted}>{prefixes[token.depth]}</span>
151
- <strong>{headingText}</strong>
300
+ <strong>
301
+ <InlineTokens tokens={token.tokens || []} theme={theme} />
302
+ </strong>
152
303
  </text>
153
304
  </box>
154
305
  )
155
306
  }
156
307
 
157
- function ParagraphRenderer({ token, theme }: { token: Tokens.Paragraph; theme: ResolvedTheme }) {
308
+ function ParagraphRenderer({
309
+ token,
310
+ theme,
311
+ indent,
312
+ }: {
313
+ token: Tokens.Paragraph
314
+ theme: ResolvedTheme
315
+ indent: number
316
+ }) {
158
317
  return (
159
- <box style={{ marginBottom: 1, marginLeft: 1, marginRight: 1 }}>
318
+ <box style={{ marginBottom: 1, marginLeft: 1 + indent, marginRight: 1 }}>
160
319
  <text style={{ fg: theme.markdownText }}>
161
320
  <InlineTokens tokens={token.tokens || []} theme={theme} />
162
321
  </text>
@@ -168,10 +327,12 @@ function CodeBlockRenderer({
168
327
  token,
169
328
  theme,
170
329
  syntax,
330
+ indent,
171
331
  }: {
172
332
  token: Tokens.Code
173
333
  theme: ResolvedTheme
174
334
  syntax: SyntaxStyle
335
+ indent: number
175
336
  }) {
176
337
  const lineCount = token.text.split("\n").length
177
338
  return (
@@ -179,7 +340,7 @@ function CodeBlockRenderer({
179
340
  style={{
180
341
  marginTop: 0,
181
342
  marginBottom: 1,
182
- marginLeft: 1,
343
+ marginLeft: 1 + indent,
183
344
  marginRight: 1,
184
345
  border: true,
185
346
  borderColor: theme.border,
@@ -197,101 +358,72 @@ function CodeBlockRenderer({
197
358
  )
198
359
  }
199
360
 
200
- function BlockquoteRenderer({ token, theme }: { token: Tokens.Blockquote; theme: ResolvedTheme }) {
201
- const quoteText = token.tokens
202
- ? token.tokens
203
- .map((t) => {
204
- const innerTokens = "tokens" in t ? (t as { tokens?: Token[] }).tokens : undefined
205
- const textContent = "text" in t ? (t as { text?: string }).text : ""
206
- return getPlainText(innerTokens || []) || textContent || ""
207
- })
208
- .join("\n")
209
- : ""
210
-
211
- return (
212
- <box style={{ marginTop: 0, marginBottom: 1, marginLeft: 1, marginRight: 1, paddingLeft: 2 }}>
213
- <text style={{ fg: theme.markdownBlockQuote }} content="│ " />
214
- <text style={{ fg: theme.textMuted }} content={quoteText} />
215
- </box>
216
- )
217
- }
218
-
219
- function ListRenderer({ token, theme }: { token: Tokens.List; theme: ResolvedTheme }) {
220
- return (
221
- <box style={{ marginBottom: 1, marginLeft: 3, marginRight: 1, flexDirection: "column" }}>
222
- {token.items.map((item, index) => (
223
- <ListItemRenderer
224
- key={index}
225
- item={item}
226
- ordered={token.ordered}
227
- index={index + (token.start || 1)}
228
- theme={theme}
229
- />
230
- ))}
231
- </box>
232
- )
233
- }
234
-
235
- function ListItemRenderer({
236
- item,
237
- ordered,
238
- index,
361
+ function BlockquoteRenderer({
362
+ token,
239
363
  theme,
364
+ indent,
240
365
  }: {
241
- item: Tokens.ListItem
242
- ordered: boolean
243
- index: number
366
+ token: Tokens.Blockquote
244
367
  theme: ResolvedTheme
368
+ indent: number
245
369
  }) {
246
- const bullet = ordered ? `${index}. ` : "- "
247
- const itemContent = item.tokens || []
370
+ // Collect inline tokens from blockquote's child paragraphs/text
371
+ const inlineTokens: Token[] = []
372
+ if (token.tokens) {
373
+ for (const child of token.tokens) {
374
+ if ("tokens" in child && Array.isArray(child.tokens)) {
375
+ if (inlineTokens.length > 0) {
376
+ inlineTokens.push({ type: "text", raw: "\n", text: "\n" } as Token)
377
+ }
378
+ inlineTokens.push(...(child.tokens as Token[]))
379
+ } else if ("text" in child && typeof child.text === "string") {
380
+ inlineTokens.push(child)
381
+ }
382
+ }
383
+ }
248
384
 
249
385
  return (
250
- <box style={{ flexDirection: "row" }}>
251
- <text style={{ fg: theme.markdownListItem }} content={bullet} />
252
- <box style={{ flexShrink: 1, flexDirection: "column" }}>
253
- {itemContent.map((token, idx) => {
254
- if (token.type === "text" && "tokens" in token && token.tokens) {
255
- return (
256
- <text key={idx} style={{ fg: theme.markdownText }}>
257
- <InlineTokens tokens={token.tokens} theme={theme} />
258
- </text>
259
- )
260
- }
261
- if (token.type === "paragraph" && token.tokens) {
262
- return (
263
- <text key={idx} style={{ fg: theme.markdownText }}>
264
- <InlineTokens tokens={token.tokens} theme={theme} />
265
- </text>
266
- )
267
- }
268
- if ("text" in token && typeof token.text === "string") {
269
- return <text key={idx} style={{ fg: theme.markdownText }} content={token.text} />
270
- }
271
- return null
272
- })}
273
- </box>
386
+ <box
387
+ style={{
388
+ marginTop: 0,
389
+ marginBottom: 1,
390
+ marginLeft: 1 + indent,
391
+ marginRight: 1,
392
+ paddingLeft: 2,
393
+ }}
394
+ >
395
+ <text style={{ fg: theme.markdownBlockQuote }} content="│ " />
396
+ <text style={{ fg: theme.textMuted }}>
397
+ <InlineTokens tokens={inlineTokens} theme={theme} />
398
+ </text>
274
399
  </box>
275
400
  )
276
401
  }
277
402
 
278
- function MarkdownTableRenderer({ token }: { token: Tokens.Table }) {
403
+ function MarkdownTableRenderer({ token, indent }: { token: Tokens.Table; indent: number }) {
279
404
  const { theme } = useTheme()
280
405
 
281
406
  const content: TextTableContent = useMemo(() => {
282
- const headerRow: TextTableCellContent[] = token.header.map(cell => [
283
- boldChunk(getPlainText(cell.tokens).trim())
284
- ])
285
- const dataRows = token.rows.map(row =>
286
- row.map(cell => [
287
- { __isChunk: true as const, text: getPlainText(cell.tokens) }
288
- ] as TextTableCellContent)
407
+ const headerRow: TextTableCellContent[] = token.header.map((cell) => {
408
+ const chunks = inlineTokensToChunks(cell.tokens, theme)
409
+ // Wrap header chunks in bold
410
+ return chunks.length > 0
411
+ ? chunks.map((c) => boldChunk(c))
412
+ : [boldChunk(getPlainText(cell.tokens).trim())]
413
+ })
414
+ const dataRows = token.rows.map((row) =>
415
+ row.map((cell) => {
416
+ const chunks = inlineTokensToChunks(cell.tokens, theme)
417
+ return chunks.length > 0
418
+ ? chunks
419
+ : ([{ __isChunk: true as const, text: getPlainText(cell.tokens) }] as TextTableCellContent)
420
+ }),
289
421
  )
290
422
  return [headerRow, ...dataRows]
291
- }, [token])
423
+ }, [token, theme])
292
424
 
293
425
  return (
294
- <box style={{ marginLeft: 1, marginRight: 1, marginBottom: 1 }}>
426
+ <box style={{ marginLeft: 1 + indent, marginRight: 1, marginBottom: 1 }}>
295
427
  <text-table
296
428
  content={content}
297
429
  wrapMode="word"
@@ -306,25 +438,17 @@ function MarkdownTableRenderer({ token }: { token: Tokens.Table }) {
306
438
  )
307
439
  }
308
440
 
309
- function HorizontalRule({ theme }: { theme: ResolvedTheme }) {
441
+ function HorizontalRule({ theme, indent }: { theme: ResolvedTheme; indent: number }) {
310
442
  return (
311
- <box style={{ marginTop: 0, marginBottom: 1, marginLeft: 1, marginRight: 1 }}>
443
+ <box style={{ marginTop: 0, marginBottom: 1, marginLeft: 1 + indent, marginRight: 1 }}>
312
444
  <text style={{ fg: theme.markdownHorizontalRule }} content={"─".repeat(40)} />
313
445
  </box>
314
446
  )
315
447
  }
316
448
 
317
- function getPlainText(tokens: Token[]): string {
318
- return tokens
319
- .map((token) => {
320
- if (token.type === "text") return token.text
321
- if (token.type === "codespan") return (token as Tokens.Codespan).text
322
- if ("tokens" in token && token.tokens) return getPlainText(token.tokens as Token[])
323
- if ("text" in token) return (token as { text: string }).text
324
- return ""
325
- })
326
- .join("")
327
- }
449
+ // ---------------------------------------------------------------------------
450
+ // Inline token rendering (React elements)
451
+ // ---------------------------------------------------------------------------
328
452
 
329
453
  function InlineTokens({ tokens, theme }: { tokens: Token[]; theme: ResolvedTheme }): ReactNode {
330
454
  const result: ReactNode[] = []
@@ -340,11 +464,17 @@ function InlineTokens({ tokens, theme }: { tokens: Token[]; theme: ResolvedTheme
340
464
  break
341
465
  case "strong":
342
466
  result.push(
343
- <strong key={key}>{getPlainText((token as Tokens.Strong).tokens || [])}</strong>,
467
+ <strong key={key}>
468
+ <InlineTokens tokens={(token as Tokens.Strong).tokens || []} theme={theme} />
469
+ </strong>,
344
470
  )
345
471
  break
346
472
  case "em":
347
- result.push(<em key={key}>{getPlainText((token as Tokens.Em).tokens || [])}</em>)
473
+ result.push(
474
+ <em key={key}>
475
+ <InlineTokens tokens={(token as Tokens.Em).tokens || []} theme={theme} />
476
+ </em>,
477
+ )
348
478
  break
349
479
  case "codespan":
350
480
  result.push(
@@ -358,12 +488,30 @@ function InlineTokens({ tokens, theme }: { tokens: Token[]; theme: ResolvedTheme
358
488
  result.push(
359
489
  <u key={key}>
360
490
  <a href={linkToken.href} fg={theme.markdownLink}>
361
- {getPlainText(linkToken.tokens || [])}
491
+ <InlineTokens tokens={linkToken.tokens || []} theme={theme} />
362
492
  </a>
363
493
  </u>,
364
494
  )
365
495
  break
366
496
  }
497
+ case "del":
498
+ result.push(
499
+ <span key={key} fg={theme.textMuted}>
500
+ {"~"}
501
+ <InlineTokens tokens={(token as Tokens.Del).tokens || []} theme={theme} />
502
+ {"~"}
503
+ </span>,
504
+ )
505
+ break
506
+ case "image": {
507
+ const imgToken = token as Tokens.Image
508
+ result.push(
509
+ <span key={key} fg={theme.textMuted}>
510
+ {imgToken.text || imgToken.href}
511
+ </span>,
512
+ )
513
+ break
514
+ }
367
515
  case "br":
368
516
  result.push("\n")
369
517
  break
@@ -383,3 +531,73 @@ function InlineTokens({ tokens, theme }: { tokens: Token[]; theme: ResolvedTheme
383
531
 
384
532
  return <>{result}</>
385
533
  }
534
+
535
+ // ---------------------------------------------------------------------------
536
+ // Inline token rendering (TextChunks — for text-table cells)
537
+ // ---------------------------------------------------------------------------
538
+
539
+ function inlineTokensToChunks(tokens: Token[], theme: ResolvedTheme): TextChunk[] {
540
+ const chunks: TextChunk[] = []
541
+
542
+ for (const token of tokens) {
543
+ switch (token.type) {
544
+ case "text":
545
+ chunks.push({ __isChunk: true as const, text: (token as Tokens.Text).text })
546
+ break
547
+ case "strong":
548
+ for (const sub of inlineTokensToChunks(
549
+ (token as Tokens.Strong).tokens || [],
550
+ theme,
551
+ )) {
552
+ chunks.push(boldChunk(sub))
553
+ }
554
+ break
555
+ case "em":
556
+ for (const sub of inlineTokensToChunks((token as Tokens.Em).tokens || [], theme)) {
557
+ chunks.push(italicChunk(sub))
558
+ }
559
+ break
560
+ case "codespan":
561
+ chunks.push({
562
+ __isChunk: true as const,
563
+ text: ` ${(token as Tokens.Codespan).text} `,
564
+ fg: parseColor(theme.markdownCode),
565
+ bg: parseColor(theme.backgroundPanel),
566
+ })
567
+ break
568
+ case "link": {
569
+ const linkToken = token as Tokens.Link
570
+ for (const sub of inlineTokensToChunks(linkToken.tokens || [], theme)) {
571
+ chunks.push(underlineChunk({ ...sub, fg: parseColor(theme.markdownLink) }))
572
+ }
573
+ break
574
+ }
575
+ case "escape":
576
+ chunks.push({ __isChunk: true as const, text: (token as Tokens.Escape).text })
577
+ break
578
+ default:
579
+ if ("text" in token && typeof (token as { text?: string }).text === "string") {
580
+ chunks.push({ __isChunk: true as const, text: (token as { text: string }).text })
581
+ }
582
+ break
583
+ }
584
+ }
585
+
586
+ return chunks
587
+ }
588
+
589
+ // ---------------------------------------------------------------------------
590
+ // Plain text extraction (only for width computation, not rendering)
591
+ // ---------------------------------------------------------------------------
592
+
593
+ function getPlainText(tokens: Token[]): string {
594
+ return tokens
595
+ .map((token) => {
596
+ if (token.type === "text") return token.text
597
+ if (token.type === "codespan") return (token as Tokens.Codespan).text
598
+ if ("tokens" in token && token.tokens) return getPlainText(token.tokens as Token[])
599
+ if ("text" in token) return (token as { text: string }).text
600
+ return ""
601
+ })
602
+ .join("")
603
+ }