@silvery/ui 0.3.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.
Files changed (87) hide show
  1. package/package.json +71 -0
  2. package/src/animation/easing.ts +38 -0
  3. package/src/animation/index.ts +18 -0
  4. package/src/animation/useAnimation.ts +143 -0
  5. package/src/animation/useInterval.ts +39 -0
  6. package/src/animation/useLatest.ts +35 -0
  7. package/src/animation/useTimeout.ts +65 -0
  8. package/src/animation/useTransition.ts +110 -0
  9. package/src/animation.ts +24 -0
  10. package/src/ansi/index.ts +43 -0
  11. package/src/canvas/index.ts +169 -0
  12. package/src/cli/ansi.ts +85 -0
  13. package/src/cli/index.ts +39 -0
  14. package/src/cli/multi-progress.ts +340 -0
  15. package/src/cli/progress-bar.ts +222 -0
  16. package/src/cli/spinner.ts +275 -0
  17. package/src/components/Badge.tsx +54 -0
  18. package/src/components/Breadcrumb.tsx +72 -0
  19. package/src/components/Button.tsx +73 -0
  20. package/src/components/CommandPalette.tsx +186 -0
  21. package/src/components/Console.tsx +79 -0
  22. package/src/components/CursorLine.tsx +71 -0
  23. package/src/components/Divider.tsx +67 -0
  24. package/src/components/EditContextDisplay.tsx +164 -0
  25. package/src/components/ErrorBoundary.tsx +179 -0
  26. package/src/components/Form.tsx +86 -0
  27. package/src/components/GridCell.tsx +42 -0
  28. package/src/components/HorizontalVirtualList.tsx +375 -0
  29. package/src/components/ModalDialog.tsx +179 -0
  30. package/src/components/PickerDialog.tsx +208 -0
  31. package/src/components/PickerList.tsx +93 -0
  32. package/src/components/ProgressBar.tsx +126 -0
  33. package/src/components/Screen.tsx +78 -0
  34. package/src/components/ScrollbackList.tsx +92 -0
  35. package/src/components/ScrollbackView.tsx +390 -0
  36. package/src/components/SelectList.tsx +176 -0
  37. package/src/components/Skeleton.tsx +87 -0
  38. package/src/components/Spinner.tsx +64 -0
  39. package/src/components/SplitView.tsx +199 -0
  40. package/src/components/Table.tsx +139 -0
  41. package/src/components/Tabs.tsx +203 -0
  42. package/src/components/TextArea.tsx +264 -0
  43. package/src/components/TextInput.tsx +240 -0
  44. package/src/components/Toast.tsx +216 -0
  45. package/src/components/Toggle.tsx +73 -0
  46. package/src/components/Tooltip.tsx +60 -0
  47. package/src/components/TreeView.tsx +212 -0
  48. package/src/components/Typography.tsx +233 -0
  49. package/src/components/VirtualList.tsx +318 -0
  50. package/src/components/VirtualView.tsx +221 -0
  51. package/src/components/useReadline.ts +213 -0
  52. package/src/components/useTextArea.ts +648 -0
  53. package/src/components.ts +133 -0
  54. package/src/display/Table.tsx +179 -0
  55. package/src/display/index.ts +13 -0
  56. package/src/hooks/useTea.ts +133 -0
  57. package/src/image/Image.tsx +187 -0
  58. package/src/image/index.ts +15 -0
  59. package/src/image/kitty-graphics.ts +161 -0
  60. package/src/image/sixel-encoder.ts +194 -0
  61. package/src/images.ts +22 -0
  62. package/src/index.ts +34 -0
  63. package/src/input/Select.tsx +155 -0
  64. package/src/input/TextInput.tsx +227 -0
  65. package/src/input/index.ts +25 -0
  66. package/src/progress/als-context.ts +160 -0
  67. package/src/progress/declarative.ts +519 -0
  68. package/src/progress/index.ts +54 -0
  69. package/src/progress/step-node.ts +152 -0
  70. package/src/progress/steps.ts +425 -0
  71. package/src/progress/task.ts +138 -0
  72. package/src/progress/tasks.ts +216 -0
  73. package/src/react/ProgressBar.tsx +146 -0
  74. package/src/react/Spinner.tsx +74 -0
  75. package/src/react/Tasks.tsx +144 -0
  76. package/src/react/context.tsx +145 -0
  77. package/src/react/index.ts +30 -0
  78. package/src/types.ts +252 -0
  79. package/src/utils/eta.ts +155 -0
  80. package/src/utils/index.ts +13 -0
  81. package/src/wrappers/index.ts +36 -0
  82. package/src/wrappers/with-progress.ts +250 -0
  83. package/src/wrappers/with-select.ts +194 -0
  84. package/src/wrappers/with-spinner.ts +108 -0
  85. package/src/wrappers/with-text-input.ts +388 -0
  86. package/src/wrappers/wrap-emitter.ts +158 -0
  87. package/src/wrappers/wrap-generator.ts +143 -0
