@tooee/renderers 0.1.0

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.
@@ -0,0 +1,137 @@
1
+ import { useState, useMemo, useCallback } from "react"
2
+ import { useKeyboard } from "@opentui/react"
3
+ import { useTheme } from "@tooee/themes"
4
+
5
+ export interface CommandPaletteEntry {
6
+ id: string
7
+ title: string
8
+ hotkey?: string
9
+ category?: string
10
+ icon?: string
11
+ }
12
+
13
+ interface CommandPaletteProps {
14
+ commands: CommandPaletteEntry[]
15
+ onSelect: (commandId: string) => void
16
+ onClose: () => void
17
+ }
18
+
19
+ function fuzzyMatch(query: string, text: string): number | null {
20
+ const lowerQuery = query.toLowerCase()
21
+ const lowerText = text.toLowerCase()
22
+
23
+ let qi = 0
24
+ let score = 0
25
+ let lastMatchIndex = -2
26
+
27
+ for (let ti = 0; ti < lowerText.length && qi < lowerQuery.length; ti++) {
28
+ if (lowerText[ti] === lowerQuery[qi]) {
29
+ // Start of string bonus
30
+ if (ti === 0) score += 3
31
+ // Word boundary bonus (after space, hyphen, dot, slash)
32
+ else if (" -./".includes(lowerText[ti - 1]!)) score += 2
33
+ // Consecutive bonus
34
+ else if (ti === lastMatchIndex + 1) score += 1
35
+
36
+ lastMatchIndex = ti
37
+ qi++
38
+ }
39
+ }
40
+
41
+ return qi === lowerQuery.length ? score : null
42
+ }
43
+
44
+ export function CommandPalette({ commands, onSelect, onClose }: CommandPaletteProps) {
45
+ const { theme } = useTheme()
46
+ const [filter, setFilter] = useState("")
47
+ const [activeIndex, setActiveIndex] = useState(0)
48
+
49
+ const filtered = useMemo(() => {
50
+ if (!filter) return commands
51
+ const results: { entry: CommandPaletteEntry; score: number }[] = []
52
+ for (const entry of commands) {
53
+ const score = fuzzyMatch(filter, entry.title)
54
+ if (score !== null) results.push({ entry, score })
55
+ }
56
+ results.sort((a, b) => b.score - a.score)
57
+ return results.map((r) => r.entry)
58
+ }, [commands, filter])
59
+
60
+ const handleSelect = useCallback(() => {
61
+ const item = filtered[activeIndex]
62
+ if (item) {
63
+ onSelect(item.id)
64
+ }
65
+ }, [filtered, activeIndex, onSelect])
66
+
67
+ useKeyboard((key) => {
68
+ if (key.name === "escape") {
69
+ key.preventDefault()
70
+ onClose()
71
+ } else if (key.name === "return") {
72
+ key.preventDefault()
73
+ handleSelect()
74
+ } else if (key.name === "up") {
75
+ key.preventDefault()
76
+ setActiveIndex((i) => Math.max(0, i - 1))
77
+ } else if (key.name === "down") {
78
+ key.preventDefault()
79
+ setActiveIndex((i) => Math.min(filtered.length - 1, i + 1))
80
+ }
81
+ })
82
+
83
+ return (
84
+ <box
85
+ position="absolute"
86
+ left="20%"
87
+ right="20%"
88
+ top={2}
89
+ maxHeight="60%"
90
+ flexDirection="column"
91
+ backgroundColor={theme.backgroundPanel}
92
+ border
93
+ borderColor={theme.border}
94
+ >
95
+ {/* Filter row */}
96
+ <box flexDirection="row" paddingLeft={1} paddingRight={1} height={1}>
97
+ <text content=":" fg={theme.accent} />
98
+ <input
99
+ focused
100
+ placeholder="Filter commands..."
101
+ onInput={(value: string) => {
102
+ setFilter(value)
103
+ setActiveIndex(0)
104
+ }}
105
+ backgroundColor="transparent"
106
+ focusedBackgroundColor="transparent"
107
+ textColor={theme.text}
108
+ placeholderColor={theme.textMuted}
109
+ cursorColor={theme.accent}
110
+ style={{ flexGrow: 1 }}
111
+ />
112
+ <text content={` ${filtered.length}`} fg={theme.textMuted} />
113
+ </box>
114
+
115
+ {/* Separator */}
116
+ <box height={1} width="100%" backgroundColor={theme.border} />
117
+
118
+ {/* Command list */}
119
+ <scrollbox focused={false} style={{ flexGrow: 1 }}>
120
+ {filtered.map((entry, i) => (
121
+ <box
122
+ key={entry.id}
123
+ flexDirection="row"
124
+ paddingLeft={1}
125
+ paddingRight={1}
126
+ height={1}
127
+ backgroundColor={i === activeIndex ? theme.backgroundElement : undefined}
128
+ >
129
+ <text content={entry.title} fg={theme.text} style={{ flexGrow: 1 }} />
130
+ {entry.hotkey && <text content={entry.hotkey} fg={theme.textMuted} />}
131
+ {entry.category && <text content={` ${entry.category}`} fg={theme.textMuted} />}
132
+ </box>
133
+ ))}
134
+ </scrollbox>
135
+ </box>
136
+ )
137
+ }
@@ -0,0 +1,183 @@
1
+ import { useState, useEffect } from "react"
2
+ import { Jimp } from "jimp"
3
+ import { useTerminalDimensions } from "@opentui/react"
4
+ import { useTheme } from "@tooee/themes"
5
+
6
+ interface ImageViewProps {
7
+ /** File path to the image */
8
+ src: string
9
+ /** Maximum width in characters (defaults to terminal width) */
10
+ maxWidth?: number
11
+ /** Maximum height in characters (defaults to terminal height) */
12
+ maxHeight?: number
13
+ }
14
+
15
+ interface PixelRow {
16
+ key: number
17
+ cells: PixelCell[]
18
+ }
19
+
20
+ interface PixelCell {
21
+ char: string
22
+ fg: string
23
+ bg: string
24
+ }
25
+
26
+ /**
27
+ * Renders an image in the terminal using half-block characters.
28
+ * Each character cell displays two vertical pixels using foreground (top) and background (bottom) colors.
29
+ */
30
+ export function ImageView({ src, maxWidth, maxHeight }: ImageViewProps) {
31
+ const { theme } = useTheme()
32
+ const { width: termWidth, height: termHeight } = useTerminalDimensions()
33
+ const [rows, setRows] = useState<PixelRow[] | null>(null)
34
+ const [error, setError] = useState<string | null>(null)
35
+ const [imageInfo, setImageInfo] = useState<{ width: number; height: number } | null>(null)
36
+
37
+ const effectiveMaxWidth = maxWidth ?? termWidth - 2
38
+ const effectiveMaxHeight = maxHeight ?? (termHeight - 4) * 2 // *2 because each char = 2 pixels
39
+
40
+ useEffect(() => {
41
+ let cancelled = false
42
+
43
+ async function loadImage() {
44
+ try {
45
+ const image = await Jimp.read(src)
46
+ if (cancelled) return
47
+
48
+ const originalWidth = image.width
49
+ const originalHeight = image.height
50
+
51
+ // Calculate scale to fit within bounds while preserving aspect ratio
52
+ // Terminal characters are roughly 2:1 (height:width), so we need to account for this
53
+ const _charAspectRatio = 2 // Each character is roughly 2x as tall as it is wide
54
+ const adjustedMaxHeight = effectiveMaxHeight
55
+
56
+ const scaleX = effectiveMaxWidth / originalWidth
57
+ const scaleY = adjustedMaxHeight / originalHeight
58
+ const scale = Math.min(scaleX, scaleY, 1) // Don't upscale
59
+
60
+ const newWidth = Math.floor(originalWidth * scale)
61
+ const newHeight = Math.floor(originalHeight * scale)
62
+
63
+ // Resize image
64
+ if (newWidth !== originalWidth || newHeight !== originalHeight) {
65
+ image.resize({ w: newWidth, h: newHeight })
66
+ }
67
+
68
+ // Convert to half-block characters
69
+ const pixelRows = convertToHalfBlocks(image)
70
+
71
+ if (!cancelled) {
72
+ setImageInfo({ width: newWidth, height: Math.ceil(newHeight / 2) })
73
+ setRows(pixelRows)
74
+ setError(null)
75
+ }
76
+ } catch (err) {
77
+ if (!cancelled) {
78
+ setError(err instanceof Error ? err.message : "Failed to load image")
79
+ setRows(null)
80
+ }
81
+ }
82
+ }
83
+
84
+ loadImage()
85
+ return () => {
86
+ cancelled = true
87
+ }
88
+ }, [src, effectiveMaxWidth, effectiveMaxHeight])
89
+
90
+ if (error) {
91
+ return (
92
+ <box style={{ flexDirection: "column", padding: 1 }}>
93
+ <text content={`Error loading image: ${error}`} fg={theme.error} />
94
+ </box>
95
+ )
96
+ }
97
+
98
+ if (!rows) {
99
+ return (
100
+ <box style={{ padding: 1 }}>
101
+ <text content="Loading image..." fg={theme.textMuted} />
102
+ </box>
103
+ )
104
+ }
105
+
106
+ return (
107
+ <box style={{ flexDirection: "column" }}>
108
+ {rows.map((row) => (
109
+ <text key={row.key} height={1}>
110
+ {row.cells.map((cell, i) => (
111
+ <span key={i} fg={cell.fg} bg={cell.bg}>
112
+ {cell.char}
113
+ </span>
114
+ ))}
115
+ </text>
116
+ ))}
117
+ {imageInfo && (
118
+ <text
119
+ content={`${imageInfo.width}×${imageInfo.height * 2} pixels`}
120
+ fg={theme.textMuted}
121
+ style={{ marginTop: 1 }}
122
+ />
123
+ )}
124
+ </box>
125
+ )
126
+ }
127
+
128
+ /**
129
+ * Convert an image to half-block characters.
130
+ * Uses ▀ (upper half block) where top pixel is fg, bottom pixel is bg.
131
+ */
132
+ function convertToHalfBlocks(image: Awaited<ReturnType<typeof Jimp.read>>): PixelRow[] {
133
+ const width = image.width
134
+ const height = image.height
135
+ const rows: PixelRow[] = []
136
+
137
+ // Process two rows at a time (top and bottom of each character cell)
138
+ for (let y = 0; y < height; y += 2) {
139
+ const cells: PixelCell[] = []
140
+
141
+ for (let x = 0; x < width; x++) {
142
+ const topColor = getPixelColor(image, x, y)
143
+ const bottomColor = y + 1 < height ? getPixelColor(image, x, y + 1) : topColor
144
+
145
+ cells.push({
146
+ char: "▀",
147
+ fg: topColor,
148
+ bg: bottomColor,
149
+ })
150
+ }
151
+
152
+ rows.push({ key: y, cells })
153
+ }
154
+
155
+ return rows
156
+ }
157
+
158
+ function getPixelColor(image: Awaited<ReturnType<typeof Jimp.read>>, x: number, y: number): string {
159
+ const pixel = image.getPixelColor(x, y)
160
+ // Jimp returns RGBA as a single integer: 0xRRGGBBAA
161
+ const r = (pixel >> 24) & 0xff
162
+ const g = (pixel >> 16) & 0xff
163
+ const b = (pixel >> 8) & 0xff
164
+ const a = pixel & 0xff
165
+
166
+ // Handle transparency by blending with a dark background
167
+ if (a < 255) {
168
+ const alpha = a / 255
169
+ const bgR = 0x1a
170
+ const bgG = 0x1b
171
+ const bgB = 0x26
172
+ const blendedR = Math.round(r * alpha + bgR * (1 - alpha))
173
+ const blendedG = Math.round(g * alpha + bgG * (1 - alpha))
174
+ const blendedB = Math.round(b * alpha + bgB * (1 - alpha))
175
+ return rgbToHex(blendedR, blendedG, blendedB)
176
+ }
177
+
178
+ return rgbToHex(r, g, b)
179
+ }
180
+
181
+ function rgbToHex(r: number, g: number, b: number): string {
182
+ return `#${r.toString(16).padStart(2, "0")}${g.toString(16).padStart(2, "0")}${b.toString(16).padStart(2, "0")}`
183
+ }
@@ -0,0 +1,379 @@
1
+ import { marked, type Token, type Tokens } from "marked"
2
+ import type { ReactNode } from "react"
3
+ import { useTheme, type ResolvedTheme } from "@tooee/themes"
4
+ import type { SyntaxStyle } from "@opentui/core"
5
+ import { Table } from "./Table.jsx"
6
+
7
+ interface MarkdownViewProps {
8
+ content: string
9
+ activeBlock?: number
10
+ selectedBlocks?: { start: number; end: number }
11
+ matchingBlocks?: Set<number>
12
+ currentMatchBlock?: number
13
+ toggledBlocks?: Set<number>
14
+ }
15
+
16
+ export function MarkdownView({
17
+ content,
18
+ activeBlock,
19
+ selectedBlocks,
20
+ matchingBlocks,
21
+ currentMatchBlock,
22
+ toggledBlocks,
23
+ }: MarkdownViewProps) {
24
+ const { theme, syntax } = useTheme()
25
+ const tokens = marked.lexer(content)
26
+ const blocks = tokens.filter((t) => t.type !== "space")
27
+
28
+ return (
29
+ <box style={{ flexDirection: "column" }}>
30
+ {blocks.map((token, index) => {
31
+ const { accent: accentColor, background: bgColor } = getBlockStyle(
32
+ index,
33
+ theme,
34
+ activeBlock,
35
+ selectedBlocks,
36
+ matchingBlocks,
37
+ currentMatchBlock,
38
+ toggledBlocks,
39
+ )
40
+ const blockContent = (
41
+ <TokenRenderer key={index} token={token} theme={theme} syntax={syntax} />
42
+ )
43
+
44
+ if (accentColor) {
45
+ return (
46
+ <box
47
+ key={index}
48
+ style={{ flexDirection: "row" }}
49
+ backgroundColor={bgColor ?? undefined}
50
+ >
51
+ <text content="▎" fg={accentColor} />
52
+ <box style={{ flexGrow: 1, flexDirection: "column" }}>{blockContent}</box>
53
+ </box>
54
+ )
55
+ }
56
+
57
+ return <box key={index}>{blockContent}</box>
58
+ })}
59
+ </box>
60
+ )
61
+ }
62
+
63
+ function getBlockStyle(
64
+ index: number,
65
+ theme: ResolvedTheme,
66
+ activeBlock?: number,
67
+ selectedBlocks?: { start: number; end: number },
68
+ matchingBlocks?: Set<number>,
69
+ currentMatchBlock?: number,
70
+ toggledBlocks?: Set<number>,
71
+ ): { accent: string | null; background: string | null } {
72
+ // Priority: cursor > toggled > current match > match > selection
73
+ if (activeBlock === index) return { accent: theme.primary, background: theme.backgroundElement }
74
+ if (toggledBlocks?.has(index)) return { accent: theme.secondary, background: theme.backgroundPanel }
75
+ if (currentMatchBlock === index) return { accent: theme.accent, background: null }
76
+ if (matchingBlocks?.has(index)) return { accent: theme.warning, background: null }
77
+ if (selectedBlocks && index >= selectedBlocks.start && index <= selectedBlocks.end) {
78
+ return { accent: theme.secondary, background: theme.backgroundPanel }
79
+ }
80
+ return { accent: null, background: null }
81
+ }
82
+
83
+ function TokenRenderer({
84
+ token,
85
+ theme,
86
+ syntax,
87
+ }: {
88
+ token: Token
89
+ theme: ResolvedTheme
90
+ syntax: SyntaxStyle
91
+ }): ReactNode {
92
+ switch (token.type) {
93
+ case "heading":
94
+ return <HeadingRenderer token={token as Tokens.Heading} theme={theme} />
95
+ case "paragraph":
96
+ return <ParagraphRenderer token={token as Tokens.Paragraph} theme={theme} />
97
+ case "code":
98
+ return <CodeBlockRenderer token={token as Tokens.Code} theme={theme} syntax={syntax} />
99
+ case "blockquote":
100
+ return <BlockquoteRenderer token={token as Tokens.Blockquote} theme={theme} />
101
+ case "list":
102
+ return <ListRenderer token={token as Tokens.List} theme={theme} />
103
+ case "table":
104
+ return <MarkdownTableRenderer token={token as Tokens.Table} />
105
+ case "hr":
106
+ return <HorizontalRule theme={theme} />
107
+ case "space":
108
+ return null
109
+ case "html":
110
+ return null
111
+ default:
112
+ if ("text" in token && typeof token.text === "string") {
113
+ return (
114
+ <text
115
+ content={token.text}
116
+ style={{
117
+ fg: theme.markdownText,
118
+ marginBottom: 1,
119
+ marginTop: 0,
120
+ marginLeft: 1,
121
+ marginRight: 1,
122
+ }}
123
+ />
124
+ )
125
+ }
126
+ return null
127
+ }
128
+ }
129
+
130
+ function HeadingRenderer({ token, theme }: { token: Tokens.Heading; theme: ResolvedTheme }) {
131
+ const headingColors: Record<number, string> = {
132
+ 1: theme.markdownHeading,
133
+ 2: theme.secondary,
134
+ 3: theme.accent,
135
+ 4: theme.text,
136
+ 5: theme.textMuted,
137
+ 6: theme.textMuted,
138
+ }
139
+
140
+ const prefixes: Record<number, string> = {
141
+ 1: "# ",
142
+ 2: "## ",
143
+ 3: "### ",
144
+ 4: "#### ",
145
+ 5: "##### ",
146
+ 6: "###### ",
147
+ }
148
+
149
+ const headingText = getPlainText(token.tokens || [])
150
+
151
+ return (
152
+ <box style={{ marginTop: 1, marginBottom: 1 }}>
153
+ <text style={{ fg: headingColors[token.depth] || theme.text }}>
154
+ <span fg={theme.textMuted}>{prefixes[token.depth]}</span>
155
+ <strong>{headingText}</strong>
156
+ </text>
157
+ </box>
158
+ )
159
+ }
160
+
161
+ function ParagraphRenderer({ token, theme }: { token: Tokens.Paragraph; theme: ResolvedTheme }) {
162
+ return (
163
+ <box style={{ marginBottom: 1, marginLeft: 1, marginRight: 1 }}>
164
+ <text style={{ fg: theme.markdownText }}>
165
+ <InlineTokens tokens={token.tokens || []} theme={theme} />
166
+ </text>
167
+ </box>
168
+ )
169
+ }
170
+
171
+ function CodeBlockRenderer({
172
+ token,
173
+ theme,
174
+ syntax,
175
+ }: {
176
+ token: Tokens.Code
177
+ theme: ResolvedTheme
178
+ syntax: SyntaxStyle
179
+ }) {
180
+ return (
181
+ <box
182
+ style={{
183
+ marginTop: 0,
184
+ marginBottom: 1,
185
+ marginLeft: 1,
186
+ marginRight: 1,
187
+ border: true,
188
+ borderColor: theme.border,
189
+ backgroundColor: theme.backgroundElement,
190
+ flexDirection: "column",
191
+ }}
192
+ >
193
+ <code content={token.text} filetype={token.lang} syntaxStyle={syntax} />
194
+ </box>
195
+ )
196
+ }
197
+
198
+ function BlockquoteRenderer({ token, theme }: { token: Tokens.Blockquote; theme: ResolvedTheme }) {
199
+ const quoteText = token.tokens
200
+ ? token.tokens
201
+ .map((t) => {
202
+ const innerTokens = "tokens" in t ? (t as { tokens?: Token[] }).tokens : undefined
203
+ const textContent = "text" in t ? (t as { text?: string }).text : ""
204
+ return getPlainText(innerTokens || []) || textContent || ""
205
+ })
206
+ .join("\n")
207
+ : ""
208
+
209
+ return (
210
+ <box style={{ marginTop: 0, marginBottom: 1, marginLeft: 1, marginRight: 1, paddingLeft: 2 }}>
211
+ <text style={{ fg: theme.markdownBlockQuote }} content="│ " />
212
+ <text style={{ fg: theme.textMuted }} content={quoteText} />
213
+ </box>
214
+ )
215
+ }
216
+
217
+ function ListRenderer({ token, theme }: { token: Tokens.List; theme: ResolvedTheme }) {
218
+ return (
219
+ <box style={{ marginBottom: 1, marginLeft: 3, marginRight: 1, flexDirection: "column" }}>
220
+ {token.items.map((item, index) => (
221
+ <ListItemRenderer
222
+ key={index}
223
+ item={item}
224
+ ordered={token.ordered}
225
+ index={index + (token.start || 1)}
226
+ theme={theme}
227
+ />
228
+ ))}
229
+ </box>
230
+ )
231
+ }
232
+
233
+ function ListItemRenderer({
234
+ item,
235
+ ordered,
236
+ index,
237
+ theme,
238
+ }: {
239
+ item: Tokens.ListItem
240
+ ordered: boolean
241
+ index: number
242
+ theme: ResolvedTheme
243
+ }) {
244
+ const bullet = ordered ? `${index}. ` : "- "
245
+ const itemContent = item.tokens || []
246
+
247
+ return (
248
+ <box style={{ flexDirection: "row" }}>
249
+ <text style={{ fg: theme.markdownListItem }} content={bullet} />
250
+ <box style={{ flexShrink: 1, flexDirection: "column" }}>
251
+ {itemContent.map((token, idx) => {
252
+ if (token.type === "text" && "tokens" in token && token.tokens) {
253
+ return (
254
+ <text key={idx} style={{ fg: theme.markdownText }}>
255
+ <InlineTokens tokens={token.tokens} theme={theme} />
256
+ </text>
257
+ )
258
+ }
259
+ if (token.type === "paragraph" && token.tokens) {
260
+ return (
261
+ <text key={idx} style={{ fg: theme.markdownText }}>
262
+ <InlineTokens tokens={token.tokens} theme={theme} />
263
+ </text>
264
+ )
265
+ }
266
+ if ("text" in token && typeof token.text === "string") {
267
+ return <text key={idx} style={{ fg: theme.markdownText }} content={token.text} />
268
+ }
269
+ return null
270
+ })}
271
+ </box>
272
+ </box>
273
+ )
274
+ }
275
+
276
+ function MarkdownTableRenderer({ token }: { token: Tokens.Table }) {
277
+ const seen = new Map<string, number>()
278
+ const columns = token.header.map((cell, index) => {
279
+ const header = getPlainText(cell.tokens)
280
+ const trimmed = header.trim()
281
+ const base = trimmed || `column_${index + 1}`
282
+ const count = seen.get(base) ?? 0
283
+ seen.set(base, count + 1)
284
+ const key = count === 0 ? base : `${base}_${count + 1}`
285
+ return {
286
+ key,
287
+ header: trimmed || undefined,
288
+ }
289
+ })
290
+ const rows = token.rows.map((row) => {
291
+ const record: Record<string, string> = {}
292
+ row.forEach((cell, index) => {
293
+ const column = columns[index]
294
+ if (column) {
295
+ record[column.key] = getPlainText(cell.tokens)
296
+ }
297
+ })
298
+ return record
299
+ })
300
+ return <Table columns={columns} rows={rows} />
301
+ }
302
+
303
+ function HorizontalRule({ theme }: { theme: ResolvedTheme }) {
304
+ return (
305
+ <box style={{ marginTop: 0, marginBottom: 1, marginLeft: 1, marginRight: 1 }}>
306
+ <text style={{ fg: theme.markdownHorizontalRule }} content={"─".repeat(40)} />
307
+ </box>
308
+ )
309
+ }
310
+
311
+ function getPlainText(tokens: Token[]): string {
312
+ return tokens
313
+ .map((token) => {
314
+ if (token.type === "text") return token.text
315
+ if (token.type === "codespan") return (token as Tokens.Codespan).text
316
+ if ("tokens" in token && token.tokens) return getPlainText(token.tokens as Token[])
317
+ if ("text" in token) return (token as { text: string }).text
318
+ return ""
319
+ })
320
+ .join("")
321
+ }
322
+
323
+ function InlineTokens({ tokens, theme }: { tokens: Token[]; theme: ResolvedTheme }): ReactNode {
324
+ const result: ReactNode[] = []
325
+
326
+ for (let i = 0; i < tokens.length; i++) {
327
+ const token = tokens[i]
328
+ if (!token) continue
329
+ const key = i
330
+
331
+ switch (token.type) {
332
+ case "text":
333
+ result.push((token as Tokens.Text).text)
334
+ break
335
+ case "strong":
336
+ result.push(
337
+ <strong key={key}>{getPlainText((token as Tokens.Strong).tokens || [])}</strong>,
338
+ )
339
+ break
340
+ case "em":
341
+ result.push(<em key={key}>{getPlainText((token as Tokens.Em).tokens || [])}</em>)
342
+ break
343
+ case "codespan":
344
+ result.push(
345
+ <span key={key} fg={theme.markdownCode} bg={theme.backgroundPanel}>
346
+ {` ${(token as Tokens.Codespan).text} `}
347
+ </span>,
348
+ )
349
+ break
350
+ case "link": {
351
+ const linkToken = token as Tokens.Link
352
+ result.push(
353
+ <u key={key}>
354
+ <a href={linkToken.href} fg={theme.markdownLink}>
355
+ {getPlainText(linkToken.tokens || [])}
356
+ </a>
357
+ </u>,
358
+ )
359
+ break
360
+ }
361
+ case "br":
362
+ result.push("\n")
363
+ break
364
+ case "escape":
365
+ result.push((token as Tokens.Escape).text)
366
+ break
367
+ case "space":
368
+ result.push(" ")
369
+ break
370
+ default:
371
+ if ("text" in token && typeof (token as { text?: string }).text === "string") {
372
+ result.push((token as { text: string }).text)
373
+ }
374
+ break
375
+ }
376
+ }
377
+
378
+ return <>{result}</>
379
+ }