@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,179 @@
1
+ /**
2
+ * ModalDialog Component
3
+ *
4
+ * Reusable modal dialog with consistent styling: double border, title bar,
5
+ * optional footer, and solid background that covers board content.
6
+ *
7
+ * Moved from km-tui shared-components to silvery for reuse across apps.
8
+ *
9
+ * Usage:
10
+ * ```tsx
11
+ * <ModalDialog title="Settings" width={60} footer="ESC to close">
12
+ * <Text>Dialog content here</Text>
13
+ * </ModalDialog>
14
+ *
15
+ * <ModalDialog title="Help" hotkey="?" titleRight={<Text>1/3</Text>}>
16
+ * <Text>Help content</Text>
17
+ * </ModalDialog>
18
+ * ```
19
+ */
20
+ import React from "react"
21
+ import { Box } from "@silvery/react/components/Box"
22
+ import { Text } from "@silvery/react/components/Text"
23
+
24
+ // =============================================================================
25
+ // Types
26
+ // =============================================================================
27
+
28
+ export interface ModalDialogProps {
29
+ /** Border color (default: $border). Cyan is reserved for text input focus rings. */
30
+ borderColor?: string
31
+ /** Dialog title (rendered bold in titleColor or borderColor) */
32
+ title?: string
33
+ /** Title color override (default: $primary). Separate from border for independent styling. */
34
+ titleColor?: string
35
+ /** Title alignment (default: center) */
36
+ titleAlign?: "center" | "flex-start" | "flex-end"
37
+ /** Toggle hotkey character (e.g., "?" for help). Renders [X] prefix in title. */
38
+ hotkey?: string
39
+ /** Content to render on the right side of the title bar (e.g., hotkey indicator, match count) */
40
+ titleRight?: React.ReactNode
41
+ /** Dialog width */
42
+ width?: number
43
+ /** Dialog height (optional, omit for auto-height) */
44
+ height?: number
45
+ /** Footer hint text (rendered dimColor at bottom) */
46
+ footer?: React.ReactNode
47
+ /** Footer alignment (default: center) */
48
+ footerAlign?: "center" | "flex-start" | "flex-end"
49
+ /** Called when ESC is pressed (optional convenience handler) */
50
+ onClose?: () => void
51
+ /** Whether to create a focus scope (default: true, for future focus system integration) */
52
+ focusScope?: boolean
53
+ /** Dialog children */
54
+ children: React.ReactNode
55
+ }
56
+
57
+ // =============================================================================
58
+ // Helpers
59
+ // =============================================================================
60
+
61
+ /**
62
+ * Format a dialog title with a hotkey prefix.
63
+ *
64
+ * If the hotkey letter appears in the title (case-insensitive), highlights it inline:
65
+ * hotkey="D", title="Details" -> [D]etails
66
+ * If the hotkey is not found in the title, prepends it:
67
+ * hotkey="?", title="Help" -> [?] Help
68
+ *
69
+ * Brackets are dim, the hotkey letter is bold/bright.
70
+ */
71
+ export function formatTitleWithHotkey(title: string, hotkey: string, color?: string): React.ReactElement {
72
+ const idx = title.toLowerCase().indexOf(hotkey.toLowerCase())
73
+ if (idx >= 0 && hotkey.length === 1 && hotkey.toLowerCase() !== hotkey.toUpperCase()) {
74
+ // Letter found in title — highlight it inline: prefix + [X] + rest
75
+ const before = title.slice(0, idx)
76
+ const matched = title[idx]
77
+ const after = title.slice(idx + 1)
78
+ return (
79
+ <Text color={color} bold>
80
+ {before}
81
+ <Text dimColor bold={false}>
82
+ [
83
+ </Text>
84
+ <Text bold>{matched}</Text>
85
+ <Text dimColor bold={false}>
86
+ ]
87
+ </Text>
88
+ {after}
89
+ </Text>
90
+ )
91
+ }
92
+ // Hotkey not in title (or symbol) — prepend [X] Title
93
+ return (
94
+ <Text color={color} bold>
95
+ <Text dimColor bold={false}>
96
+ [
97
+ </Text>
98
+ <Text bold>{hotkey}</Text>
99
+ <Text dimColor bold={false}>
100
+ ]
101
+ </Text>{" "}
102
+ {title}
103
+ </Text>
104
+ )
105
+ }
106
+
107
+ // =============================================================================
108
+ // Component
109
+ // =============================================================================
110
+
111
+ /**
112
+ * Reusable modal dialog with consistent styling.
113
+ *
114
+ * Features:
115
+ * - Solid raised background (covers board content)
116
+ * - Double border (configurable color). Cyan reserved for focus rings.
117
+ * - Horizontal padding (2), vertical padding (1)
118
+ * - Title: bold, colored, with spacer below
119
+ * - Footer: centered, dimColor, with spacer above
120
+ */
121
+ export function ModalDialog({
122
+ borderColor = "$border",
123
+ title,
124
+ titleColor,
125
+ titleAlign = "center",
126
+ hotkey,
127
+ titleRight,
128
+ width,
129
+ height,
130
+ footer,
131
+ footerAlign = "center",
132
+ onClose: _onClose,
133
+ focusScope: _focusScope = true,
134
+ children,
135
+ }: ModalDialogProps): React.ReactElement {
136
+ const effectiveTitleColor = titleColor ?? "$primary"
137
+ // When titleRight is provided, use space-between layout for the title bar
138
+ const effectiveTitleAlign = titleRight ? "space-between" : titleAlign
139
+
140
+ return (
141
+ <Box
142
+ flexDirection="column"
143
+ width={width}
144
+ height={height}
145
+ borderStyle="double"
146
+ borderColor={borderColor}
147
+ backgroundColor={"$surface-bg"}
148
+ paddingX={2}
149
+ paddingY={1}
150
+ >
151
+ {title && (
152
+ <Box flexShrink={0} flexDirection="column">
153
+ <Box justifyContent={effectiveTitleAlign}>
154
+ {hotkey ? (
155
+ formatTitleWithHotkey(title, hotkey, effectiveTitleColor)
156
+ ) : (
157
+ <Text color={effectiveTitleColor} bold>
158
+ {title}
159
+ </Text>
160
+ )}
161
+ {titleRight}
162
+ </Box>
163
+ <Text> </Text>
164
+ </Box>
165
+ )}
166
+ {/* Content area - flexGrow pushes footer to bottom, overflow hidden prevents title displacement */}
167
+ <Box flexDirection="column" flexGrow={1} overflow="hidden">
168
+ {children}
169
+ </Box>
170
+ {/* Footer with spacer line above */}
171
+ {footer && (
172
+ <>
173
+ <Text> </Text>
174
+ <Box justifyContent={footerAlign}>{typeof footer === "string" ? <Text dimColor>{footer}</Text> : footer}</Box>
175
+ </>
176
+ )}
177
+ </Box>
178
+ )
179
+ }
@@ -0,0 +1,208 @@
1
+ /**
2
+ * PickerDialog Component
3
+ *
4
+ * Generic search-and-select dialog combining ModalDialog + text input + scrolling
5
+ * result list. Handles keyboard routing: arrows for selection, Enter to confirm,
6
+ * Esc to cancel, printable chars for filtering.
7
+ *
8
+ * Uses useReadline internally for full readline editing (kill ring, word movement).
9
+ *
10
+ * Usage:
11
+ * ```tsx
12
+ * <PickerDialog
13
+ * title="Search"
14
+ * items={filteredResults}
15
+ * renderItem={(item, selected) => (
16
+ * <Text inverse={selected}>{item.name}</Text>
17
+ * )}
18
+ * keyExtractor={(item) => item.id}
19
+ * onSelect={(item) => navigateTo(item)}
20
+ * onCancel={() => closeDialog()}
21
+ * onChange={(query) => setFilter(query)}
22
+ * placeholder="Type to search..."
23
+ * />
24
+ * ```
25
+ */
26
+ import React, { useCallback, useRef, useState } from "react"
27
+ import { useInput } from "@silvery/react/hooks/useInput"
28
+ import { Box } from "@silvery/react/components/Box"
29
+ import { Text } from "@silvery/react/components/Text"
30
+ import { CursorLine } from "./CursorLine"
31
+ import { ModalDialog } from "./ModalDialog"
32
+ import { PickerList } from "./PickerList"
33
+ import { useReadline } from "./useReadline"
34
+
35
+ // =============================================================================
36
+ // Types
37
+ // =============================================================================
38
+
39
+ export interface PickerDialogProps<T> {
40
+ /** Dialog title */
41
+ title: string
42
+ /** Placeholder text when input is empty */
43
+ placeholder?: string
44
+ /** Items to display in the result list */
45
+ items: T[]
46
+ /** Render function for each item. `selected` is true for the highlighted item. */
47
+ renderItem: (item: T, selected: boolean) => React.ReactNode
48
+ /** Unique key for each item */
49
+ keyExtractor: (item: T) => string
50
+ /** Called when an item is confirmed (Enter) */
51
+ onSelect: (item: T) => void
52
+ /** Called when the dialog is cancelled (Esc) */
53
+ onCancel: () => void
54
+ /** Called when the input text changes (for filtering) */
55
+ onChange?: (query: string) => void
56
+ /** Initial input value */
57
+ initialValue?: string
58
+ /** Message when items list is empty */
59
+ emptyMessage?: string
60
+ /** Maximum visible items before scrolling (default: 10) */
61
+ maxVisible?: number
62
+ /** Dialog width */
63
+ width?: number
64
+ /** Dialog height (auto-sized if omitted) */
65
+ height?: number
66
+ /** Footer content */
67
+ footer?: React.ReactNode
68
+ /** Input prompt prefix (e.g., "/ " or "All > ") */
69
+ prompt?: string
70
+ /** Prompt color */
71
+ promptColor?: string
72
+ /** Whether the input is active (default: true) */
73
+ isActive?: boolean
74
+ }
75
+
76
+ // =============================================================================
77
+ // Component
78
+ // =============================================================================
79
+
80
+ /**
81
+ * Generic search-and-select dialog.
82
+ *
83
+ * Keyboard routing:
84
+ * - Printable chars, Ctrl shortcuts: readline text editing
85
+ * - Up/Down arrows: navigate result list
86
+ * - PgUp/PgDn: scroll by page
87
+ * - Enter: confirm selected item
88
+ * - Esc: cancel dialog
89
+ */
90
+ export function PickerDialog<T>({
91
+ title,
92
+ placeholder,
93
+ items,
94
+ renderItem,
95
+ keyExtractor,
96
+ onSelect,
97
+ onCancel,
98
+ onChange,
99
+ initialValue = "",
100
+ emptyMessage = "No items",
101
+ maxVisible = 10,
102
+ width,
103
+ height,
104
+ footer,
105
+ prompt,
106
+ promptColor,
107
+ isActive = true,
108
+ }: PickerDialogProps<T>): React.ReactElement {
109
+ const [selectedIndex, setSelectedIndex] = useState(0)
110
+
111
+ // Refs for stable callbacks in useInput closures
112
+ const onSelectRef = useRef(onSelect)
113
+ onSelectRef.current = onSelect
114
+ const onCancelRef = useRef(onCancel)
115
+ onCancelRef.current = onCancel
116
+ const itemsRef = useRef(items)
117
+ itemsRef.current = items
118
+ const selectedIndexRef = useRef(selectedIndex)
119
+ selectedIndexRef.current = selectedIndex
120
+
121
+ // Readline hook for text editing (kill ring, word movement, etc.)
122
+ const readline = useReadline({
123
+ initialValue,
124
+ onChange: useCallback(
125
+ (value: string) => {
126
+ onChange?.(value)
127
+ setSelectedIndex(0)
128
+ },
129
+ [onChange],
130
+ ),
131
+ isActive,
132
+ handleEnter: false, // We handle Enter for item selection
133
+ handleEscape: false, // We handle Esc for cancel
134
+ handleVerticalArrows: false, // We handle Up/Down for list navigation
135
+ })
136
+
137
+ const clampedIndex = items.length > 0 ? Math.min(selectedIndex, items.length - 1) : 0
138
+ if (clampedIndex !== selectedIndex) {
139
+ setSelectedIndex(clampedIndex)
140
+ }
141
+
142
+ // Effective max visible for page navigation step size
143
+ const effectiveMaxVisible = Math.min(maxVisible, items.length)
144
+
145
+ // Navigation handler (separate from readline text editing)
146
+ useInput(
147
+ (_input, key) => {
148
+ if (key.escape) {
149
+ onCancelRef.current()
150
+ return
151
+ }
152
+ if (key.return) {
153
+ const currentItems = itemsRef.current
154
+ const idx = selectedIndexRef.current
155
+ const item = currentItems[Math.min(idx, currentItems.length - 1)]
156
+ if (item) onSelectRef.current(item)
157
+ return
158
+ }
159
+ if (key.upArrow) {
160
+ setSelectedIndex((i) => Math.max(0, i - 1))
161
+ return
162
+ }
163
+ if (key.downArrow) {
164
+ setSelectedIndex((i) => Math.min(i + 1, Math.max(0, itemsRef.current.length - 1)))
165
+ return
166
+ }
167
+ if (key.pageUp) {
168
+ setSelectedIndex((i) => Math.max(0, i - effectiveMaxVisible))
169
+ return
170
+ }
171
+ if (key.pageDown) {
172
+ setSelectedIndex((i) => Math.min(i + effectiveMaxVisible, Math.max(0, itemsRef.current.length - 1)))
173
+ return
174
+ }
175
+ },
176
+ { isActive },
177
+ )
178
+
179
+ // Show placeholder when input is empty
180
+ const showPlaceholder = !readline.value && placeholder
181
+
182
+ return (
183
+ <ModalDialog title={title} width={width} height={height} footer={footer}>
184
+ {/* Search input */}
185
+ <Box flexShrink={0} flexDirection="column">
186
+ <Box>
187
+ {prompt && <Text color={promptColor}>{prompt}</Text>}
188
+ {showPlaceholder ? (
189
+ <Text dimColor>{placeholder}</Text>
190
+ ) : (
191
+ <CursorLine beforeCursor={readline.beforeCursor} afterCursor={readline.afterCursor} showCursor={isActive} />
192
+ )}
193
+ </Box>
194
+ <Text dimColor>{"─".repeat(40)}</Text>
195
+ </Box>
196
+
197
+ {/* Result list (delegated to PickerList) */}
198
+ <PickerList
199
+ items={items}
200
+ selectedIndex={clampedIndex}
201
+ renderItem={renderItem}
202
+ keyExtractor={keyExtractor}
203
+ emptyMessage={emptyMessage}
204
+ maxVisible={maxVisible}
205
+ />
206
+ </ModalDialog>
207
+ )
208
+ }
@@ -0,0 +1,93 @@
1
+ /**
2
+ * PickerList Component
3
+ *
4
+ * Standalone scrolling result list with selection highlighting. Extracted from
5
+ * PickerDialog so it can be composed independently by callers that manage their
6
+ * own input (e.g., km-tui command-system dialogs).
7
+ *
8
+ * Handles:
9
+ * - Scroll offset calculation (centers selected item in view)
10
+ * - Visible items slicing
11
+ * - Empty state rendering
12
+ * - Item rendering via renderItem callback
13
+ *
14
+ * Does NOT handle:
15
+ * - Keyboard navigation (caller manages selectedIndex)
16
+ * - Input/search (caller's responsibility)
17
+ *
18
+ * Usage:
19
+ * ```tsx
20
+ * <PickerList
21
+ * items={filteredResults}
22
+ * selectedIndex={selected}
23
+ * renderItem={(item, sel) => <Text inverse={sel}>{item.name}</Text>}
24
+ * keyExtractor={(item) => item.id}
25
+ * />
26
+ * ```
27
+ */
28
+ import React from "react"
29
+ import { Box } from "@silvery/react/components/Box"
30
+ import { Text } from "@silvery/react/components/Text"
31
+
32
+ // =============================================================================
33
+ // Types
34
+ // =============================================================================
35
+
36
+ export interface PickerListProps<T> {
37
+ /** Items to display */
38
+ items: T[]
39
+ /** Currently selected index (caller-managed) */
40
+ selectedIndex: number
41
+ /** Render function for each item. `selected` is true for the highlighted item. */
42
+ renderItem: (item: T, selected: boolean) => React.ReactNode
43
+ /** Unique key for each item */
44
+ keyExtractor: (item: T) => string
45
+ /** Message when items list is empty (default: "No items") */
46
+ emptyMessage?: string
47
+ /** Maximum visible items before scrolling (default: 10) */
48
+ maxVisible?: number
49
+ }
50
+
51
+ // =============================================================================
52
+ // Component
53
+ // =============================================================================
54
+
55
+ /**
56
+ * Scrolling result list with selection highlighting.
57
+ *
58
+ * Centers the selected item in the visible window. When there are fewer items
59
+ * than maxVisible, all items are shown without scrolling.
60
+ */
61
+ export function PickerList<T>({
62
+ items,
63
+ selectedIndex,
64
+ renderItem,
65
+ keyExtractor,
66
+ emptyMessage = "No items",
67
+ maxVisible = 10,
68
+ }: PickerListProps<T>): React.ReactElement {
69
+ const clampedIndex = items.length > 0 ? Math.min(selectedIndex, items.length - 1) : 0
70
+ const effectiveMaxVisible = Math.min(maxVisible, items.length)
71
+
72
+ // Scroll offset: center the selected item in the visible window
73
+ const scrollOffset =
74
+ items.length > effectiveMaxVisible
75
+ ? Math.max(0, Math.min(clampedIndex - Math.floor(effectiveMaxVisible / 2), items.length - effectiveMaxVisible))
76
+ : 0
77
+
78
+ const visibleItems = items.slice(scrollOffset, scrollOffset + effectiveMaxVisible)
79
+
80
+ return (
81
+ <Box flexDirection="column" flexGrow={1} flexShrink={1} overflow="hidden">
82
+ {items.length === 0 ? (
83
+ <Text dimColor>{emptyMessage}</Text>
84
+ ) : (
85
+ visibleItems.map((item, i) => {
86
+ const actualIndex = scrollOffset + i
87
+ const isSelected = actualIndex === clampedIndex
88
+ return <React.Fragment key={keyExtractor(item)}>{renderItem(item, isSelected)}</React.Fragment>
89
+ })
90
+ )}
91
+ </Box>
92
+ )
93
+ }
@@ -0,0 +1,126 @@
1
+ /**
2
+ * ProgressBar Component
3
+ *
4
+ * A terminal progress bar with determinate and indeterminate modes.
5
+ *
6
+ * Usage:
7
+ * ```tsx
8
+ * <ProgressBar value={0.5} />
9
+ * <ProgressBar value={0.75} color="green" label="Downloading..." />
10
+ * <ProgressBar /> // indeterminate (animated)
11
+ * ```
12
+ */
13
+ import React, { useEffect, useState } 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 ProgressBarProps {
23
+ /** Progress value 0-1 (omit for indeterminate) */
24
+ value?: number
25
+ /** Width in columns (default: uses available width via useContentRect) */
26
+ width?: number
27
+ /** Fill character (default: "█") */
28
+ fillChar?: string
29
+ /** Empty character (default: "░") */
30
+ emptyChar?: string
31
+ /** Show percentage label (default: true for determinate) */
32
+ showPercentage?: boolean
33
+ /** Label text */
34
+ label?: string
35
+ /** Color of the filled portion */
36
+ color?: string
37
+ }
38
+
39
+ // =============================================================================
40
+ // Constants
41
+ // =============================================================================
42
+
43
+ const DEFAULT_FILL = "█"
44
+ const DEFAULT_EMPTY = "░"
45
+ const DEFAULT_WIDTH = 30
46
+ const INDETERMINATE_BLOCK_SIZE = 4
47
+ const INDETERMINATE_INTERVAL = 100
48
+
49
+ // =============================================================================
50
+ // Component
51
+ // =============================================================================
52
+
53
+ export function ProgressBar({
54
+ value,
55
+ width: widthProp,
56
+ fillChar = DEFAULT_FILL,
57
+ emptyChar = DEFAULT_EMPTY,
58
+ showPercentage,
59
+ label,
60
+ color,
61
+ }: ProgressBarProps): React.ReactElement {
62
+ const { width: contentWidth } = useContentRect()
63
+ const [bouncePos, setBouncePos] = useState(0)
64
+ const [bounceDir, setBounceDir] = useState(1)
65
+
66
+ const isDeterminate = value !== undefined
67
+ const showPct = showPercentage ?? isDeterminate
68
+
69
+ // Calculate available bar width
70
+ const labelWidth = label ? label.length + 1 : 0
71
+ const pctWidth = showPct ? 5 : 0 // " 100%"
72
+ const availableWidth = widthProp ?? (contentWidth > 0 ? contentWidth : DEFAULT_WIDTH)
73
+ const barWidth = Math.max(1, availableWidth - labelWidth - pctWidth)
74
+
75
+ // Indeterminate animation
76
+ useEffect(() => {
77
+ if (isDeterminate) return
78
+
79
+ const timer = setInterval(() => {
80
+ setBouncePos((prev) => {
81
+ const maxPos = barWidth - INDETERMINATE_BLOCK_SIZE
82
+ if (maxPos <= 0) return 0
83
+
84
+ const next = prev + bounceDir
85
+ if (next >= maxPos) {
86
+ setBounceDir(-1)
87
+ return maxPos
88
+ }
89
+ if (next <= 0) {
90
+ setBounceDir(1)
91
+ return 0
92
+ }
93
+ return next
94
+ })
95
+ }, INDETERMINATE_INTERVAL)
96
+
97
+ return () => clearInterval(timer)
98
+ }, [isDeterminate, barWidth, bounceDir])
99
+
100
+ let filledPart: string
101
+ let emptyPart: string
102
+
103
+ if (isDeterminate) {
104
+ const clamped = Math.max(0, Math.min(1, value))
105
+ const filled = Math.round(clamped * barWidth)
106
+ filledPart = fillChar.repeat(filled)
107
+ emptyPart = emptyChar.repeat(barWidth - filled)
108
+ } else {
109
+ // Indeterminate: sliding block
110
+ const blockSize = Math.min(INDETERMINATE_BLOCK_SIZE, barWidth)
111
+ const pos = Math.max(0, Math.min(bouncePos, barWidth - blockSize))
112
+ filledPart = emptyChar.repeat(pos) + fillChar.repeat(blockSize)
113
+ emptyPart = emptyChar.repeat(barWidth - pos - blockSize)
114
+ }
115
+
116
+ const pct = isDeterminate ? Math.round(Math.max(0, Math.min(1, value)) * 100) : 0
117
+
118
+ return (
119
+ <Box>
120
+ {label && <Text>{label} </Text>}
121
+ <Text color={color}>{filledPart}</Text>
122
+ <Text dimColor>{emptyPart}</Text>
123
+ {showPct && <Text> {pct}%</Text>}
124
+ </Box>
125
+ )
126
+ }
@@ -0,0 +1,78 @@
1
+ /**
2
+ * Screen - Fullscreen root component.
3
+ *
4
+ * Claims the full terminal dimensions for flexbox layout. This is the
5
+ * declarative equivalent of the implicit fullscreen mode from run()/createApp().
6
+ *
7
+ * @example
8
+ * ```tsx
9
+ * <Screen>
10
+ * <Sidebar />
11
+ * <MainContent />
12
+ * <StatusBar />
13
+ * </Screen>
14
+ * ```
15
+ *
16
+ * @example
17
+ * ```tsx
18
+ * // Fullscreen + scrollable region (log viewer, dashboard)
19
+ * <Screen>
20
+ * <Sidebar />
21
+ * <VirtualView items={logs} renderItem={...} />
22
+ * <StatusBar />
23
+ * </Screen>
24
+ * ```
25
+ */
26
+
27
+ import { useState, useEffect, type ReactNode, type ReactElement } from "react"
28
+ import { Box } from "@silvery/react/components/Box"
29
+
30
+ // =============================================================================
31
+ // Types
32
+ // =============================================================================
33
+
34
+ export interface ScreenProps {
35
+ /** Children to render in the fullscreen area */
36
+ children: ReactNode
37
+ /** Flex direction for layout. Default: "column" (screens are typically vertical) */
38
+ flexDirection?: "row" | "column" | "row-reverse" | "column-reverse"
39
+ }
40
+
41
+ // =============================================================================
42
+ // Helpers
43
+ // =============================================================================
44
+
45
+ function getTermDims(): { width: number; height: number } {
46
+ return {
47
+ width: process.stdout.columns ?? 80,
48
+ height: process.stdout.rows ?? 24,
49
+ }
50
+ }
51
+
52
+ // =============================================================================
53
+ // Component
54
+ // =============================================================================
55
+
56
+ /**
57
+ * Fullscreen root component.
58
+ *
59
+ * Provides a Box that fills the entire terminal. Tracks terminal resize
60
+ * events to stay in sync with the actual terminal dimensions.
61
+ */
62
+ export function Screen({ children, flexDirection = "column" }: ScreenProps): ReactElement {
63
+ const [dims, setDims] = useState(getTermDims)
64
+
65
+ useEffect(() => {
66
+ const onResize = () => setDims(getTermDims())
67
+ process.stdout.on("resize", onResize)
68
+ return () => {
69
+ process.stdout.off("resize", onResize)
70
+ }
71
+ }, [])
72
+
73
+ return (
74
+ <Box width={dims.width} height={dims.height} flexDirection={flexDirection}>
75
+ {children}
76
+ </Box>
77
+ )
78
+ }