@@ -0,0 +1,186 @@
1
+ /**
2
+ * CommandPalette Component
3
+ *
4
+ * A filterable command list with keyboard navigation. Takes an array of
5
+ * commands with name, description, and optional shortcut. Users can type
6
+ * to filter and navigate with arrow keys / j/k.
7
+ *
8
+ * Usage:
9
+ * ```tsx
10
+ * const commands = [
11
+ * { name: "Save", description: "Save current file", shortcut: "Ctrl+S" },
12
+ * { name: "Quit", description: "Exit application", shortcut: "Ctrl+Q" },
13
+ * { name: "Help", description: "Show help" },
14
+ * ]
15
+ *
16
+ * <CommandPalette
17
+ * commands={commands}
18
+ * onSelect={(cmd) => exec(cmd.name)}
19
+ * placeholder="Type a command..."
20
+ * />
21
+ * ```
22
+ */
23
+ import React, { useCallback, useMemo, useState } from "react"
24
+ import { useInput } from "@silvery/react/hooks/useInput"
25
+ import { Box } from "@silvery/react/components/Box"
26
+ import { Text } from "@silvery/react/components/Text"
27
+
28
+ // =============================================================================
29
+ // Types
30
+ // =============================================================================
31
+
32
+ export interface CommandItem {
33
+ /** Command display name */
34
+ name: string
35
+ /** Command description */
36
+ description?: string
37
+ /** Keyboard shortcut hint */
38
+ shortcut?: string
39
+ }
40
+
41
+ export interface CommandPaletteProps {
42
+ /** Available commands */
43
+ commands: CommandItem[]
44
+ /** Called when a command is selected (Enter) */
45
+ onSelect?: (command: CommandItem) => void
46
+ /** Called when the palette is dismissed (Escape) */
47
+ onClose?: () => void
48
+ /** Placeholder text for the filter input (default: "Search commands...") */
49
+ placeholder?: string
50
+ /** Max visible results (default: 10) */
51
+ maxVisible?: number
52
+ /** Whether this component captures input (default: true) */
53
+ isActive?: boolean
54
+ }
55
+
56
+ // =============================================================================
57
+ // Helpers
58
+ // =============================================================================
59
+
60
+ /** Case-insensitive fuzzy match: all query characters appear in order. */
61
+ function fuzzyMatch(query: string, text: string): boolean {
62
+ const lower = text.toLowerCase()
63
+ const q = query.toLowerCase()
64
+ let qi = 0
65
+ for (let i = 0; i < lower.length && qi < q.length; i++) {
66
+ if (lower[i] === q[qi]) qi++
67
+ }
68
+ return qi === q.length
69
+ }
70
+
71
+ // =============================================================================
72
+ // Component
73
+ // =============================================================================
74
+
75
+ /**
76
+ * Filterable command palette with keyboard navigation.
77
+ *
78
+ * Type to filter commands by name, navigate with Up/Down or j/k,
79
+ * confirm with Enter, dismiss with Escape.
80
+ */
81
+ export function CommandPalette({
82
+ commands,
83
+ onSelect,
84
+ onClose,
85
+ placeholder = "Search commands...",
86
+ maxVisible = 10,
87
+ isActive = true,
88
+ }: CommandPaletteProps): React.ReactElement {
89
+ const [query, setQuery] = useState("")
90
+ const [selectedIndex, setSelectedIndex] = useState(0)
91
+
92
+ const filtered = useMemo(() => {
93
+ if (!query) return commands
94
+ return commands.filter(
95
+ (cmd) => fuzzyMatch(query, cmd.name) || (cmd.description && fuzzyMatch(query, cmd.description)),
96
+ )
97
+ }, [commands, query])
98
+
99
+ const visible = filtered.slice(0, maxVisible)
100
+
101
+ const clampIndex = useCallback((idx: number) => Math.max(0, Math.min(idx, filtered.length - 1)), [filtered.length])
102
+
103
+ useInput(
104
+ (input, key) => {
105
+ // Navigation
106
+ if (key.upArrow) {
107
+ setSelectedIndex((prev) => clampIndex(prev - 1))
108
+ return
109
+ }
110
+ if (key.downArrow) {
111
+ setSelectedIndex((prev) => clampIndex(prev + 1))
112
+ return
113
+ }
114
+
115
+ // Select
116
+ if (key.return) {
117
+ const cmd = filtered[selectedIndex]
118
+ if (cmd) onSelect?.(cmd)
119
+ return
120
+ }
121
+
122
+ // Dismiss
123
+ if (key.escape) {
124
+ onClose?.()
125
+ return
126
+ }
127
+
128
+ // Backspace
129
+ if (key.backspace || key.delete) {
130
+ setQuery((prev) => {
131
+ const next = prev.slice(0, -1)
132
+ setSelectedIndex(0)
133
+ return next
134
+ })
135
+ return
136
+ }
137
+
138
+ // Printable character
139
+ if (input && input >= " " && !key.ctrl && !key.meta) {
140
+ setQuery((prev) => {
141
+ setSelectedIndex(0)
142
+ return prev + input
143
+ })
144
+ }
145
+ },
146
+ { isActive },
147
+ )
148
+
149
+ return (
150
+ <Box flexDirection="column" borderStyle="single" borderColor="$border" backgroundColor="$surface-bg" paddingX={1}>
151
+ {/* Search input */}
152
+ <Box>
153
+ <Text color="$primary" bold>
154
+ {">"}{" "}
155
+ </Text>
156
+ <Text>{query || <Text color="$disabledfg">{placeholder}</Text>}</Text>
157
+ </Box>
158
+ <Box>
159
+ <Text color="$border">{"─".repeat(30)}</Text>
160
+ </Box>
161
+ {/* Results */}
162
+ {visible.length === 0 ? (
163
+ <Text color="$disabledfg">No matching commands</Text>
164
+ ) : (
165
+ visible.map((cmd, i) => {
166
+ const isSelected = i === selectedIndex
167
+ return (
168
+ <Box key={cmd.name} gap={1}>
169
+ <Text inverse={isSelected} color={isSelected ? "$primary" : "$fg"}>
170
+ {isSelected ? ">" : " "} {cmd.name}
171
+ </Text>
172
+ {cmd.description && <Text color="$muted">{cmd.description}</Text>}
173
+ {cmd.shortcut && (
174
+ <Text color="$disabledfg" bold>
175
+ {cmd.shortcut}
176
+ </Text>
177
+ )}
178
+ </Box>
179
+ )
180
+ })
181
+ )}
182
+ {/* Status */}
183
+ {filtered.length > maxVisible && <Text color="$disabledfg">{filtered.length - maxVisible} more...</Text>}
184
+ </Box>
185
+ )
186
+ }
@@ -0,0 +1,79 @@
1
+ import type { ConsoleEntry, PatchedConsole } from "@silvery/term/ansi"
2
+ import type { ReactElement, ReactNode } from "react"
3
+ import { useConsole } from "@silvery/react/hooks/useConsole"
4
+ import { Box } from "@silvery/react/components/Box"
5
+ import { Text } from "@silvery/react/components/Text"
6
+
7
+ interface ConsoleProps {
8
+ /** The patched console to render entries from */
9
+ console: PatchedConsole
10
+
11
+ /** Optional render function for custom entry rendering */
12
+ children?: (entry: ConsoleEntry, index: number) => ReactNode
13
+ }
14
+
15
+ /**
16
+ * Format console entry args into a string.
17
+ * Joins args with spaces, handling objects via JSON.stringify.
18
+ */
19
+ function formatArgs(args: unknown[]): string {
20
+ return args
21
+ .map((arg) => {
22
+ if (typeof arg === "string") return arg
23
+ if (typeof arg === "number" || typeof arg === "boolean") {
24
+ return String(arg)
25
+ }
26
+ if (arg === null) return "null"
27
+ if (arg === undefined) return "undefined"
28
+ try {
29
+ return JSON.stringify(arg)
30
+ } catch {
31
+ return String(arg)
32
+ }
33
+ })
34
+ .join(" ")
35
+ }
36
+
37
+ /**
38
+ * Renders captured console output from a PatchedConsole.
39
+ *
40
+ * Uses useConsole hook to subscribe to entries and re-renders when new
41
+ * entries arrive. Supports custom rendering via children render prop.
42
+ *
43
+ * @example Default rendering
44
+ * ```tsx
45
+ * import { Console } from '@silvery/react'
46
+ * import { patchConsole } from '@silvery/chalk'
47
+ *
48
+ * using patched = patchConsole(console)
49
+ * <Console console={patched} />
50
+ * ```
51
+ *
52
+ * @example Custom rendering
53
+ * ```tsx
54
+ * <Console console={patched}>
55
+ * {(entry, i) => (
56
+ * <Text key={i} color={entry.stream === 'stderr' ? 'yellow' : 'green'}>
57
+ * [{entry.method}] {entry.args.join(' ')}
58
+ * </Text>
59
+ * )}
60
+ * </Console>
61
+ * ```
62
+ */
63
+ export function Console({ console: patched, children }: ConsoleProps): ReactElement {
64
+ const entries = useConsole(patched)
65
+
66
+ return (
67
+ <Box flexDirection="column">
68
+ {entries.map((entry, i) =>
69
+ children ? (
70
+ children(entry, i)
71
+ ) : (
72
+ <Text key={i} color={entry.stream === "stderr" ? "red" : undefined}>
73
+ {formatArgs(entry.args)}
74
+ </Text>
75
+ ),
76
+ )}
77
+ </Box>
78
+ )
79
+ }
@@ -0,0 +1,71 @@
1
+ /**
2
+ * CursorLine Component
3
+ *
4
+ * Renders a single line of text with a visible cursor at a split point.
5
+ * Extracts the duplicated cursor-rendering pattern found across km-tui
6
+ * (inline edit, input box, search bar, etc.) into a reusable primitive.
7
+ *
8
+ * Usage:
9
+ * ```tsx
10
+ * <CursorLine beforeCursor="hel" afterCursor="lo world" />
11
+ * <CursorLine beforeCursor="full text" afterCursor="" />
12
+ * <CursorLine beforeCursor="" afterCursor="start" cursorStyle="underline" />
13
+ * ```
14
+ */
15
+ import React from "react"
16
+ import { Text } from "@silvery/react/components/Text"
17
+
18
+ // =============================================================================
19
+ // Types
20
+ // =============================================================================
21
+
22
+ export interface CursorLineProps {
23
+ /** Text before the cursor position */
24
+ beforeCursor: string
25
+ /** Text after the cursor position (first char gets cursor highlight) */
26
+ afterCursor: string
27
+ /** Text color */
28
+ color?: string
29
+ /** Whether to show the cursor (default: true) */
30
+ showCursor?: boolean
31
+ /** Cursor style: 'block' (inverse) or 'underline' (default: block) */
32
+ cursorStyle?: "block" | "underline"
33
+ }
34
+
35
+ // =============================================================================
36
+ // Component
37
+ // =============================================================================
38
+
39
+ /**
40
+ * Renders a single line with a visible cursor character.
41
+ *
42
+ * The cursor character is `afterCursor[0]` (or a space when afterCursor is
43
+ * empty, indicating the cursor is at the end of the text). The character is
44
+ * rendered with inverse video (block) or underline styling.
45
+ */
46
+ export function CursorLine({
47
+ beforeCursor,
48
+ afterCursor,
49
+ color,
50
+ showCursor = true,
51
+ cursorStyle = "block",
52
+ }: CursorLineProps): React.ReactElement {
53
+ if (!showCursor)
54
+ return (
55
+ <Text color={color}>
56
+ {beforeCursor}
57
+ {afterCursor}
58
+ </Text>
59
+ )
60
+
61
+ const cursorChar = afterCursor[0] ?? " "
62
+ const rest = afterCursor.slice(1)
63
+
64
+ return (
65
+ <Text color={color}>
66
+ {beforeCursor}
67
+ {cursorStyle === "block" ? <Text inverse>{cursorChar}</Text> : <Text underline>{cursorChar}</Text>}
68
+ {rest}
69
+ </Text>
70
+ )
71
+ }
@@ -0,0 +1,67 @@
1
+ /**
2
+ * Divider Component
3
+ *
4
+ * A horizontal separator line with optional centered title.
5
+ *
6
+ * Usage:
7
+ * ```tsx
8
+ * <Divider />
9
+ * <Divider title="Section" />
10
+ * <Divider char="=" width={40} />
11
+ * ```
12
+ */
13
+ import React from "react"
14
+ import { useContentRect } from "@silvery/react/hooks/useLayout"
15
+ import { Box } from "@silvery/react/components/Box"
16
+ import { Text } from "@silvery/react/components/Text"
17
+
18
+ // =============================================================================
19
+ // Types
20
+ // =============================================================================
21
+
22
+ export interface DividerProps {
23
+ /** Character to repeat (default: "─") */
24
+ char?: string
25
+ /** Title text centered in divider */
26
+ title?: string
27
+ /** Width (default: 100% via useContentRect) */
28
+ width?: number
29
+ }
30
+
31
+ // =============================================================================
32
+ // Constants
33
+ // =============================================================================
34
+
35
+ const DEFAULT_CHAR = "─"
36
+ const DEFAULT_WIDTH = 40
37
+
38
+ // =============================================================================
39
+ // Component
40
+ // =============================================================================
41
+
42
+ export function Divider({ char = DEFAULT_CHAR, title, width: widthProp }: DividerProps): React.ReactElement {
43
+ const { width: contentWidth } = useContentRect()
44
+ const totalWidth = widthProp ?? (contentWidth > 0 ? contentWidth : DEFAULT_WIDTH)
45
+
46
+ if (!title) {
47
+ return (
48
+ <Box>
49
+ <Text dimColor>{char.repeat(totalWidth)}</Text>
50
+ </Box>
51
+ )
52
+ }
53
+
54
+ // Title with surrounding lines: "───── Title ─────"
55
+ const titleWithPad = ` ${title} `
56
+ const remaining = Math.max(0, totalWidth - titleWithPad.length)
57
+ const leftLen = Math.floor(remaining / 2)
58
+ const rightLen = remaining - leftLen
59
+
60
+ return (
61
+ <Box>
62
+ <Text dimColor>{char.repeat(leftLen)}</Text>
63
+ <Text bold>{titleWithPad}</Text>
64
+ <Text dimColor>{char.repeat(rightLen)}</Text>
65
+ </Box>
66
+ )
67
+ }
@@ -0,0 +1,164 @@
1
+ /**
2
+ * EditContextDisplay Component
3
+ *
4
+ * Pure rendering component for multi-line text display with scrolling.
5
+ * Consumes the output of useEditContext (value + cursor position) and
6
+ * handles word wrapping, viewport scrolling, and cursor highlighting.
7
+ *
8
+ * Unlike TextArea, this component has NO input handling — the command
9
+ * system handles all input via useEditContext's EditTarget. This is the
10
+ * rendering half of the edit context pattern.
11
+ *
12
+ * Usage:
13
+ * ```tsx
14
+ * const { value, cursor } = useEditContext({ ... })
15
+ * const { width } = useContentRect()
16
+ *
17
+ * <EditContextDisplay
18
+ * value={value}
19
+ * cursor={cursor}
20
+ * height={10}
21
+ * wrapWidth={width}
22
+ * />
23
+ * ```
24
+ *
25
+ * Scroll logic extracted from TextArea.tsx — same clampScroll pattern
26
+ * that keeps cursor visible within the viewport.
27
+ */
28
+ import React, { useMemo, useRef } from "react"
29
+ import { cursorToRowCol, getWrappedLines } from "@silvery/tea/text-cursor"
30
+ import { Box } from "@silvery/react/components/Box"
31
+ import { Text } from "@silvery/react/components/Text"
32
+
33
+ // =============================================================================
34
+ // Types
35
+ // =============================================================================
36
+
37
+ export interface EditContextDisplayProps {
38
+ /** Current text value (from useEditContext) */
39
+ value: string
40
+ /** Cursor position as character offset (from useEditContext) */
41
+ cursor: number
42
+ /** Visible height in rows. When omitted, renders all lines (no scrolling). */
43
+ height?: number
44
+ /** Width for word wrapping. When omitted, renders without wrapping. */
45
+ wrapWidth?: number
46
+ /** Cursor style: 'block' (inverse) or 'underline' */
47
+ cursorStyle?: "block" | "underline"
48
+ /** Placeholder text when value is empty */
49
+ placeholder?: string
50
+ /** Whether to show the cursor (default: true) */
51
+ showCursor?: boolean
52
+ }
53
+
54
+ // =============================================================================
55
+ // Helpers
56
+ // =============================================================================
57
+
58
+ /** Ensure scroll offset keeps the cursor row visible within the viewport. */
59
+ function clampScroll(cursorRow: number, currentScroll: number, viewportHeight: number): number {
60
+ if (viewportHeight <= 0) return 0
61
+ let scroll = currentScroll
62
+ if (cursorRow < scroll) {
63
+ scroll = cursorRow
64
+ }
65
+ if (cursorRow >= scroll + viewportHeight) {
66
+ scroll = cursorRow - viewportHeight + 1
67
+ }
68
+ return Math.max(0, scroll)
69
+ }
70
+
71
+ // =============================================================================
72
+ // Component
73
+ // =============================================================================
74
+
75
+ export function EditContextDisplay({
76
+ value,
77
+ cursor,
78
+ height,
79
+ wrapWidth,
80
+ cursorStyle = "block",
81
+ placeholder = "",
82
+ showCursor = true,
83
+ }: EditContextDisplayProps): React.ReactElement {
84
+ // Scroll offset persists across renders via ref. No useState needed because
85
+ // every cursor/value change triggers a re-render from the parent (props change),
86
+ // and we compute the new scroll synchronously during that render.
87
+ const scrollRef = useRef(0)
88
+
89
+ // Effective wrap width: use provided wrapWidth, or a large value for no wrapping
90
+ const effectiveWrapWidth = wrapWidth != null && wrapWidth > 0 ? wrapWidth : 10000
91
+
92
+ // Clamp cursor to valid range
93
+ const clampedCursor = Math.min(Math.max(0, cursor), value.length)
94
+
95
+ // Compute wrapped lines and cursor position
96
+ const wrappedLines = useMemo(() => getWrappedLines(value, effectiveWrapWidth), [value, effectiveWrapWidth])
97
+
98
+ const { row: cursorRow, col: cursorCol } = useMemo(
99
+ () => cursorToRowCol(value, clampedCursor, effectiveWrapWidth),
100
+ [value, clampedCursor, effectiveWrapWidth],
101
+ )
102
+
103
+ // Update scroll offset to keep cursor visible (ref-only, no state)
104
+ const hasViewport = height != null && height > 0
105
+ if (hasViewport) {
106
+ scrollRef.current = clampScroll(cursorRow, scrollRef.current, height)
107
+ }
108
+
109
+ // =========================================================================
110
+ // Placeholder
111
+ // =========================================================================
112
+
113
+ if (!value && placeholder) {
114
+ if (hasViewport) {
115
+ return (
116
+ <Box flexDirection="column" height={height} justifyContent="center" alignItems="center">
117
+ <Text dimColor>{placeholder}</Text>
118
+ </Box>
119
+ )
120
+ }
121
+ return (
122
+ <Box flexDirection="column">
123
+ <Text dimColor>{placeholder}</Text>
124
+ </Box>
125
+ )
126
+ }
127
+
128
+ // =========================================================================
129
+ // Determine visible lines
130
+ // =========================================================================
131
+
132
+ const currentScroll = hasViewport ? scrollRef.current : 0
133
+ const visibleLines = hasViewport ? wrappedLines.slice(currentScroll, currentScroll + height) : wrappedLines
134
+
135
+ // =========================================================================
136
+ // Render
137
+ // =========================================================================
138
+
139
+ return (
140
+ <Box key={currentScroll} flexDirection="column" height={hasViewport ? height : undefined}>
141
+ {visibleLines.map((wl, i) => {
142
+ const absoluteRow = currentScroll + i
143
+ const isCursorRow = absoluteRow === cursorRow && showCursor
144
+
145
+ if (!isCursorRow) {
146
+ return <Text key={absoluteRow}>{wl.line || " "}</Text>
147
+ }
148
+
149
+ // Render line with cursor highlight
150
+ const beforeCursorText = wl.line.slice(0, cursorCol)
151
+ const atCursor = wl.line[cursorCol] ?? " "
152
+ const afterCursorText = wl.line.slice(cursorCol + 1)
153
+
154
+ return (
155
+ <Text key={absoluteRow}>
156
+ {beforeCursorText}
157
+ {cursorStyle === "block" ? <Text inverse>{atCursor}</Text> : <Text underline>{atCursor}</Text>}
158
+ {afterCursorText}
159
+ </Text>
160
+ )
161
+ })}
162
+ </Box>
163
+ )
164
+ }