@visulima/tui-kit 0.0.0 → 1.0.1

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 (371) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/LICENSE.md +2095 -0
  3. package/README.md +115 -29
  4. package/dist/accordion.d.ts +52 -0
  5. package/dist/accordion.js +1 -0
  6. package/dist/alert.d.ts +22 -0
  7. package/dist/alert.js +1 -0
  8. package/dist/animate-presence.d.ts +16 -0
  9. package/dist/animate-presence.js +1 -0
  10. package/dist/approval-prompt.d.ts +55 -0
  11. package/dist/approval-prompt.js +1 -0
  12. package/dist/area-chart.d.ts +56 -0
  13. package/dist/area-chart.js +1 -0
  14. package/dist/badge.d.ts +19 -0
  15. package/dist/badge.js +1 -0
  16. package/dist/bar-chart.d.ts +58 -0
  17. package/dist/bar-chart.js +1 -0
  18. package/dist/big-text.d.ts +71 -0
  19. package/dist/big-text.js +1 -0
  20. package/dist/blink-dot.d.ts +31 -0
  21. package/dist/blink-dot.js +1 -0
  22. package/dist/breadcrumb.d.ts +29 -0
  23. package/dist/breadcrumb.js +1 -0
  24. package/dist/button.d.ts +38 -0
  25. package/dist/button.js +1 -0
  26. package/dist/calendar.d.ts +60 -0
  27. package/dist/calendar.js +1 -0
  28. package/dist/card.d.ts +47 -0
  29. package/dist/card.js +1 -0
  30. package/dist/checkbox-group.d.ts +50 -0
  31. package/dist/checkbox-group.js +1 -0
  32. package/dist/checkbox.d.ts +40 -0
  33. package/dist/checkbox.js +1 -0
  34. package/dist/code.d.ts +41 -0
  35. package/dist/code.js +2 -0
  36. package/dist/collapsible.d.ts +44 -0
  37. package/dist/collapsible.js +1 -0
  38. package/dist/color-picker.d.ts +59 -0
  39. package/dist/color-picker.js +1 -0
  40. package/dist/command-block.d.ts +39 -0
  41. package/dist/command-block.js +4 -0
  42. package/dist/command-palette.d.ts +73 -0
  43. package/dist/command-palette.js +1 -0
  44. package/dist/confirm-dialog.d.ts +63 -0
  45. package/dist/confirm-dialog.js +1 -0
  46. package/dist/confirm-input.d.ts +32 -0
  47. package/dist/confirm-input.js +1 -0
  48. package/dist/console-overlay.d.ts +47 -0
  49. package/dist/console-overlay.js +1 -0
  50. package/dist/content-switcher.d.ts +49 -0
  51. package/dist/content-switcher.js +1 -0
  52. package/dist/controlled-scroll-view.d.ts +3 -0
  53. package/dist/controlled-scroll-view.js +1 -0
  54. package/dist/data-grid.d.ts +43 -0
  55. package/dist/data-grid.js +1 -0
  56. package/dist/date-picker.d.ts +54 -0
  57. package/dist/date-picker.js +1 -0
  58. package/dist/definition-list.d.ts +38 -0
  59. package/dist/definition-list.js +1 -0
  60. package/dist/dialog.d.ts +94 -0
  61. package/dist/dialog.js +1 -0
  62. package/dist/diff-view.d.ts +72 -0
  63. package/dist/diff-view.js +1 -0
  64. package/dist/digits.d.ts +26 -0
  65. package/dist/digits.js +1 -0
  66. package/dist/directory-tree.d.ts +44 -0
  67. package/dist/directory-tree.js +1 -0
  68. package/dist/divider.d.ts +41 -0
  69. package/dist/divider.js +1 -0
  70. package/dist/drawer.d.ts +45 -0
  71. package/dist/drawer.js +1 -0
  72. package/dist/file-change.d.ts +34 -0
  73. package/dist/file-change.js +1 -0
  74. package/dist/file-picker-entry.d.ts +2 -0
  75. package/dist/file-picker-entry.js +1 -0
  76. package/dist/form.d.ts +74 -0
  77. package/dist/form.js +1 -0
  78. package/dist/gauge.d.ts +76 -0
  79. package/dist/gauge.js +1 -0
  80. package/dist/gradient.d.ts +38 -0
  81. package/dist/gradient.js +1 -0
  82. package/dist/grid.d.ts +34 -0
  83. package/dist/grid.js +1 -0
  84. package/dist/heading.d.ts +28 -0
  85. package/dist/heading.js +1 -0
  86. package/dist/heatmap.d.ts +52 -0
  87. package/dist/heatmap.js +1 -0
  88. package/dist/help.d.ts +45 -0
  89. package/dist/help.js +1 -0
  90. package/dist/histogram.d.ts +52 -0
  91. package/dist/histogram.js +1 -0
  92. package/dist/image.d.ts +23 -0
  93. package/dist/image.js +1 -0
  94. package/dist/index.d.ts +125 -0
  95. package/dist/index.js +1 -0
  96. package/dist/json.d.ts +51 -0
  97. package/dist/json.js +1 -0
  98. package/dist/kbd.d.ts +27 -0
  99. package/dist/kbd.js +1 -0
  100. package/dist/line-chart.d.ts +100 -0
  101. package/dist/line-chart.js +1 -0
  102. package/dist/link.d.ts +34 -0
  103. package/dist/link.js +1 -0
  104. package/dist/loading-indicator.d.ts +25 -0
  105. package/dist/loading-indicator.js +1 -0
  106. package/dist/log.d.ts +33 -0
  107. package/dist/log.js +1 -0
  108. package/dist/markdown.d.ts +35 -0
  109. package/dist/markdown.js +1 -0
  110. package/dist/masked-input.d.ts +42 -0
  111. package/dist/masked-input.js +1 -0
  112. package/dist/menu.d.ts +70 -0
  113. package/dist/menu.js +1 -0
  114. package/dist/message-bubble.d.ts +36 -0
  115. package/dist/message-bubble.js +1 -0
  116. package/dist/model-badge.d.ts +33 -0
  117. package/dist/model-badge.js +1 -0
  118. package/dist/model-selector.d.ts +45 -0
  119. package/dist/model-selector.js +1 -0
  120. package/dist/multi-progress.d.ts +48 -0
  121. package/dist/multi-progress.js +1 -0
  122. package/dist/multi-select.d.ts +55 -0
  123. package/dist/multi-select.js +1 -0
  124. package/dist/notification-badge.d.ts +40 -0
  125. package/dist/notification-badge.js +1 -0
  126. package/dist/number-input.d.ts +71 -0
  127. package/dist/number-input.js +1 -0
  128. package/dist/operation-tree.d.ts +46 -0
  129. package/dist/operation-tree.js +1 -0
  130. package/dist/option-list.d.ts +41 -0
  131. package/dist/option-list.js +1 -0
  132. package/dist/ordered-list.d.ts +22 -0
  133. package/dist/ordered-list.js +1 -0
  134. package/dist/packem_shared/ControlledScrollView-BzlbIUph.js +1 -0
  135. package/dist/packem_shared/FilePicker-V5H2fb-v.js +1 -0
  136. package/dist/packem_shared/ScrollBar-C9i3z3Ft.js +1 -0
  137. package/dist/packem_shared/ScrollBarBox-DZEC8YnV.js +1 -0
  138. package/dist/packem_shared/ScrollList-Djtb2vVy.js +1 -0
  139. package/dist/packem_shared/ScrollView-ESxpl6Hy.js +1 -0
  140. package/dist/packem_shared/TreeNodeMap-cJlYqckB.js +1 -0
  141. package/dist/packem_shared/TreeView-q0l446NV.js +1 -0
  142. package/dist/packem_shared/controlled-scroll-view-DerVKKv6.js +1 -0
  143. package/dist/packem_shared/controlled-scroll-view.d-D3kZZzjI.d.ts +43 -0
  144. package/dist/packem_shared/file-picker.d-CH7pccC7.d.ts +92 -0
  145. package/dist/packem_shared/line-chart-BbhMhcZg.js +1 -0
  146. package/dist/packem_shared/scroll-bar-box.d-CpTYEcTZ.d.ts +13 -0
  147. package/dist/packem_shared/scroll-bar.d-B4sD1hBR.d.ts +18 -0
  148. package/dist/packem_shared/scroll-list.d-CPCF0RwC.d.ts +12 -0
  149. package/dist/packem_shared/scroll-view.d-GZcgAHl4.d.ts +98 -0
  150. package/dist/packem_shared/treeViewTheme-Dq3ekRAC.js +1 -0
  151. package/dist/packem_shared/use-tree-view.d-DeMQ6G2r.d.ts +314 -0
  152. package/dist/packem_shared/useTreeView-D0JEqT6W.js +1 -0
  153. package/dist/packem_shared/useTreeViewState-BiTJakkh.js +1 -0
  154. package/dist/packem_shared/variant-config-BJqpNo7H.js +1 -0
  155. package/dist/packem_shared/variant-config.d-D5NnrrQK.d.ts +2 -0
  156. package/dist/paginator.d.ts +59 -0
  157. package/dist/paginator.js +1 -0
  158. package/dist/paragraph.d.ts +32 -0
  159. package/dist/paragraph.js +1 -0
  160. package/dist/path-input.d.ts +56 -0
  161. package/dist/path-input.js +1 -0
  162. package/dist/pie-chart.d.ts +44 -0
  163. package/dist/pie-chart.js +1 -0
  164. package/dist/placeholder.d.ts +47 -0
  165. package/dist/placeholder.js +1 -0
  166. package/dist/popover.d.ts +39 -0
  167. package/dist/popover.js +1 -0
  168. package/dist/progress-bar.d.ts +43 -0
  169. package/dist/progress-bar.js +1 -0
  170. package/dist/progress-circle.d.ts +50 -0
  171. package/dist/progress-circle.js +1 -0
  172. package/dist/qr-code.d.ts +39 -0
  173. package/dist/qr-code.js +1 -0
  174. package/dist/r/accordion.json +24 -0
  175. package/dist/r/alert.json +24 -0
  176. package/dist/r/animate-presence.json +22 -0
  177. package/dist/r/approval-prompt.json +24 -0
  178. package/dist/r/area-chart.json +27 -0
  179. package/dist/r/badge.json +24 -0
  180. package/dist/r/bar-chart.json +24 -0
  181. package/dist/r/big-text.json +23 -0
  182. package/dist/r/blink-dot.json +24 -0
  183. package/dist/r/breadcrumb.json +24 -0
  184. package/dist/r/button.json +24 -0
  185. package/dist/r/calendar.json +24 -0
  186. package/dist/r/card.json +26 -0
  187. package/dist/r/chart-utils.json +24 -0
  188. package/dist/r/checkbox-group.json +24 -0
  189. package/dist/r/checkbox.json +24 -0
  190. package/dist/r/code.json +23 -0
  191. package/dist/r/collapsible.json +24 -0
  192. package/dist/r/color-picker.json +24 -0
  193. package/dist/r/command-block.json +26 -0
  194. package/dist/r/command-palette.json +24 -0
  195. package/dist/r/confirm-dialog.json +24 -0
  196. package/dist/r/confirm-input.json +22 -0
  197. package/dist/r/console-overlay.json +22 -0
  198. package/dist/r/content-switcher.json +24 -0
  199. package/dist/r/controlled-scroll-view.json +24 -0
  200. package/dist/r/data-grid.json +24 -0
  201. package/dist/r/date-picker.json +26 -0
  202. package/dist/r/definition-list.json +24 -0
  203. package/dist/r/dialog.json +24 -0
  204. package/dist/r/diff-view.json +24 -0
  205. package/dist/r/digits.json +24 -0
  206. package/dist/r/directory-tree.json +24 -0
  207. package/dist/r/divider.json +24 -0
  208. package/dist/r/drawer.json +24 -0
  209. package/dist/r/file-change.json +24 -0
  210. package/dist/r/file-picker.json +44 -0
  211. package/dist/r/form.json +24 -0
  212. package/dist/r/gauge.json +24 -0
  213. package/dist/r/gradient.json +24 -0
  214. package/dist/r/grid.json +22 -0
  215. package/dist/r/heading.json +24 -0
  216. package/dist/r/heatmap.json +24 -0
  217. package/dist/r/help.json +22 -0
  218. package/dist/r/histogram.json +26 -0
  219. package/dist/r/image.json +24 -0
  220. package/dist/r/json.json +24 -0
  221. package/dist/r/kbd.json +24 -0
  222. package/dist/r/line-chart.json +26 -0
  223. package/dist/r/link.json +23 -0
  224. package/dist/r/loading-indicator.json +27 -0
  225. package/dist/r/log.json +24 -0
  226. package/dist/r/markdown.json +29 -0
  227. package/dist/r/masked-input.json +22 -0
  228. package/dist/r/menu.json +24 -0
  229. package/dist/r/message-bubble.json +24 -0
  230. package/dist/r/model-badge.json +24 -0
  231. package/dist/r/model-selector.json +24 -0
  232. package/dist/r/multi-progress.json +24 -0
  233. package/dist/r/multi-select.json +24 -0
  234. package/dist/r/notification-badge.json +24 -0
  235. package/dist/r/number-input.json +24 -0
  236. package/dist/r/operation-tree.json +26 -0
  237. package/dist/r/option-list.json +24 -0
  238. package/dist/r/ordered-list.json +22 -0
  239. package/dist/r/paginator.json +22 -0
  240. package/dist/r/paragraph.json +24 -0
  241. package/dist/r/path-input.json +26 -0
  242. package/dist/r/pie-chart.json +24 -0
  243. package/dist/r/placeholder.json +24 -0
  244. package/dist/r/popover.json +24 -0
  245. package/dist/r/progress-bar.json +22 -0
  246. package/dist/r/progress-circle.json +24 -0
  247. package/dist/r/qr-code.json +24 -0
  248. package/dist/r/radio-group.json +24 -0
  249. package/dist/r/registry.json +1051 -0
  250. package/dist/r/scatter-plot.json +27 -0
  251. package/dist/r/scroll-bar-box.json +24 -0
  252. package/dist/r/scroll-bar.json +24 -0
  253. package/dist/r/scroll-list.json +24 -0
  254. package/dist/r/scroll-view.json +24 -0
  255. package/dist/r/scroll.json +58 -0
  256. package/dist/r/search-input.json +26 -0
  257. package/dist/r/select-input-indicator.json +24 -0
  258. package/dist/r/select-input-item.json +24 -0
  259. package/dist/r/select-input.json +28 -0
  260. package/dist/r/shimmer-text.json +24 -0
  261. package/dist/r/sidebar.json +24 -0
  262. package/dist/r/slider.json +24 -0
  263. package/dist/r/sparkline.json +24 -0
  264. package/dist/r/spinner.json +23 -0
  265. package/dist/r/status-line.json +24 -0
  266. package/dist/r/status-message.json +24 -0
  267. package/dist/r/stepper.json +24 -0
  268. package/dist/r/stopwatch.json +22 -0
  269. package/dist/r/streaming-text.json +24 -0
  270. package/dist/r/switch.json +24 -0
  271. package/dist/r/tab.json +22 -0
  272. package/dist/r/table.json +23 -0
  273. package/dist/r/tabs.json +26 -0
  274. package/dist/r/tag-input.json +24 -0
  275. package/dist/r/tag.json +24 -0
  276. package/dist/r/text-input.json +22 -0
  277. package/dist/r/textarea.json +22 -0
  278. package/dist/r/thinking-block.json +24 -0
  279. package/dist/r/time-picker.json +24 -0
  280. package/dist/r/timer.json +22 -0
  281. package/dist/r/toast.json +24 -0
  282. package/dist/r/token-usage.json +24 -0
  283. package/dist/r/tool-call.json +24 -0
  284. package/dist/r/tooltip.json +24 -0
  285. package/dist/r/transition.json +22 -0
  286. package/dist/r/tree-select.json +24 -0
  287. package/dist/r/tree-view.json +64 -0
  288. package/dist/r/unordered-list.json +22 -0
  289. package/dist/r/use-linked-scroll.json +24 -0
  290. package/dist/r/variant-config.json +22 -0
  291. package/dist/radio-group.d.ts +63 -0
  292. package/dist/radio-group.js +1 -0
  293. package/dist/scatter-plot.d.ts +24 -0
  294. package/dist/scatter-plot.js +1 -0
  295. package/dist/scroll-bar-box.d.ts +3 -0
  296. package/dist/scroll-bar-box.js +1 -0
  297. package/dist/scroll-bar.d.ts +3 -0
  298. package/dist/scroll-bar.js +1 -0
  299. package/dist/scroll-list.d.ts +4 -0
  300. package/dist/scroll-list.js +1 -0
  301. package/dist/scroll-view.d.ts +3 -0
  302. package/dist/scroll-view.js +1 -0
  303. package/dist/search-input.d.ts +46 -0
  304. package/dist/search-input.js +1 -0
  305. package/dist/select-input-indicator.d.ts +28 -0
  306. package/dist/select-input-indicator.js +1 -0
  307. package/dist/select-input-item.d.ts +35 -0
  308. package/dist/select-input-item.js +1 -0
  309. package/dist/select-input.d.ts +97 -0
  310. package/dist/select-input.js +1 -0
  311. package/dist/shimmer-text.d.ts +41 -0
  312. package/dist/shimmer-text.js +1 -0
  313. package/dist/sidebar.d.ts +40 -0
  314. package/dist/sidebar.js +1 -0
  315. package/dist/slider.d.ts +87 -0
  316. package/dist/slider.js +1 -0
  317. package/dist/sparkline.d.ts +36 -0
  318. package/dist/sparkline.js +1 -0
  319. package/dist/spinner.d.ts +15 -0
  320. package/dist/spinner.js +1 -0
  321. package/dist/status-line.d.ts +31 -0
  322. package/dist/status-line.js +1 -0
  323. package/dist/status-message.d.ts +18 -0
  324. package/dist/status-message.js +1 -0
  325. package/dist/stepper.d.ts +42 -0
  326. package/dist/stepper.js +1 -0
  327. package/dist/stopwatch.d.ts +42 -0
  328. package/dist/stopwatch.js +1 -0
  329. package/dist/streaming-text.d.ts +39 -0
  330. package/dist/streaming-text.js +1 -0
  331. package/dist/switch.d.ts +46 -0
  332. package/dist/switch.js +1 -0
  333. package/dist/tab.d.ts +17 -0
  334. package/dist/tab.js +1 -0
  335. package/dist/table.d.ts +118 -0
  336. package/dist/table.js +1 -0
  337. package/dist/tabs.d.ts +96 -0
  338. package/dist/tabs.js +1 -0
  339. package/dist/tag-input.d.ts +59 -0
  340. package/dist/tag-input.js +1 -0
  341. package/dist/tag.d.ts +30 -0
  342. package/dist/tag.js +1 -0
  343. package/dist/text-input.d.ts +40 -0
  344. package/dist/text-input.js +1 -0
  345. package/dist/textarea.d.ts +57 -0
  346. package/dist/textarea.js +2 -0
  347. package/dist/thinking-block.d.ts +48 -0
  348. package/dist/thinking-block.js +1 -0
  349. package/dist/time-picker.d.ts +57 -0
  350. package/dist/time-picker.js +1 -0
  351. package/dist/timer.d.ts +50 -0
  352. package/dist/timer.js +1 -0
  353. package/dist/toast.d.ts +41 -0
  354. package/dist/toast.js +1 -0
  355. package/dist/token-usage.d.ts +43 -0
  356. package/dist/token-usage.js +1 -0
  357. package/dist/tool-call.d.ts +28 -0
  358. package/dist/tool-call.js +1 -0
  359. package/dist/tooltip.d.ts +37 -0
  360. package/dist/tooltip.js +1 -0
  361. package/dist/transition.d.ts +66 -0
  362. package/dist/transition.js +1 -0
  363. package/dist/tree-select.d.ts +55 -0
  364. package/dist/tree-select.js +1 -0
  365. package/dist/tree-view-entry.d.ts +4 -0
  366. package/dist/tree-view-entry.js +1 -0
  367. package/dist/unordered-list.d.ts +27 -0
  368. package/dist/unordered-list.js +1 -0
  369. package/dist/use-linked-scroll.d.ts +42 -0
  370. package/dist/use-linked-scroll.js +1 -0
  371. package/package.json +553 -7
@@ -0,0 +1,24 @@
1
+ {
2
+ "$schema": "https://ui.shadcn.com/schema/registry-item.json",
3
+ "name": "toast",
4
+ "type": "registry:ui",
5
+ "title": "Toast",
6
+ "description": "Toast component for @visulima/tui.",
7
+ "categories": [
8
+ "ui"
9
+ ],
10
+ "dependencies": [
11
+ "@visulima/tui"
12
+ ],
13
+ "registryDependencies": [
14
+ "https://visulima.com/r/variant-config.json"
15
+ ],
16
+ "files": [
17
+ {
18
+ "content": "/* eslint-disable react/function-component-definition */\nimport Box from \"@visulima/tui/components/box\";\nimport Text from \"@visulima/tui/components/text\";\nimport type { ReactElement, ReactNode } from \"react\";\nimport { useEffect, useRef, useState } from \"react\";\n\nimport type { Variant } from \"@/lib/variant-config\";\nimport VARIANT_CONFIG from \"@/lib/variant-config\";\n\nexport type ToastVariant = Variant;\n\nexport type Props = {\n /**\n * Toast body.\n */\n readonly children: ReactNode;\n\n /**\n * Milliseconds to display before auto-dismissing. Use `0` to disable.\n * @default 4000\n */\n readonly duration?: number;\n\n /**\n * Called after the toast dismisses itself.\n */\n readonly onDismiss?: () => void;\n\n /**\n * Optional title rendered before the body.\n */\n readonly title?: string;\n\n /**\n * Visual variant determining the icon and border color.\n * @default \"info\"\n */\n readonly variant?: ToastVariant;\n\n /**\n * Manually control visibility. When provided, `duration` is ignored.\n */\n readonly visible?: boolean;\n\n /**\n * Optional width override.\n */\n readonly width?: number;\n};\n\n/**\n * Ephemeral notification. Disappears automatically after `duration` ms.\n * @returns A bordered `ReactElement` while visible; `null` once dismissed.\n */\nexport default function Toast({ children, duration = 4000, onDismiss, title, variant = \"info\", visible, width }: Props): ReactElement | null {\n const [internalVisible, setInternalVisible] = useState(true);\n const isVisible = visible ?? internalVisible;\n const onDismissRef = useRef(onDismiss);\n\n onDismissRef.current = onDismiss;\n\n useEffect(() => {\n if (visible !== undefined || duration <= 0 || !isVisible) {\n return undefined;\n }\n\n const timer = setTimeout(() => {\n setInternalVisible(false);\n onDismissRef.current?.();\n }, duration);\n\n return () => {\n clearTimeout(timer);\n };\n }, [duration, visible, isVisible]);\n\n if (!isVisible) {\n return null;\n }\n\n const { color, icon } = VARIANT_CONFIG[variant];\n\n return (\n <Box borderColor={color} borderStyle=\"round\" gap={1} paddingX={1} width={width}>\n <Text color={color}>{icon}</Text>\n <Box flexDirection=\"column\" flexGrow={1}>\n {title === undefined ? undefined : <Text bold>{title}</Text>}\n <Text>{children}</Text>\n </Box>\n </Box>\n );\n}\n\nexport { Toast };\nexport type { Props as ToastProps };\n",
19
+ "path": "registry/ui/toast.tsx",
20
+ "target": "components/ui/toast.tsx",
21
+ "type": "registry:ui"
22
+ }
23
+ ]
24
+ }
@@ -0,0 +1,24 @@
1
+ {
2
+ "$schema": "https://ui.shadcn.com/schema/registry-item.json",
3
+ "name": "token-usage",
4
+ "type": "registry:ui",
5
+ "title": "Token Usage",
6
+ "description": "Token Usage component for @visulima/tui.",
7
+ "categories": [
8
+ "ui"
9
+ ],
10
+ "dependencies": [
11
+ "@visulima/colorize",
12
+ "@visulima/tui",
13
+ "type-fest"
14
+ ],
15
+ "registryDependencies": [],
16
+ "files": [
17
+ {
18
+ "content": "/* eslint-disable react/function-component-definition */\nimport type { AnsiColors } from \"@visulima/colorize\";\nimport Box from \"@visulima/tui/components/box\";\nimport Text from \"@visulima/tui/components/text\";\nimport type { ReactElement } from \"react\";\nimport type { LiteralUnion } from \"type-fest\";\n\ntype Color = LiteralUnion<AnsiColors, string>;\n\nexport type Props = {\n /**\n * Width of the input/output ratio bar in cells.\n * @default 20\n */\n readonly barWidth?: number;\n\n /**\n * Optional context-window size. When set, a `used / limit (pct%)` summary\n * and a fill bar are shown instead of the input/output split.\n */\n readonly contextLimit?: number;\n\n /**\n * Number of input (prompt) tokens.\n * @default 0\n */\n readonly input?: number;\n\n /**\n * Color for the input segment.\n * @default \"cyan\"\n */\n readonly inputColor?: Color;\n\n /**\n * Number of output (completion) tokens.\n * @default 0\n */\n readonly output?: number;\n\n /**\n * Color for the output segment.\n * @default \"magenta\"\n */\n readonly outputColor?: Color;\n};\n\nfunction fillColor(ratio: number): Color {\n if (ratio > 0.9) {\n return \"red\";\n }\n\n if (ratio > 0.7) {\n return \"yellow\";\n }\n\n return \"green\";\n}\n\nconst formatCount = (value: number): string => {\n if (value >= 1_000_000) {\n return `${(value / 1_000_000).toFixed(1)}M`;\n }\n\n if (value >= 1000) {\n return `${(value / 1000).toFixed(1)}k`;\n }\n\n return String(value);\n};\n\n/**\n * A compact token-usage readout. By default it shows input vs output counts\n * with a proportional two-color bar; when `contextLimit` is set, it instead\n * shows how much of the context window is used.\n */\nexport default function TokenUsage({\n barWidth = 20,\n contextLimit,\n input = 0,\n inputColor = \"cyan\",\n output = 0,\n outputColor = \"magenta\",\n}: Props): ReactElement {\n const total = input + output;\n\n if (contextLimit !== undefined && contextLimit > 0) {\n const ratio = Math.min(1, total / contextLimit);\n const filled = Math.round(ratio * barWidth);\n const barColor = fillColor(ratio);\n\n return (\n <Box gap={1}>\n <Text color={barColor}>{\"█\".repeat(filled)}</Text>\n <Text dimColor>{\"░\".repeat(Math.max(0, barWidth - filled))}</Text>\n <Text>\n {formatCount(total)}\n /\n {formatCount(contextLimit)}\n {\" \"}\n (\n {Math.round(ratio * 100)}\n %)\n </Text>\n </Box>\n );\n }\n\n const inputCells = total === 0 ? 0 : Math.round((input / total) * barWidth);\n const outputCells = Math.max(0, barWidth - inputCells);\n\n return (\n <Box gap={1}>\n <Box>\n <Text color={inputColor}>{\"█\".repeat(inputCells)}</Text>\n <Text color={outputColor}>{\"█\".repeat(outputCells)}</Text>\n </Box>\n <Text color={inputColor}>{`↑${formatCount(input)}`}</Text>\n <Text color={outputColor}>{`↓${formatCount(output)}`}</Text>\n <Text dimColor>{`Σ${formatCount(total)}`}</Text>\n </Box>\n );\n}\n\nexport { TokenUsage };\nexport type { Props as TokenUsageProps };\n",
19
+ "path": "registry/ui/token-usage.tsx",
20
+ "target": "components/ui/token-usage.tsx",
21
+ "type": "registry:ui"
22
+ }
23
+ ]
24
+ }
@@ -0,0 +1,24 @@
1
+ {
2
+ "$schema": "https://ui.shadcn.com/schema/registry-item.json",
3
+ "name": "tool-call",
4
+ "type": "registry:ui",
5
+ "title": "Tool Call",
6
+ "description": "Tool Call component for @visulima/tui.",
7
+ "categories": [
8
+ "ui"
9
+ ],
10
+ "dependencies": [
11
+ "@visulima/colorize",
12
+ "@visulima/tui",
13
+ "type-fest"
14
+ ],
15
+ "registryDependencies": [],
16
+ "files": [
17
+ {
18
+ "content": "/* eslint-disable react/function-component-definition */\nimport type { AnsiColors } from \"@visulima/colorize\";\nimport Box from \"@visulima/tui/components/box\";\nimport Text from \"@visulima/tui/components/text\";\nimport type { ReactElement, ReactNode } from \"react\";\nimport type { LiteralUnion } from \"type-fest\";\n\ntype Color = LiteralUnion<AnsiColors, string>;\n\nexport type ToolCallStatus = \"error\" | \"pending\" | \"running\" | \"success\";\n\nconst STATUS_META: Record<ToolCallStatus, { readonly color: Color; readonly icon: string }> = {\n error: { color: \"red\", icon: \"✖\" },\n pending: { color: \"gray\", icon: \"○\" },\n running: { color: \"yellow\", icon: \"◐\" },\n success: { color: \"green\", icon: \"✔\" },\n};\n\nexport type Props = {\n /**\n * The tool arguments. Objects are shown as compact `key=value` pairs;\n * strings are shown verbatim.\n */\n readonly args?: Record<string, unknown> | string;\n\n /**\n * Rendered output/result of the call, shown indented beneath the header.\n */\n readonly children?: ReactNode;\n\n /**\n * The tool name.\n */\n readonly name: string;\n\n /**\n * Call status, driving the icon and color.\n * @default \"pending\"\n */\n readonly status?: ToolCallStatus;\n};\n\nconst formatArgs = (args: Record<string, unknown> | string | undefined): string => {\n if (args === undefined) {\n return \"\";\n }\n\n if (typeof args === \"string\") {\n return args;\n }\n\n return Object.entries(args)\n .map(([key, value]) => `${key}=${typeof value === \"string\" ? value : JSON.stringify(value)}`)\n .join(\" \");\n};\n\n/**\n * A single tool invocation: a status icon, the tool name, its arguments as\n * compact `key=value` pairs, and any result rendered as indented children.\n */\nexport default function ToolCall({ args, children, name, status = \"pending\" }: Props): ReactElement {\n const meta = STATUS_META[status];\n const argumentText = formatArgs(args);\n\n return (\n <Box flexDirection=\"column\">\n <Box gap={1}>\n <Text color={meta.color}>{meta.icon}</Text>\n <Text bold>{name}</Text>\n {argumentText.length > 0 ? <Text dimColor>{argumentText}</Text> : undefined}\n </Box>\n {children === undefined\n ? undefined\n : (\n <Box marginLeft={2}>\n <Text dimColor>{children}</Text>\n </Box>\n )}\n </Box>\n );\n}\n\nexport { ToolCall };\nexport type { Props as ToolCallProps };\n",
19
+ "path": "registry/ui/tool-call.tsx",
20
+ "target": "components/ui/tool-call.tsx",
21
+ "type": "registry:ui"
22
+ }
23
+ ]
24
+ }
@@ -0,0 +1,24 @@
1
+ {
2
+ "$schema": "https://ui.shadcn.com/schema/registry-item.json",
3
+ "name": "tooltip",
4
+ "type": "registry:ui",
5
+ "title": "Tooltip",
6
+ "description": "Tooltip component for @visulima/tui.",
7
+ "categories": [
8
+ "ui"
9
+ ],
10
+ "dependencies": [
11
+ "@visulima/colorize",
12
+ "@visulima/tui",
13
+ "type-fest"
14
+ ],
15
+ "registryDependencies": [],
16
+ "files": [
17
+ {
18
+ "content": "/* eslint-disable react/function-component-definition */\nimport type { AnsiColors } from \"@visulima/colorize\";\nimport Box from \"@visulima/tui/components/box\";\nimport Text from \"@visulima/tui/components/text\";\nimport type { ReactElement, ReactNode } from \"react\";\nimport type { LiteralUnion } from \"type-fest\";\n\nexport type TooltipPlacement = \"bottom\" | \"left\" | \"right\" | \"top\";\n\nexport type Props = {\n /**\n * The element the tooltip is attached to.\n */\n readonly children: ReactNode;\n\n /**\n * Border color.\n * @default \"gray\"\n */\n readonly color?: LiteralUnion<AnsiColors, string>;\n\n /**\n * Tooltip body content.\n */\n readonly content: ReactNode;\n\n /**\n * Controls whether the tooltip is rendered.\n * @default true\n */\n readonly isVisible?: boolean;\n\n /**\n * Relative position. Terminal rendering does not support floating layers,\n * so the tooltip is placed inline next to (or above / below) the anchor.\n * @default \"right\"\n */\n readonly placement?: TooltipPlacement;\n};\n\nconst PLACEMENT_STYLE: Record<TooltipPlacement, { flexDirection: \"column\" | \"row\"; orderFirst: boolean }> = {\n bottom: { flexDirection: \"column\", orderFirst: false },\n left: { flexDirection: \"row\", orderFirst: true },\n right: { flexDirection: \"row\", orderFirst: false },\n top: { flexDirection: \"column\", orderFirst: true },\n};\n\n/**\n * Renders supplementary text next to an anchor. Because terminals don't have\n * a floating-layer concept, the tooltip lives inline in the layout. Hide it\n * by toggling `isVisible`.\n */\nexport default function Tooltip({ children, color = \"gray\", content, isVisible = true, placement = \"right\" }: Props): ReactElement {\n const { flexDirection, orderFirst } = PLACEMENT_STYLE[placement];\n\n const tooltip = isVisible\n ? (\n <Box\n borderColor={color}\n borderStyle=\"round\"\n flexShrink={0}\n marginLeft={placement === \"right\" ? 1 : 0}\n marginRight={placement === \"left\" ? 1 : 0}\n paddingX={1}\n >\n {typeof content === \"string\" ? <Text color={color}>{content}</Text> : content}\n </Box>\n )\n : undefined;\n\n return (\n <Box alignItems=\"center\" flexDirection={flexDirection}>\n {orderFirst ? tooltip : undefined}\n <Box>{children}</Box>\n {orderFirst ? undefined : tooltip}\n </Box>\n );\n}\n\nexport { Tooltip };\nexport type { Props as TooltipProps };\n",
19
+ "path": "registry/ui/tooltip.tsx",
20
+ "target": "components/ui/tooltip.tsx",
21
+ "type": "registry:ui"
22
+ }
23
+ ]
24
+ }
@@ -0,0 +1,22 @@
1
+ {
2
+ "$schema": "https://ui.shadcn.com/schema/registry-item.json",
3
+ "name": "transition",
4
+ "type": "registry:ui",
5
+ "title": "Transition",
6
+ "description": "Transition component for @visulima/tui.",
7
+ "categories": [
8
+ "ui"
9
+ ],
10
+ "dependencies": [
11
+ "@visulima/tui"
12
+ ],
13
+ "registryDependencies": [],
14
+ "files": [
15
+ {
16
+ "content": "/* eslint-disable react/function-component-definition */\nimport Box from \"@visulima/tui/components/box\";\nimport Text from \"@visulima/tui/components/text\";\nimport type { ReactElement, ReactNode } from \"react\";\nimport { useEffect, useRef, useState } from \"react\";\n\nexport type TransitionPreset = \"fade\" | \"reveal\" | \"slide-down\" | \"slide-left\" | \"slide-right\" | \"slide-up\";\n\nexport type TransitionPhase = \"entered\" | \"entering\" | \"exited\" | \"exiting\";\n\nexport type Props = {\n /**\n * Content to animate.\n */\n readonly children: ReactNode;\n\n /**\n * For slide presets, how many characters of offset to animate across.\n * For the `reveal` preset, the maximum row count to scale through.\n * @default 4\n */\n readonly distance?: number;\n\n /**\n * Duration of both enter and exit transitions, in milliseconds.\n * @default 240\n */\n readonly duration?: number;\n\n /**\n * Called once the exit animation finishes (i.e. the component has fully\n * transitioned out). AnimatePresence uses this to unmount the wrapper.\n */\n readonly onExit?: () => void;\n\n /**\n * Animation preset controlling how the content appears/disappears.\n * @default \"fade\"\n */\n readonly preset?: TransitionPreset;\n\n /**\n * Controls the target phase. `true` drives towards `entered`, `false`\n * drives towards `exited`. Flip the value to trigger animation.\n * @default true\n */\n readonly show?: boolean;\n\n /**\n * Tick interval in milliseconds. Lower values yield smoother animation\n * at the cost of CPU.\n * @default 30\n */\n readonly tickInterval?: number;\n};\n\ntype RenderProgress = (progress: number, children: ReactNode, distance: number) => ReactElement;\n\nconst renderFade: RenderProgress = (progress, children) => {\n // In a terminal there's no alpha — dim text once the transition is past\n // the halfway point to simulate a fade-in/out.\n if (progress >= 1) {\n return <Box>{children}</Box>;\n }\n\n if (progress <= 0) {\n return <Box />;\n }\n\n return <Box>{typeof children === \"string\" ? <Text dimColor={progress < 0.7}>{children}</Text> : children}</Box>;\n};\n\nconst slideOffset = (progress: number, distance: number): number => Math.max(0, Math.min(distance, Math.round((1 - progress) * distance)));\n\nconst renderSlide\n = (axis: \"x\" | \"y\", sign: 1 | -1): RenderProgress =>\n (progress, children, distance) => {\n const offset = slideOffset(progress, distance);\n\n if (progress >= 1) {\n return <Box>{children}</Box>;\n }\n\n if (progress <= 0) {\n return <Box />;\n }\n\n if (axis === \"x\") {\n return (\n <Box paddingLeft={sign === 1 ? offset : 0} paddingRight={sign === -1 ? offset : 0}>\n {children}\n </Box>\n );\n }\n\n return (\n <Box flexDirection=\"column\" paddingBottom={sign === -1 ? offset : 0} paddingTop={sign === 1 ? offset : 0}>\n {children}\n </Box>\n );\n };\n\nconst renderReveal: RenderProgress = (progress, children, distance) => {\n // Wraps content in a Box whose height grows from 0 to `distance` rows.\n if (progress >= 1) {\n return <Box>{children}</Box>;\n }\n\n if (progress <= 0) {\n return <Box />;\n }\n\n const rows = Math.max(0, Math.min(distance, Math.round(progress * distance)));\n\n return (\n <Box flexDirection=\"column\" height={rows} overflowY=\"hidden\">\n {children}\n </Box>\n );\n};\n\nconst PRESET_RENDERERS: Record<TransitionPreset, RenderProgress> = {\n fade: renderFade,\n reveal: renderReveal,\n \"slide-down\": renderSlide(\"y\", -1),\n \"slide-left\": renderSlide(\"x\", -1),\n \"slide-right\": renderSlide(\"x\", 1),\n \"slide-up\": renderSlide(\"y\", 1),\n};\n\nconst resolvePhase = (show: boolean, progress: number): TransitionPhase => {\n if (show) {\n return progress >= 1 ? \"entered\" : \"entering\";\n }\n\n return progress <= 0 ? \"exited\" : \"exiting\";\n};\n\ntype TransitionComponent = ((props: Props) => ReactElement | null) & { isAnimatable: true };\n\n/**\n * Animate a child's entry and exit using a TUI-friendly preset. Drive via\n * `show`; the component toggles between enter and exit, fires `onExit` once\n * the exit animation completes.\n *\n * **Fade preset note:** `dimColor` is only applied when the child is a plain\n * string. Non-string children (Box trees, other components) pass through\n * untouched during the transition — wrap them in `&lt;Text>` if you need the\n * fade effect.\n * @returns A `ReactElement` rendering the transitioning child, or `null`\n * once a hidden transition has fully completed.\n */\nfunction Transition({ children, distance = 4, duration = 240, onExit, preset = \"fade\", show = true, tickInterval = 30 }: Props): ReactElement | null {\n const [progress, setProgress] = useState(show ? 1 : 0);\n const targetRef = useRef<0 | 1>(show ? 1 : 0);\n const startRef = useRef(Date.now());\n const startProgressRef = useRef(show ? 1 : 0);\n const completedRef = useRef(!show);\n const onExitRef = useRef(onExit);\n\n onExitRef.current = onExit;\n\n // Restart animation whenever `show` flips. The `animationKey` state forces\n // the interval-owning effect to re-fire without depending on `progress`.\n const [animationKey, setAnimationKey] = useState(0);\n\n useEffect(() => {\n const target = show ? 1 : 0;\n\n if (targetRef.current === target) {\n return;\n }\n\n targetRef.current = target;\n startRef.current = Date.now();\n startProgressRef.current = progress;\n completedRef.current = false;\n // eslint-disable-next-line react-x/set-state-in-effect, react-you-might-not-need-an-effect/no-adjust-state-on-prop-change -- animation restart key must bump in effect when `show` flips; doing it in render would re-trigger every tick\n setAnimationKey((previous) => previous + 1);\n // `progress` is intentionally excluded — it would retrigger the\n // animation on every tick. We snapshot it once when `show` flips.\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [show]);\n\n // Single interval owner. Runs while the animation is in progress and\n // tears itself down once we reach the target. Crucially does NOT depend\n // on `progress`, so each tick's setState does not rebuild the timer.\n useEffect(() => {\n const target = targetRef.current;\n\n if (startProgressRef.current === target) {\n if (target === 0 && !completedRef.current) {\n completedRef.current = true;\n onExitRef.current?.();\n }\n\n return undefined;\n }\n\n const id = setInterval(() => {\n const elapsed = Date.now() - startRef.current;\n const ratio = duration <= 0 ? 1 : Math.min(1, elapsed / duration);\n const next = startProgressRef.current + (target - startProgressRef.current) * ratio;\n\n setProgress(next);\n\n if (ratio >= 1) {\n clearInterval(id);\n\n if (target === 0 && !completedRef.current) {\n completedRef.current = true;\n onExitRef.current?.();\n }\n }\n }, tickInterval);\n\n return () => {\n clearInterval(id);\n };\n }, [animationKey, duration, tickInterval]);\n\n const phase = resolvePhase(show, progress);\n\n if (phase === \"exited\" && !show) {\n return null;\n }\n\n const renderer = PRESET_RENDERERS[preset];\n\n return renderer(progress, children, distance);\n}\n\n/**\n * Static marker used by `AnimatePresence` to recognize Transition children\n * even when the consumer hasn't explicitly passed a `show` prop. Consumers\n * can apply the same marker to their own wrappers (`MyWrapper.isAnimatable\n * = true`) to opt them into animation orchestration.\n */\nconst TransitionWithMarker: TransitionComponent = Object.assign(Transition, { isAnimatable: true as const });\n\nexport default TransitionWithMarker;\n\nexport { Transition };\nexport type { Props as TransitionProps };\n",
17
+ "path": "registry/ui/transition.tsx",
18
+ "target": "components/ui/transition.tsx",
19
+ "type": "registry:ui"
20
+ }
21
+ ]
22
+ }
@@ -0,0 +1,24 @@
1
+ {
2
+ "$schema": "https://ui.shadcn.com/schema/registry-item.json",
3
+ "name": "tree-select",
4
+ "type": "registry:ui",
5
+ "title": "Tree Select",
6
+ "description": "Tree Select component for @visulima/tui.",
7
+ "categories": [
8
+ "ui"
9
+ ],
10
+ "dependencies": [
11
+ "@visulima/colorize",
12
+ "@visulima/tui",
13
+ "type-fest"
14
+ ],
15
+ "registryDependencies": [],
16
+ "files": [
17
+ {
18
+ "content": "/* eslint-disable react/function-component-definition */\nimport type { AnsiColors } from \"@visulima/colorize\";\nimport Box from \"@visulima/tui/components/box\";\nimport Text from \"@visulima/tui/components/text\";\nimport useFocus from \"@visulima/tui/hooks/use-focus\";\nimport useInput from \"@visulima/tui/hooks/use-input\";\nimport type { ReactElement } from \"react\";\nimport { useCallback, useMemo, useRef, useState } from \"react\";\nimport type { LiteralUnion } from \"type-fest\";\n\nconst EMPTY_VALUES: ReadonlyArray<string> = [];\n\nexport type TreeSelectNode = {\n readonly children?: ReadonlyArray<TreeSelectNode>;\n readonly label: string;\n readonly value: string;\n};\n\nexport type Props = {\n /**\n * Accent color for the focused row and selection marks.\n * @default \"blue\"\n */\n readonly accentColor?: LiteralUnion<AnsiColors, string>;\n\n /**\n * Auto-focus on mount.\n */\n readonly autoFocus?: boolean;\n\n /**\n * Selected values when uncontrolled.\n */\n readonly defaultValue?: ReadonlyArray<string>;\n\n /**\n * Disable input and dim the tree.\n */\n readonly isDisabled?: boolean;\n\n /**\n * The tree to render.\n */\n readonly nodes: ReadonlyArray<TreeSelectNode>;\n\n /**\n * Fires whenever the selection changes.\n */\n readonly onChange?: (values: ReadonlyArray<string>) => void;\n\n /**\n * Fires on Enter with the current selection.\n */\n readonly onSubmit?: (values: ReadonlyArray<string>) => void;\n\n /**\n * `single` replaces the selection; `multiple` toggles values.\n * @default \"single\"\n */\n readonly selectionMode?: \"multiple\" | \"single\";\n\n /**\n * Controlled selection. When provided, `defaultValue` is ignored.\n */\n readonly value?: ReadonlyArray<string>;\n};\n\ntype FlatNode = {\n readonly depth: number;\n readonly hasChildren: boolean;\n readonly node: TreeSelectNode;\n};\n\nfunction wrap(value: number, size: number): number {\n return size === 0 ? 0 : ((value % size) + size) % size;\n}\n\nfunction markerFor(hasChildren: boolean, isExpanded: boolean, isSelected: boolean): string {\n if (hasChildren) {\n return isExpanded ? \"▾\" : \"▸\";\n }\n\n return isSelected ? \"◉\" : \"◯\";\n}\n\n/** Depth-first flatten of the visible rows, honouring the expanded set. */\nconst flatten = (nodes: ReadonlyArray<TreeSelectNode>, expanded: ReadonlySet<string>, depth: number, out: FlatNode[]): void => {\n for (const node of nodes) {\n const hasChildren = node.children !== undefined && node.children.length > 0;\n\n out.push({ depth, hasChildren, node });\n\n if (hasChildren && expanded.has(node.value)) {\n flatten(node.children, expanded, depth + 1, out);\n }\n }\n};\n\n/**\n * A keyboard-navigable tree with selectable leaves. ↑/↓ move the cursor, →/←\n * expand/collapse a branch (or Space on a branch), Space selects a leaf, and\n * Enter submits. Supports single or multiple selection.\n */\nexport default function TreeSelect({\n accentColor = \"blue\",\n autoFocus = false,\n defaultValue = EMPTY_VALUES,\n isDisabled = false,\n nodes,\n onChange,\n onSubmit,\n selectionMode = \"single\",\n value: controlledValue,\n}: Props): ReactElement {\n const { isFocused } = useFocus({ autoFocus, isActive: !isDisabled });\n const isControlled = controlledValue !== undefined;\n\n const [internal, setInternal] = useState<ReadonlyArray<string>>(defaultValue);\n const selected = useMemo(() => new Set(controlledValue ?? internal), [controlledValue, internal]);\n\n const [expanded, setExpanded] = useState<ReadonlySet<string>>(() => new Set());\n const [cursor, setCursor] = useState(0);\n\n const onChangeRef = useRef(onChange);\n\n onChangeRef.current = onChange;\n\n const rows = useMemo(() => {\n const out: FlatNode[] = [];\n\n flatten(nodes, expanded, 0, out);\n\n return out;\n }, [expanded, nodes]);\n\n const emit = useCallback(\n (next: Set<string>) => {\n const values = [...next];\n\n if (!isControlled) {\n setInternal(values);\n }\n\n onChangeRef.current?.(values);\n },\n [isControlled],\n );\n\n const toggleExpand = useCallback((value: string, force?: boolean) => {\n setExpanded((current) => {\n const next = new Set(current);\n const shouldExpand = force ?? !next.has(value);\n\n if (shouldExpand) {\n next.add(value);\n } else {\n next.delete(value);\n }\n\n return next;\n });\n }, []);\n\n const inputHandler = useCallback(\n (input: string, key: { downArrow: boolean; leftArrow: boolean; return: boolean; rightArrow: boolean; upArrow: boolean }) => {\n const row = rows[cursor];\n\n if (key.upArrow) {\n setCursor((index) => wrap(index - 1, rows.length));\n\n return;\n }\n\n if (key.downArrow) {\n setCursor((index) => wrap(index + 1, rows.length));\n\n return;\n }\n\n if (row === undefined) {\n return;\n }\n\n if (key.rightArrow && row.hasChildren) {\n toggleExpand(row.node.value, true);\n\n return;\n }\n\n if (key.leftArrow && row.hasChildren) {\n toggleExpand(row.node.value, false);\n\n return;\n }\n\n if (input === \" \") {\n if (row.hasChildren) {\n toggleExpand(row.node.value);\n\n return;\n }\n\n const next = selectionMode === \"single\" ? new Set<string>() : new Set(selected);\n\n if (next.has(row.node.value)) {\n next.delete(row.node.value);\n } else {\n next.add(row.node.value);\n }\n\n emit(next);\n\n return;\n }\n\n if (key.return) {\n onSubmit?.([...selected]);\n }\n },\n [cursor, emit, onSubmit, rows, selected, selectionMode, toggleExpand],\n );\n\n useInput(inputHandler, { isActive: isFocused && !isDisabled });\n\n return (\n <Box flexDirection=\"column\">\n {rows.map((row, index) => {\n const isActive = isFocused && index === cursor;\n const isSelected = selected.has(row.node.value);\n const indent = \" \".repeat(row.depth);\n const marker = markerFor(row.hasChildren, expanded.has(row.node.value), isSelected);\n const labelColor = !row.hasChildren && isSelected ? accentColor : undefined;\n\n return (\n // eslint-disable-next-line react-x/no-array-index-key -- row index is stable for the render\n <Box key={index}>\n <Text color={isActive ? accentColor : undefined}>{isActive ? \"❯\" : \" \"}</Text>\n <Text>{indent}</Text>\n <Text color={labelColor} dimColor={isDisabled}>\n {marker}\n {\" \"}\n {row.node.label}\n </Text>\n </Box>\n );\n })}\n </Box>\n );\n}\n\nexport { TreeSelect };\nexport type { Props as TreeSelectProps };\n",
19
+ "path": "registry/ui/tree-select.tsx",
20
+ "target": "components/ui/tree-select.tsx",
21
+ "type": "registry:ui"
22
+ }
23
+ ]
24
+ }
@@ -0,0 +1,64 @@
1
+ {
2
+ "$schema": "https://ui.shadcn.com/schema/registry-item.json",
3
+ "name": "tree-view",
4
+ "type": "registry:ui",
5
+ "title": "Tree View",
6
+ "description": "Tree View component for @visulima/tui.",
7
+ "categories": [
8
+ "ui"
9
+ ],
10
+ "dependencies": [
11
+ "@visulima/tui"
12
+ ],
13
+ "registryDependencies": [],
14
+ "files": [
15
+ {
16
+ "content": "// fallow-ignore-file unused-type -- public component API barrel; re-exports consumed by library users, not internally.\n// fallow-ignore-file unused-export -- public component API barrel; re-exports consumed by library users, not internally.\nexport type { Theme as TreeViewTheme } from \"./theme\";\nexport { theme as treeViewTheme } from \"./theme\";\nexport type { FlatNode } from \"./tree-node-map\";\nexport { TreeNodeMap } from \"./tree-node-map\";\nexport type { Props as TreeViewProps } from \"./tree-view\";\nexport { TreeView } from \"./tree-view\";\nexport type { AsyncChildrenFunction, SelectionMode, TreeNode, TreeNodeRendererProps, TreeNodeState } from \"./types\";\nexport type { UseTreeViewProps } from \"./use-tree-view\";\nexport { useTreeView } from \"./use-tree-view\";\nexport type { TreeViewState, UseTreeViewStateProps } from \"./use-tree-view-state\";\nexport { useTreeViewState } from \"./use-tree-view-state\";\n",
17
+ "path": "registry/ui/tree-view/index.ts",
18
+ "target": "components/ui/tree-view/index.ts",
19
+ "type": "registry:ui"
20
+ },
21
+ {
22
+ "content": "import type { Props as BoxProps } from \"@visulima/tui/components/box\";\nimport type { Props as TextProps } from \"@visulima/tui/components/text\";\n\ntype StyleFunctionProps = {\n readonly depth?: number;\n readonly hasChildren?: boolean;\n readonly isExpanded?: boolean;\n readonly isFocused?: boolean;\n readonly isLoading?: boolean;\n readonly isSelected?: boolean;\n};\n\nconst theme = {\n styles: {\n container: (): BoxProps => {\n return {\n flexDirection: \"column\" as const,\n };\n },\n expandIndicator: (_props: StyleFunctionProps): TextProps => {\n return {\n color: \"gray\",\n };\n },\n focusIndicator: (): TextProps => {\n return {\n color: \"blue\",\n };\n },\n indent: ({ depth }: StyleFunctionProps): BoxProps => {\n return {\n width: (depth ?? 0) * 2,\n };\n },\n label: ({ isFocused, isSelected }: StyleFunctionProps): TextProps => {\n let color: string | undefined;\n\n if (isSelected) {\n color = \"green\";\n }\n\n if (isFocused) {\n color = \"blue\";\n }\n\n return { color };\n },\n loadingIndicator: (): TextProps => {\n return {\n color: \"yellow\",\n };\n },\n node: ({ isFocused }: StyleFunctionProps): BoxProps => {\n return {\n gap: 1,\n paddingLeft: isFocused ? 0 : 2,\n };\n },\n selectedIndicator: (): TextProps => {\n return {\n color: \"green\",\n };\n },\n },\n};\n\nexport { theme };\nexport type Theme = typeof theme;\n",
23
+ "path": "registry/ui/tree-view/theme.ts",
24
+ "target": "components/ui/tree-view/theme.ts",
25
+ "type": "registry:ui"
26
+ },
27
+ {
28
+ "content": "import type { TreeNode } from \"./types\";\n\n/**\n * A flattened representation of a tree node with navigation links.\n */\nexport type FlatNode<T = Record<string, unknown>> = {\n /** Ordered list of direct children IDs. */\n readonly childrenIds: ReadonlyArray<string>;\n /** Depth in the tree (0 for roots). */\n readonly depth: number;\n /** Index in the flattened DFS order (across ALL nodes, not just visible). */\n readonly flatIndex: number;\n /** Whether this node has children. */\n readonly hasChildren: boolean;\n /** Next sibling's ID, or undefined. */\n readonly nextSiblingId: string | undefined;\n /** The original tree node. */\n readonly node: TreeNode<T>;\n /** Parent's id, or undefined if root. */\n readonly parentId: string | undefined;\n /** Previous sibling's ID, or undefined. */\n readonly previousSiblingId: string | undefined;\n /** Total number of siblings (including this node). */\n readonly siblingCount: number;\n /** Zero-based index among siblings. */\n readonly siblingIndex: number;\n};\n\ntype StackEntry<T> = {\n readonly depth: number;\n readonly node: TreeNode<T>;\n readonly parentId: string | undefined;\n readonly siblingIndex: number;\n readonly siblings: TreeNode<T>[];\n};\n\n/**\n * A flattened map of all tree nodes built via DFS traversal.\n * Stores parent/child/sibling links for O(1) navigation.\n */\nexport class TreeNodeMap<T = Record<string, unknown>> {\n /** All node IDs in DFS order. */\n readonly orderedIds: string[];\n\n /** Root-level node IDs. */\n readonly rootIds: string[];\n\n /** Map from node ID to FlatNode. */\n private readonly map: Map<string, FlatNode<T>>;\n\n constructor(data: TreeNode<T>[]) {\n this.map = new Map();\n this.orderedIds = [];\n this.rootIds = [];\n\n this.buildFromData(data);\n }\n\n /**\n * Iterate over all entries.\n */\n entries(): IterableIterator<[string, FlatNode<T>]> {\n return this.map.entries();\n }\n\n /**\n * Get a flat node by ID.\n */\n get(id: string): FlatNode<T> | undefined {\n return this.map.get(id);\n }\n\n /**\n * Total number of nodes in the tree.\n */\n get size(): number {\n return this.map.size;\n }\n\n /**\n * Given a set of expanded node IDs, return the ordered list of\n * VISIBLE node IDs (i.e., a node is visible if all its ancestors\n * are expanded).\n *\n * Uses iterative DFS, skipping collapsed subtrees.\n */\n getVisibleIds(expandedIds: ReadonlySet<string>): string[] {\n const result: string[] = [];\n const stack: string[] = [];\n\n // Push root IDs in reverse so first root is processed first\n for (let index = this.rootIds.length - 1; index >= 0; index--) {\n stack.push(this.rootIds[index]!);\n }\n\n while (stack.length > 0) {\n const id = stack.pop()!;\n\n result.push(id);\n\n const flatNode = this.map.get(id);\n\n if (flatNode && expandedIds.has(id) && flatNode.childrenIds.length > 0) {\n // Push children in reverse so first child is processed first\n for (let index = flatNode.childrenIds.length - 1; index >= 0; index--) {\n stack.push(flatNode.childrenIds[index]!);\n }\n }\n }\n\n return result;\n }\n\n /**\n * Check if a node is a descendant of another node.\n */\n isDescendantOf(nodeId: string, ancestorId: string): boolean {\n let currentId: string | undefined = nodeId;\n\n while (currentId !== undefined) {\n const flat = this.map.get(currentId);\n\n if (!flat) {\n return false;\n }\n\n if (flat.parentId === ancestorId) {\n return true;\n }\n\n currentId = flat.parentId;\n }\n\n return false;\n }\n\n /**\n * Insert dynamically-loaded children under a parent node.\n * Returns a new TreeNodeMap (immutable operation).\n *\n * Rebuilds the full tree from the current map state (not the original\n * input data), so successive calls for different parents are safe.\n */\n withChildren(parentId: string, children: TreeNode<T>[]): TreeNodeMap<T> {\n const parentFlat = this.map.get(parentId);\n\n if (!parentFlat) {\n return this;\n }\n\n // Rebuild tree from current map state so previous withChildren\n // mutations are preserved (the original node.children may be stale).\n const rebuildFromMap = (id: string): TreeNode<T> => {\n const flat = this.map.get(id)!;\n\n if (id === parentId) {\n return { ...flat.node, children };\n }\n\n if (flat.childrenIds.length > 0) {\n return { ...flat.node, children: flat.childrenIds.map((childId) => rebuildFromMap(childId)) };\n }\n\n return flat.node;\n };\n\n const rootData = this.rootIds.map((nodeId) => rebuildFromMap(nodeId));\n\n return new TreeNodeMap(rootData);\n }\n\n private buildFromData(data: TreeNode<T>[]): void {\n const stack: StackEntry<T>[] = [];\n\n // Push root nodes in reverse order so first root is processed first\n for (let index = data.length - 1; index >= 0; index--) {\n stack.push({\n depth: 0,\n node: data[index]!,\n parentId: undefined,\n siblingIndex: index,\n siblings: data,\n });\n }\n\n // Collect root IDs\n for (const node of data) {\n this.rootIds.push(node.id);\n }\n\n let flatIndex = 0;\n\n while (stack.length > 0) {\n const entry = stack.pop()!;\n const { depth, node, parentId, siblingIndex, siblings } = entry;\n\n // Check for duplicate IDs\n if (this.map.has(node.id)) {\n throw new Error(`TreeView: Duplicate node id '${node.id}' found. All node ids must be unique.`);\n }\n\n const children = node.children ?? [];\n const hasChildren = children.length > 0 || node.isParent === true;\n const childrenIds = children.map((c) => c.id);\n\n const previousSiblingId = siblingIndex > 0 ? siblings[siblingIndex - 1]!.id : undefined;\n const nextSiblingId = siblingIndex < siblings.length - 1 ? siblings[siblingIndex + 1]!.id : undefined;\n\n const flatNode: FlatNode<T> = {\n childrenIds,\n depth,\n flatIndex,\n hasChildren,\n nextSiblingId,\n node,\n parentId,\n previousSiblingId,\n siblingCount: siblings.length,\n siblingIndex,\n };\n\n this.map.set(node.id, flatNode);\n this.orderedIds.push(node.id);\n flatIndex++;\n\n // Push children in reverse order for correct DFS ordering\n if (hasChildren) {\n for (let index = children.length - 1; index >= 0; index--) {\n stack.push({\n depth: depth + 1,\n node: children[index]!,\n parentId: node.id,\n siblingIndex: index,\n siblings: children,\n });\n }\n }\n }\n }\n}\n",
29
+ "path": "registry/ui/tree-view/tree-node-map.ts",
30
+ "target": "components/ui/tree-view/tree-node-map.ts",
31
+ "type": "registry:ui"
32
+ },
33
+ {
34
+ "content": "/* eslint-disable react/function-component-definition */\n\n/**\n * TreeViewNode renders a single node in the tree with focus indicator,\n * indentation, expand/collapse indicator, label, and selection state.\n *\n * Ported from ink-tree-view by John Costa.\n * @see https://github.com/costajohnt/ink-tree-view\n *\n * MIT License\n * Copyright (c) John Costa\n */\nimport Box from \"@visulima/tui/components/box\";\nimport Text from \"@visulima/tui/components/text\";\nimport type { ReactElement } from \"react\";\n\nimport type { Theme } from \"./theme\";\nimport type { SelectionMode, TreeNode, TreeNodeState } from \"./types\";\n\n// Unicode symbols (avoids external `figures` dependency)\nconst POINTER = \"❯\";\nconst TRIANGLE_DOWN = \"▾\";\nconst TRIANGLE_RIGHT = \"▸\";\nconst CHECKBOX_ON = \"☒\";\nconst CHECKBOX_OFF = \"☐\";\nconst TICK = \"✔\";\nconst LOADING = \"⟳\";\n\ntype Props<T = Record<string, unknown>> = {\n readonly isScreenReaderEnabled: boolean;\n readonly node: TreeNode<T>;\n readonly nodeState: TreeNodeState;\n readonly selectionMode: SelectionMode;\n readonly siblingCount: number;\n readonly siblingPosition: number;\n readonly styles: Theme[\"styles\"];\n};\n\n// eslint-disable-next-line react-refresh/only-export-components -- intentionally co-located helper\nexport function buildNodeAriaLabel(label: string, nodeState: TreeNodeState, siblingPosition: number, siblingCount: number): string {\n const { depth, hasChildren, isExpanded, isLoading, isSelected } = nodeState;\n const parts: string[] = [label, `item ${siblingPosition} of ${siblingCount}`];\n\n if (depth > 0) {\n parts.push(`depth ${depth}`);\n }\n\n if (hasChildren) {\n parts.push(isExpanded ? \"expanded\" : \"collapsed\");\n }\n\n if (isLoading) {\n parts.push(\"loading\");\n }\n\n if (isSelected) {\n parts.push(\"selected\");\n }\n\n return parts.join(\", \");\n}\n\n// eslint-disable-next-line react-refresh/only-export-components -- intentionally co-located helper\nexport function buildNodeAriaState(nodeState: TreeNodeState, selectionMode: SelectionMode): { expanded?: boolean; selected?: boolean } | undefined {\n const state: { expanded?: boolean; selected?: boolean } = {};\n\n if (nodeState.hasChildren) {\n state.expanded = nodeState.isExpanded;\n }\n\n if (selectionMode !== \"none\") {\n state.selected = nodeState.isSelected;\n }\n\n return Object.keys(state).length > 0 ? state : undefined;\n}\n\nexport function TreeViewNode<T>({ isScreenReaderEnabled, node, nodeState, selectionMode, siblingCount, siblingPosition, styles }: Props<T>): ReactElement {\n const { depth, hasChildren, isExpanded, isFocused, isLoading, isSelected } = nodeState;\n\n // Determine expand/collapse indicator\n let expandChar = \" \";\n\n if (isLoading) {\n expandChar = LOADING;\n } else if (hasChildren) {\n expandChar = isExpanded ? TRIANGLE_DOWN : TRIANGLE_RIGHT;\n }\n\n const ariaLabel = isScreenReaderEnabled ? buildNodeAriaLabel(node.label, nodeState, siblingPosition, siblingCount) : undefined;\n\n return (\n <Box {...styles.node({ isFocused })} aria-role=\"listitem\" aria-state={buildNodeAriaState(nodeState, selectionMode)}>\n {ariaLabel && <Text aria-label={ariaLabel} />}\n {/* Focus indicator */}\n {isFocused && (\n <Text {...styles.focusIndicator()} aria-hidden>\n {POINTER}\n </Text>\n )}\n\n {/* Indentation based on depth */}\n {depth > 0 && <Box {...styles.indent({ depth })} aria-hidden />}\n\n {/* Multi-select checkbox */}\n {selectionMode === \"multiple\" && (\n <Text {...styles.selectedIndicator()} aria-hidden>\n {isSelected ? CHECKBOX_ON : CHECKBOX_OFF}\n </Text>\n )}\n\n {/* Expand/collapse indicator */}\n <Text {...(isLoading ? styles.loadingIndicator() : styles.expandIndicator({ isExpanded }))} aria-hidden>\n {expandChar}\n </Text>\n\n {/* Node label */}\n <Text {...styles.label({ isFocused, isSelected })}>{node.label}</Text>\n\n {/* Single-select tick */}\n {selectionMode === \"single\" && isSelected && (\n <Text {...styles.selectedIndicator()} aria-hidden>\n {\" \"}\n {TICK}\n </Text>\n )}\n </Box>\n );\n}\n",
35
+ "path": "registry/ui/tree-view/tree-view-node.tsx",
36
+ "target": "components/ui/tree-view/tree-view-node.tsx",
37
+ "type": "registry:ui"
38
+ },
39
+ {
40
+ "content": "/* eslint-disable react/function-component-definition */\n\n/**\n * TreeView component for terminal UIs with keyboard navigation,\n * expand/collapse, single/multi selection, async children loading,\n * and viewport virtualization.\n *\n * Ported from ink-tree-view by John Costa.\n * @see https://github.com/costajohnt/ink-tree-view\n *\n * MIT License\n * Copyright (c) John Costa\n */\nimport Box from \"@visulima/tui/components/box\";\nimport Text from \"@visulima/tui/components/text\";\nimport useIsScreenReaderEnabled from \"@visulima/tui/hooks/use-is-screen-reader-enabled\";\nimport type { ReactElement, ReactNode } from \"react\";\n\nimport { theme } from \"./theme\";\nimport { buildNodeAriaLabel, buildNodeAriaState, TreeViewNode } from \"./tree-view-node\";\nimport type { AsyncChildrenFunction, SelectionMode, TreeNode, TreeNodeRendererProps } from \"./types\";\nimport { useTreeView } from \"./use-tree-view\";\nimport { useTreeViewState } from \"./use-tree-view-state\";\n\nexport type Props<T = Record<string, unknown>> = {\n /**\n * Accessible label for the tree container.\n * Useful when multiple tree views are on screen.\n * @default \"Tree view\"\n */\n readonly ariaLabel?: string;\n\n /**\n * The tree data. Array of root-level nodes.\n *\n * **Important:** Must be referentially stable across renders (e.g. stored\n * in state or wrapped in `useMemo`). Passing a new array reference on\n * every render will reset focus, expansion, and selection state.\n */\n readonly data: TreeNode<T>[];\n\n /**\n * Set of node IDs that are expanded by default.\n * If not provided, all nodes start collapsed.\n */\n readonly defaultExpanded?: ReadonlySet<string> | \"all\";\n\n /**\n * Set of node IDs that are selected by default.\n */\n readonly defaultSelected?: ReadonlySet<string>;\n\n /**\n * When disabled, user input is ignored.\n * @default false\n */\n readonly isDisabled?: boolean;\n\n /**\n * Async function to load children on demand.\n */\n readonly loadChildren?: AsyncChildrenFunction<T>;\n\n /** Called when expanded set changes. */\n readonly onExpandChange?: (expandedIds: ReadonlySet<string>) => void;\n\n /** Called when the focused node changes. */\n readonly onFocusChange?: (nodeId: string) => void;\n\n /**\n * Called when `loadChildren` rejects. Receives the node ID and error.\n */\n readonly onLoadError?: (nodeId: string, error: Error) => void;\n\n /** Called when selection changes. */\n readonly onSelectChange?: (selectedIds: ReadonlySet<string>) => void;\n\n /**\n * Custom node renderer. Receives node + state, returns Ink JSX.\n */\n readonly renderNode?: (props: TreeNodeRendererProps<T>) => ReactNode;\n\n /**\n * Selection mode.\n * - \"none\": No selection behavior (default).\n * - \"single\": One node can be selected at a time.\n * - \"multiple\": Multiple nodes can be selected (checkboxes shown).\n * @default \"none\"\n */\n readonly selectionMode?: SelectionMode;\n\n /**\n * Number of visible nodes in the viewport (for virtualization).\n * @default Infinity (no virtualization)\n */\n readonly visibleNodeCount?: number;\n};\n\n/**\n * Renders an interactive tree view with keyboard navigation.\n *\n * ```tsx\n * &lt;TreeView\n * data={[\n * { id: \"1\", label: \"Root\", children: [\n * { id: \"1.1\", label: \"Child\" },\n * ]},\n * ]}\n * defaultExpanded=\"all\"\n * />\n * ```\n */\nexport function TreeView<T = Record<string, unknown>>({\n ariaLabel = \"Tree view\",\n data,\n defaultExpanded,\n defaultSelected,\n isDisabled = false,\n loadChildren,\n onExpandChange,\n onFocusChange,\n onLoadError,\n onSelectChange,\n renderNode,\n selectionMode = \"none\",\n visibleNodeCount,\n}: Props<T>): ReactElement {\n const state = useTreeViewState<T>({\n data,\n defaultExpanded,\n defaultSelected,\n onExpandChange,\n onFocusChange,\n onSelectChange,\n selectionMode,\n visibleNodeCount,\n });\n\n useTreeView<T>({\n isDisabled,\n loadChildren,\n onLoadError,\n selectionMode,\n state,\n });\n\n const { styles } = theme;\n const isScreenReaderEnabled = useIsScreenReaderEnabled();\n\n return (\n // Ink's aria-role enum does not include \"tree\"/\"treeitem\"/\"group\",\n // so we use \"list\"/\"listitem\" as the closest available semantic match.\n <Box {...styles.container()} aria-role=\"list\">\n {isScreenReaderEnabled && <Text aria-label={ariaLabel} />}\n {state.hasScrollUp && (\n <Text aria-label={`${state.viewportFromIndex} more items above`} dimColor>\n {\" \"}\n↑\n{state.viewportFromIndex}\n{\" \"}\nmore above\n </Text>\n )}\n {state.viewportNodes.map(({ node, state: nodeState }) => {\n const flatNode = state.nodeMap.get(node.id);\n const siblingPosition = flatNode ? flatNode.siblingIndex + 1 : 1;\n const siblingCount = flatNode ? flatNode.siblingCount : 1;\n\n if (renderNode) {\n const nodeAriaLabel = isScreenReaderEnabled ? buildNodeAriaLabel(node.label, nodeState, siblingPosition, siblingCount) : undefined;\n\n return (\n <Box aria-role=\"listitem\" aria-state={buildNodeAriaState(nodeState, selectionMode)} key={node.id}>\n {nodeAriaLabel && <Text aria-label={nodeAriaLabel} />}\n {renderNode({ node, state: nodeState })}\n </Box>\n );\n }\n\n return (\n <TreeViewNode\n isScreenReaderEnabled={isScreenReaderEnabled}\n key={node.id}\n node={node}\n nodeState={nodeState}\n selectionMode={selectionMode}\n siblingCount={siblingCount}\n siblingPosition={siblingPosition}\n styles={styles}\n />\n );\n })}\n {state.hasScrollDown && (\n <Text aria-label={`${state.visibleCount - state.viewportToIndex} more items below`} dimColor>\n {\" \"}\n↓\n{state.visibleCount - state.viewportToIndex}\n{\" \"}\nmore below\n </Text>\n )}\n </Box>\n );\n}\n",
41
+ "path": "registry/ui/tree-view/tree-view.tsx",
42
+ "target": "components/ui/tree-view/tree-view.tsx",
43
+ "type": "registry:ui"
44
+ },
45
+ {
46
+ "content": "/**\n * A single node in the tree. Generic over the user's data shape.\n * The `id` field MUST be unique across the entire tree.\n */\nexport type TreeNode<T = Record<string, unknown>> = {\n /** Child nodes. Undefined or empty array means leaf node. */\n readonly children?: TreeNode<T>[];\n /** Arbitrary user data attached to this node. */\n readonly data?: T;\n /** Unique identifier for this node. Used as the key for all lookups. */\n readonly id: string;\n\n /**\n * Explicitly marks this node as a parent that can have children loaded\n * asynchronously via `loadChildren`. When true, the node shows an expand\n * indicator even if `children` is empty or undefined, and expanding it\n * triggers the `loadChildren` callback.\n */\n readonly isParent?: boolean;\n /** Display label. Used by the default renderer. */\n readonly label: string;\n};\n\n/**\n * For async/lazy loading: a function that resolves children on demand.\n */\nexport type AsyncChildrenFunction<T = Record<string, unknown>> = (node: TreeNode<T>) => Promise<TreeNode<T>[]>;\n\n/**\n * State passed to custom renderers and theme functions.\n */\nexport type TreeNodeState = {\n /** Depth of this node in the tree (root = 0). */\n readonly depth: number;\n /** Whether this node has children (or a lazy loader). */\n readonly hasChildren: boolean;\n /** Whether this node is expanded (children visible). */\n readonly isExpanded: boolean;\n /** Whether this node is currently focused (cursor is on it). */\n readonly isFocused: boolean;\n /** Whether children are currently loading (async mode). */\n readonly isLoading: boolean;\n /** Whether this node is selected (in select/multi-select mode). */\n readonly isSelected: boolean;\n};\n\n/**\n * Props received by a custom node renderer.\n */\nexport type TreeNodeRendererProps<T = Record<string, unknown>> = {\n /** The tree node data. */\n readonly node: TreeNode<T>;\n /** Current state of this node. */\n readonly state: TreeNodeState;\n};\n\n/**\n * Selection mode for the tree view.\n */\nexport type SelectionMode = \"multiple\" | \"none\" | \"single\";\n",
47
+ "path": "registry/ui/tree-view/types.ts",
48
+ "target": "components/ui/tree-view/types.ts",
49
+ "type": "registry:ui"
50
+ },
51
+ {
52
+ "content": "import { useCallback, useEffect, useMemo, useReducer, useRef, useState } from \"react\";\n\nimport { TreeNodeMap } from \"./tree-node-map\";\nimport type { SelectionMode, TreeNode, TreeNodeState } from \"./types\";\n\n// ─── State ──────────────────────────────────────────────────────────────────\n\nexport type State<T> = {\n expandedIds: Set<string>;\n focusedId: string | undefined;\n loadingIds: Set<string>;\n nodeMap: TreeNodeMap<T>;\n previousExpandedIds: Set<string>;\n previousSelectedIds: Set<string>;\n selectedIds: Set<string>;\n selectionMode: SelectionMode;\n viewportFromIndex: number;\n viewportToIndex: number;\n /** O(1) lookup from node ID to index in visibleIds. */\n visibleIdIndex: Map<string, number>;\n visibleIds: string[];\n visibleNodeCount: number;\n};\n\n// ─── Actions ────────────────────────────────────────────────────────────────\n\nexport type Action<T>\n = | { nodeId: string; type: \"collapse-node\" }\n | { nodeId: string; type: \"expand-node\" }\n | { nodeId: string; type: \"set-children-error\" }\n | { children: TreeNode<T>[]; parentId: string; type: \"insert-children\" }\n | { isLoading: boolean; nodeId: string; type: \"set-loading\" }\n | { state: State<T>; type: \"reset\" }\n | { type: \"collapse\" }\n | { type: \"collapse-all\" }\n | { type: \"expand\" }\n | { type: \"expand-all\" }\n | { type: \"focus-first\" }\n | { type: \"focus-first-child\" }\n | { type: \"focus-last\" }\n | { type: \"focus-next\" }\n | { type: \"focus-parent\" }\n | { type: \"focus-previous\" }\n | { type: \"select\" }\n | { type: \"toggle-expanded\" };\n\n// ─── Index helper ──────────────────────────────────────────────────────────\n\nfunction buildVisibleIdIndex(visibleIds: string[]): Map<string, number> {\n const map = new Map<string, number>();\n\n for (const [index, visibleId] of visibleIds.entries()) {\n map.set(visibleId, index);\n }\n\n return map;\n}\n\n// ─── Viewport helper ────────────────────────────────────────────────────────\n\nfunction adjustViewport<T>(state: State<T>, targetIndex: number): { viewportFromIndex: number; viewportToIndex: number } {\n if (state.visibleNodeCount >= state.visibleIds.length) {\n return { viewportFromIndex: 0, viewportToIndex: state.visibleIds.length };\n }\n\n let from = state.viewportFromIndex;\n let to = state.viewportToIndex;\n\n // Scroll down: target is at or past the bottom of viewport\n if (targetIndex >= to) {\n to = targetIndex + 1;\n from = to - state.visibleNodeCount;\n }\n\n // Scroll up: target is before the top of viewport\n if (targetIndex < from) {\n from = targetIndex;\n to = from + state.visibleNodeCount;\n }\n\n return {\n viewportFromIndex: Math.max(0, from),\n viewportToIndex: Math.min(state.visibleIds.length, to),\n };\n}\n\nfunction adjustViewportForNewVisible<T>(state: State<T>, newVisible: string[]): { viewportFromIndex: number; viewportToIndex: number } {\n if (state.visibleNodeCount >= newVisible.length) {\n return { viewportFromIndex: 0, viewportToIndex: newVisible.length };\n }\n\n // Keep the focused node in the viewport\n const focusedIndex = state.focusedId ? newVisible.indexOf(state.focusedId) : -1;\n\n if (focusedIndex < 0) {\n return {\n viewportFromIndex: 0,\n viewportToIndex: Math.min(state.visibleNodeCount, newVisible.length),\n };\n }\n\n // Clamp to new bounds\n let to = Math.min(state.viewportToIndex, newVisible.length);\n let from = Math.max(0, to - state.visibleNodeCount);\n\n // Ensure focused node is visible\n if (focusedIndex >= to) {\n to = focusedIndex + 1;\n from = to - state.visibleNodeCount;\n }\n\n if (focusedIndex < from) {\n from = focusedIndex;\n to = from + state.visibleNodeCount;\n }\n\n return {\n viewportFromIndex: Math.max(0, from),\n viewportToIndex: Math.min(newVisible.length, to),\n };\n}\n\n// ─── Reducer ────────────────────────────────────────────────────────────────\n\nexport function reducer<T>(state: State<T>, action: Action<T>): State<T> {\n switch (action.type) {\n case \"collapse\": {\n if (!state.focusedId) {\n return state;\n }\n\n return reducer(state, { nodeId: state.focusedId, type: \"collapse-node\" });\n }\n\n case \"collapse-all\": {\n const newExpanded = new Set<string>();\n const newVisible = state.nodeMap.getVisibleIds(newExpanded);\n\n return {\n ...state,\n expandedIds: newExpanded,\n focusedId: newVisible[0],\n previousExpandedIds: state.expandedIds,\n viewportFromIndex: 0,\n viewportToIndex: Math.min(state.visibleNodeCount, newVisible.length),\n visibleIdIndex: buildVisibleIdIndex(newVisible),\n visibleIds: newVisible,\n };\n }\n\n case \"collapse-node\": {\n const { nodeId } = action;\n\n if (!state.expandedIds.has(nodeId)) {\n return state;\n }\n\n const newExpanded = new Set(state.expandedIds);\n\n newExpanded.delete(nodeId);\n const newVisible = state.nodeMap.getVisibleIds(newExpanded);\n\n // Collapse-focus invariant: if the focused node is a descendant\n // of the collapsed node, move focus to the collapsed node.\n let newFocusedId = state.focusedId;\n\n if (newFocusedId !== undefined && newFocusedId !== nodeId && state.nodeMap.isDescendantOf(newFocusedId, nodeId)) {\n newFocusedId = nodeId;\n }\n\n return {\n ...state,\n expandedIds: newExpanded,\n focusedId: newFocusedId,\n previousExpandedIds: state.expandedIds,\n visibleIdIndex: buildVisibleIdIndex(newVisible),\n visibleIds: newVisible,\n ...adjustViewportForNewVisible({ ...state, focusedId: newFocusedId, visibleIds: newVisible }, newVisible),\n };\n }\n\n case \"expand\": {\n if (!state.focusedId) {\n return state;\n }\n\n return reducer(state, { nodeId: state.focusedId, type: \"expand-node\" });\n }\n\n case \"expand-all\": {\n const allParentIds = new Set<string>();\n\n for (const [id, flat] of state.nodeMap.entries()) {\n if (flat.hasChildren) {\n allParentIds.add(id);\n }\n }\n\n const newVisible = state.nodeMap.getVisibleIds(allParentIds);\n\n return {\n ...state,\n expandedIds: allParentIds,\n previousExpandedIds: state.expandedIds,\n visibleIdIndex: buildVisibleIdIndex(newVisible),\n visibleIds: newVisible,\n ...adjustViewportForNewVisible({ ...state, visibleIds: newVisible }, newVisible),\n };\n }\n\n case \"expand-node\": {\n const { nodeId } = action;\n const flat = state.nodeMap.get(nodeId);\n\n if (!flat?.hasChildren) {\n return state;\n }\n\n if (state.expandedIds.has(nodeId)) {\n return state;\n }\n\n const newExpanded = new Set(state.expandedIds);\n\n newExpanded.add(nodeId);\n const newVisible = state.nodeMap.getVisibleIds(newExpanded);\n\n return {\n ...state,\n expandedIds: newExpanded,\n previousExpandedIds: state.expandedIds,\n visibleIdIndex: buildVisibleIdIndex(newVisible),\n visibleIds: newVisible,\n ...adjustViewportForNewVisible({ ...state, visibleIds: newVisible }, newVisible),\n };\n }\n\n case \"focus-first\": {\n if (state.visibleIds.length === 0) {\n return state;\n }\n\n return {\n ...state,\n focusedId: state.visibleIds[0],\n viewportFromIndex: 0,\n viewportToIndex: Math.min(state.visibleNodeCount, state.visibleIds.length),\n };\n }\n\n case \"focus-first-child\": {\n if (!state.focusedId) {\n return state;\n }\n\n const flat = state.nodeMap.get(state.focusedId);\n\n if (!flat || flat.childrenIds.length === 0) {\n return state;\n }\n\n if (!state.expandedIds.has(state.focusedId)) {\n return state;\n }\n\n const firstChildId = flat.childrenIds[0]!;\n const childIndex = state.visibleIdIndex.get(firstChildId) ?? -1;\n\n if (childIndex < 0) {\n return state;\n }\n\n return {\n ...state,\n focusedId: firstChildId,\n ...adjustViewport(state, childIndex),\n };\n }\n\n case \"focus-last\": {\n if (state.visibleIds.length === 0) {\n return state;\n }\n\n const lastIndex = state.visibleIds.length - 1;\n\n return {\n ...state,\n focusedId: state.visibleIds[lastIndex],\n ...adjustViewport(state, lastIndex),\n };\n }\n\n case \"focus-next\": {\n if (!state.focusedId) {\n return state;\n }\n\n const index = state.visibleIdIndex.get(state.focusedId) ?? -1;\n\n if (index < 0 || index >= state.visibleIds.length - 1) {\n return state;\n }\n\n const nextId = state.visibleIds[index + 1]!;\n\n return {\n ...state,\n focusedId: nextId,\n ...adjustViewport(state, index + 1),\n };\n }\n\n case \"focus-parent\": {\n if (!state.focusedId) {\n return state;\n }\n\n const flat = state.nodeMap.get(state.focusedId);\n\n if (!flat?.parentId) {\n return state;\n }\n\n const parentIndex = state.visibleIdIndex.get(flat.parentId) ?? -1;\n\n if (parentIndex < 0) {\n return state;\n }\n\n return {\n ...state,\n focusedId: flat.parentId,\n ...adjustViewport(state, parentIndex),\n };\n }\n\n case \"focus-previous\": {\n if (!state.focusedId) {\n return state;\n }\n\n const index = state.visibleIdIndex.get(state.focusedId) ?? -1;\n\n if (index <= 0) {\n return state;\n }\n\n const previousId = state.visibleIds[index - 1]!;\n\n return {\n ...state,\n focusedId: previousId,\n ...adjustViewport(state, index - 1),\n };\n }\n\n case \"insert-children\": {\n const parentFlat = state.nodeMap.get(action.parentId);\n\n if (!parentFlat) {\n return state;\n }\n\n const newNodeMap = state.nodeMap.withChildren(action.parentId, action.children);\n const newVisible = newNodeMap.getVisibleIds(state.expandedIds);\n\n return {\n ...state,\n nodeMap: newNodeMap,\n visibleIdIndex: buildVisibleIdIndex(newVisible),\n visibleIds: newVisible,\n };\n }\n\n case \"reset\": {\n return action.state;\n }\n\n case \"select\": {\n if (!state.focusedId) {\n return state;\n }\n\n if (state.selectionMode === \"none\") {\n return state;\n }\n\n if (state.selectionMode === \"single\") {\n const newSelected = new Set<string>([state.focusedId]);\n\n return {\n ...state,\n previousSelectedIds: state.selectedIds,\n selectedIds: newSelected,\n };\n }\n\n // 'multiple': toggle\n const newSelected = new Set(state.selectedIds);\n\n if (newSelected.has(state.focusedId)) {\n newSelected.delete(state.focusedId);\n } else {\n newSelected.add(state.focusedId);\n }\n\n return {\n ...state,\n previousSelectedIds: state.selectedIds,\n selectedIds: newSelected,\n };\n }\n\n case \"set-children-error\": {\n const newLoading = new Set(state.loadingIds);\n\n newLoading.delete(action.nodeId);\n\n return { ...state, loadingIds: newLoading };\n }\n\n case \"set-loading\": {\n const newLoading = new Set(state.loadingIds);\n\n if (action.isLoading) {\n newLoading.add(action.nodeId);\n } else {\n newLoading.delete(action.nodeId);\n }\n\n return { ...state, loadingIds: newLoading };\n }\n\n case \"toggle-expanded\": {\n if (!state.focusedId) {\n return state;\n }\n\n if (state.expandedIds.has(state.focusedId)) {\n return reducer(state, { type: \"collapse\" });\n }\n\n return reducer(state, { type: \"expand\" });\n }\n\n default: {\n return state;\n }\n }\n}\n\n// ─── Default state factory ──────────────────────────────────────────────────\n\nexport type CreateDefaultStateProps<T> = {\n data: TreeNode<T>[];\n defaultExpanded?: ReadonlySet<string> | \"all\";\n defaultSelected?: ReadonlySet<string>;\n selectionMode: SelectionMode;\n visibleNodeCount: number;\n};\n\nexport function createDefaultState<T>({ data, defaultExpanded, defaultSelected, selectionMode, visibleNodeCount }: CreateDefaultStateProps<T>): State<T> {\n const nodeMap = new TreeNodeMap(data);\n\n let expandedIds: Set<string>;\n\n if (defaultExpanded === \"all\") {\n expandedIds = new Set<string>();\n\n for (const [id, flat] of nodeMap.entries()) {\n if (flat.hasChildren) {\n expandedIds.add(id);\n }\n }\n } else if (defaultExpanded) {\n expandedIds = new Set(defaultExpanded);\n } else {\n expandedIds = new Set();\n }\n\n const visibleIds = nodeMap.getVisibleIds(expandedIds);\n const selectedIds = selectionMode !== \"none\" && defaultSelected ? new Set(defaultSelected) : new Set<string>();\n\n const nodeCount = Math.min(visibleNodeCount, visibleIds.length);\n\n return {\n expandedIds,\n focusedId: visibleIds[0],\n loadingIds: new Set(),\n nodeMap,\n previousExpandedIds: expandedIds,\n previousSelectedIds: selectedIds,\n selectedIds,\n selectionMode,\n viewportFromIndex: 0,\n viewportToIndex: nodeCount,\n visibleIdIndex: buildVisibleIdIndex(visibleIds),\n visibleIds,\n visibleNodeCount,\n };\n}\n\n// ─── Public hook types ──────────────────────────────────────────────────────\n\nexport type UseTreeViewStateProps<T = Record<string, unknown>> = {\n /** The tree data. Array of root-level nodes. */\n readonly data: TreeNode<T>[];\n\n /**\n * Set of node IDs that are expanded by default.\n * If not provided, all nodes start collapsed.\n */\n readonly defaultExpanded?: ReadonlySet<string> | \"all\";\n\n /**\n * Set of node IDs that are selected by default.\n */\n readonly defaultSelected?: ReadonlySet<string>;\n /** Called when expanded set changes. */\n readonly onExpandChange?: (expandedIds: ReadonlySet<string>) => void;\n /** Called when the focused node changes. */\n readonly onFocusChange?: (nodeId: string) => void;\n /** Called when selection changes. */\n readonly onSelectChange?: (selectedIds: ReadonlySet<string>) => void;\n\n /**\n * Selection mode.\n * @default \"none\"\n */\n readonly selectionMode?: SelectionMode;\n\n /**\n * Number of visible nodes in the viewport (for virtualization).\n * @default Infinity\n */\n readonly visibleNodeCount?: number;\n};\n\nexport type TreeViewState<T = Record<string, unknown>> = {\n readonly collapse: () => void;\n readonly collapseAll: () => void;\n readonly collapseNode: (nodeId: string) => void;\n readonly expand: () => void;\n readonly expandAll: () => void;\n readonly expandedIds: ReadonlySet<string>;\n readonly expandNode: (nodeId: string) => void;\n readonly focusedId: string | undefined;\n readonly focusFirst: () => void;\n readonly focusFirstChild: () => void;\n readonly focusLast: () => void;\n readonly focusNext: () => void;\n readonly focusParent: () => void;\n readonly focusPrevious: () => void;\n readonly hasScrollDown: boolean;\n readonly hasScrollUp: boolean;\n readonly insertChildren: (parentId: string, children: TreeNode<T>[]) => void;\n readonly loadingIds: ReadonlySet<string>;\n readonly nodeMap: TreeNodeMap<T>;\n readonly select: () => void;\n readonly selectedIds: ReadonlySet<string>;\n readonly setChildrenError: (nodeId: string) => void;\n readonly setLoading: (nodeId: string, isLoading: boolean) => void;\n readonly toggleExpanded: () => void;\n readonly viewportFromIndex: number;\n readonly viewportNodes: { node: TreeNode<T>; state: TreeNodeState }[];\n readonly viewportToIndex: number;\n readonly visibleCount: number;\n};\n\n// ─── Hook ───────────────────────────────────────────────────────────────────\n\nexport function useTreeViewState<T = Record<string, unknown>>({\n data,\n defaultExpanded,\n defaultSelected,\n onExpandChange,\n onFocusChange,\n onSelectChange,\n selectionMode = \"none\",\n visibleNodeCount = Infinity,\n}: UseTreeViewStateProps<T>): TreeViewState<T> {\n const [state, dispatch] = useReducer(reducer<T>, { data, defaultExpanded, defaultSelected, selectionMode, visibleNodeCount }, createDefaultState<T>);\n\n // Detect data changes and reset\n const [lastData, setLastData] = useState(data);\n\n if (data !== lastData) {\n dispatch({\n state: createDefaultState({\n data,\n defaultExpanded,\n defaultSelected,\n selectionMode,\n visibleNodeCount,\n }),\n type: \"reset\",\n });\n setLastData(data);\n }\n\n // Store callbacks in refs to avoid infinite loops when consumers\n // pass inline functions (new reference every render)\n const onFocusChangeReference = useRef(onFocusChange);\n\n onFocusChangeReference.current = onFocusChange;\n const onExpandChangeReference = useRef(onExpandChange);\n\n onExpandChangeReference.current = onExpandChange;\n const onSelectChangeReference = useRef(onSelectChange);\n\n onSelectChangeReference.current = onSelectChange;\n\n // Fire callbacks on state changes (skip initial mount for onFocusChange)\n const isInitialMount = useRef(true);\n\n useEffect(() => {\n if (isInitialMount.current) {\n isInitialMount.current = false;\n\n return;\n }\n\n if (state.focusedId) {\n onFocusChangeReference.current?.(state.focusedId);\n }\n }, [state.focusedId]);\n\n useEffect(() => {\n if (state.expandedIds !== state.previousExpandedIds) {\n onExpandChangeReference.current?.(state.expandedIds);\n }\n }, [state.expandedIds, state.previousExpandedIds]);\n\n useEffect(() => {\n if (state.selectedIds !== state.previousSelectedIds) {\n onSelectChangeReference.current?.(state.selectedIds);\n }\n }, [state.selectedIds, state.previousSelectedIds]);\n\n // Compute viewportNodes from state\n const viewportNodes = useMemo(\n () =>\n state.visibleIds.slice(state.viewportFromIndex, state.viewportToIndex).map((id) => {\n const flat = state.nodeMap.get(id)!;\n\n return {\n node: flat.node,\n state: {\n depth: flat.depth,\n hasChildren: flat.hasChildren,\n isExpanded: state.expandedIds.has(id),\n isFocused: id === state.focusedId,\n isLoading: state.loadingIds.has(id),\n isSelected: state.selectedIds.has(id),\n } satisfies TreeNodeState,\n };\n }),\n [\n state.visibleIds,\n state.viewportFromIndex,\n state.viewportToIndex,\n state.focusedId,\n state.expandedIds,\n state.selectedIds,\n state.nodeMap,\n state.loadingIds,\n ],\n );\n\n const focusNext = useCallback(() => {\n dispatch({ type: \"focus-next\" });\n }, []);\n const focusPrevious = useCallback(() => {\n dispatch({ type: \"focus-previous\" });\n }, []);\n const focusFirst = useCallback(() => {\n dispatch({ type: \"focus-first\" });\n }, []);\n const focusLast = useCallback(() => {\n dispatch({ type: \"focus-last\" });\n }, []);\n const expand = useCallback(() => {\n dispatch({ type: \"expand\" });\n }, []);\n const expandNode = useCallback((nodeId: string) => {\n dispatch({ nodeId, type: \"expand-node\" });\n }, []);\n const collapse = useCallback(() => {\n dispatch({ type: \"collapse\" });\n }, []);\n const collapseNode = useCallback((nodeId: string) => {\n dispatch({ nodeId, type: \"collapse-node\" });\n }, []);\n const toggleExpanded = useCallback(() => {\n dispatch({ type: \"toggle-expanded\" });\n }, []);\n const expandAll = useCallback(() => {\n dispatch({ type: \"expand-all\" });\n }, []);\n const collapseAll = useCallback(() => {\n dispatch({ type: \"collapse-all\" });\n }, []);\n const select = useCallback(() => {\n dispatch({ type: \"select\" });\n }, []);\n const focusParent = useCallback(() => {\n dispatch({ type: \"focus-parent\" });\n }, []);\n const focusFirstChild = useCallback(() => {\n dispatch({ type: \"focus-first-child\" });\n }, []);\n const setLoading = useCallback((nodeId: string, isLoading: boolean) => {\n dispatch({ isLoading, nodeId, type: \"set-loading\" });\n }, []);\n const setChildrenError = useCallback((nodeId: string) => {\n dispatch({ nodeId, type: \"set-children-error\" });\n }, []);\n const insertChildren = useCallback((parentId: string, children: TreeNode<T>[]) => {\n dispatch({ children, parentId, type: \"insert-children\" });\n }, []);\n\n return {\n collapse,\n collapseAll,\n collapseNode,\n expand,\n expandAll,\n expandedIds: state.expandedIds,\n expandNode,\n focusedId: state.focusedId,\n focusFirst,\n focusFirstChild,\n focusLast,\n focusNext,\n focusParent,\n focusPrevious,\n hasScrollDown: state.viewportToIndex < state.visibleIds.length,\n hasScrollUp: state.viewportFromIndex > 0,\n insertChildren,\n loadingIds: state.loadingIds,\n nodeMap: state.nodeMap,\n select,\n selectedIds: state.selectedIds,\n setChildrenError,\n setLoading,\n toggleExpanded,\n viewportFromIndex: state.viewportFromIndex,\n viewportNodes,\n viewportToIndex: state.viewportToIndex,\n visibleCount: state.visibleIds.length,\n };\n}\n",
53
+ "path": "registry/ui/tree-view/use-tree-view-state.ts",
54
+ "target": "components/ui/tree-view/use-tree-view-state.ts",
55
+ "type": "registry:ui"
56
+ },
57
+ {
58
+ "content": "import useInput from \"@visulima/tui/hooks/use-input\";\nimport { useCallback, useRef } from \"react\";\n\nimport type { AsyncChildrenFunction, SelectionMode } from \"./types\";\nimport type { TreeViewState } from \"./use-tree-view-state\";\n\nexport type UseTreeViewProps<T = Record<string, unknown>> = {\n /**\n * When disabled, user input is ignored.\n * @default false\n */\n readonly isDisabled?: boolean;\n\n /**\n * Async function to load children on demand.\n */\n readonly loadChildren?: AsyncChildrenFunction<T>;\n\n /**\n * Called when `loadChildren` rejects.\n */\n readonly onLoadError?: (nodeId: string, error: Error) => void;\n\n /**\n * Selection mode.\n */\n readonly selectionMode: SelectionMode;\n\n /**\n * The tree view state from `useTreeViewState`.\n */\n readonly state: TreeViewState<T>;\n};\n\nexport function useTreeView<T>({ isDisabled = false, loadChildren, onLoadError, selectionMode, state }: UseTreeViewProps<T>): void {\n const loadingReference = useRef(new Set<string>());\n const stateReference = useRef(state);\n\n stateReference.current = state;\n\n const loadChildrenReference = useRef(loadChildren);\n\n loadChildrenReference.current = loadChildren;\n\n const onLoadErrorReference = useRef(onLoadError);\n\n onLoadErrorReference.current = onLoadError;\n\n const triggerLoad = useCallback(async (nodeId: string) => {\n if (loadingReference.current.has(nodeId)) {\n return;\n }\n\n const currentLoadChildren = loadChildrenReference.current;\n\n if (!currentLoadChildren) {\n return;\n }\n\n const flat = stateReference.current.nodeMap.get(nodeId);\n\n if (!flat || flat.childrenIds.length > 0) {\n return;\n }\n\n loadingReference.current.add(nodeId);\n stateReference.current.setLoading(nodeId, true);\n\n try {\n const children = await currentLoadChildren(flat.node);\n\n stateReference.current.insertChildren(nodeId, children);\n stateReference.current.expandNode(nodeId);\n } catch (error: unknown) {\n const normalizedError = error instanceof Error ? error : new Error(String(error));\n\n stateReference.current.setChildrenError(nodeId);\n onLoadErrorReference.current?.(nodeId, normalizedError);\n } finally {\n stateReference.current.setLoading(nodeId, false);\n loadingReference.current.delete(nodeId);\n }\n }, []);\n\n useInput(\n (input, key) => {\n if (key.downArrow) {\n state.focusNext();\n\n return;\n }\n\n if (key.upArrow) {\n state.focusPrevious();\n\n return;\n }\n\n if (key.rightArrow) {\n if (state.focusedId && state.expandedIds.has(state.focusedId)) {\n // Already expanded: move to first child\n state.focusFirstChild();\n } else if (state.focusedId) {\n // Not expanded: try to expand\n if (loadChildren) {\n const flat = state.nodeMap.get(state.focusedId);\n\n if (flat && flat.hasChildren && flat.childrenIds.length === 0) {\n // Async load needed\n triggerLoad(state.focusedId).catch(() => {\n // Swallow async load errors\n });\n\n return;\n }\n }\n\n state.expand();\n }\n\n return;\n }\n\n if (key.leftArrow) {\n if (state.focusedId && state.expandedIds.has(state.focusedId)) {\n state.collapse();\n } else {\n state.focusParent();\n }\n\n return;\n }\n\n if (key.return) {\n if (selectionMode === \"none\") {\n state.toggleExpanded();\n } else {\n state.select();\n }\n\n return;\n }\n\n if (input === \" \") {\n if (selectionMode === \"multiple\") {\n state.select();\n } else {\n state.toggleExpanded();\n }\n\n return;\n }\n\n // Home key: input is the raw escape sequence\n if (input === \"\\u001B[H\" || input === \"\\u001B[1~\" || input === \"\\u001BOH\") {\n state.focusFirst();\n\n return;\n }\n\n // End key\n if (input === \"\\u001B[F\" || input === \"\\u001B[4~\" || input === \"\\u001BOF\") {\n state.focusLast();\n }\n },\n { isActive: !isDisabled },\n );\n}\n",
59
+ "path": "registry/ui/tree-view/use-tree-view.ts",
60
+ "target": "components/ui/tree-view/use-tree-view.ts",
61
+ "type": "registry:ui"
62
+ }
63
+ ]
64
+ }
@@ -0,0 +1,22 @@
1
+ {
2
+ "$schema": "https://ui.shadcn.com/schema/registry-item.json",
3
+ "name": "unordered-list",
4
+ "type": "registry:ui",
5
+ "title": "Unordered List",
6
+ "description": "Unordered List component for @visulima/tui.",
7
+ "categories": [
8
+ "ui"
9
+ ],
10
+ "dependencies": [
11
+ "@visulima/tui"
12
+ ],
13
+ "registryDependencies": [],
14
+ "files": [
15
+ {
16
+ "content": "/**\n * Unordered list component for Ink.\n *\n * Inspired by ink-ui UnorderedList by Vadim Demedes.\n * @see https://github.com/vadimdemedes/ink-ui\n *\n * MIT License\n * Copyright (c) Vadym Demedes (github.com/vadimdemedes)\n */\nimport Box from \"@visulima/tui/components/box\";\nimport Text from \"@visulima/tui/components/text\";\nimport type { ReactElement, ReactNode } from \"react\";\n\nconst DEFAULT_MARKERS: ReadonlyArray<string> = [\"─\", \"◦\", \"▪\"];\n\nexport type UnorderedListEntry = {\n /**\n * Content of the list entry. Can be a string or nested list.\n */\n readonly children?: ReadonlyArray<UnorderedListEntry>;\n\n /**\n * Display text for this item.\n */\n readonly label: ReactNode;\n};\n\nexport type Props = {\n /**\n * Entries to render as a bullet list.\n */\n readonly items: ReadonlyArray<UnorderedListEntry>;\n\n /**\n * Custom bullet symbol(s). A string uses the same symbol at all depths.\n * An array cycles through symbols by nesting depth.\n */\n readonly marker?: ReadonlyArray<string> | string;\n};\n\n/**\n * Resolve the markers array from the prop value.\n */\nconst resolveMarkers = (marker: ReadonlyArray<string> | string | undefined): ReadonlyArray<string> => {\n if (typeof marker === \"string\") {\n return [marker];\n }\n\n if (Array.isArray(marker)) {\n return marker;\n }\n\n return DEFAULT_MARKERS;\n};\n\n/**\n * Render a list recursively at the given depth.\n */\nconst renderList = (items: ReadonlyArray<UnorderedListEntry>, markers: ReadonlyArray<string>, depth: number): ReactElement => {\n const resolvedMarker = markers[depth % markers.length] ?? \"─\";\n\n return (\n <Box flexDirection=\"column\">\n {items.map((item, index) => (\n // eslint-disable-next-line react-x/no-array-index-key -- list entries have no unique key\n <Box gap={1} key={index}>\n <Text dimColor>{resolvedMarker}</Text>\n <Box flexDirection=\"column\">\n <Text>{item.label}</Text>\n {item.children ? renderList(item.children, markers, depth + 1) : undefined}\n </Box>\n </Box>\n ))}\n </Box>\n );\n};\n\n/**\n * Renders a bullet list with nesting support.\n */\nexport default function UnorderedList({ items, marker }: Props): ReactElement {\n const markers = resolveMarkers(marker);\n\n return renderList(items, markers, 0);\n}\n\nexport { UnorderedList };\nexport type { Props as UnorderedListProps };\n",
17
+ "path": "registry/ui/unordered-list.tsx",
18
+ "target": "components/ui/unordered-list.tsx",
19
+ "type": "registry:ui"
20
+ }
21
+ ]
22
+ }
@@ -0,0 +1,24 @@
1
+ {
2
+ "$schema": "https://ui.shadcn.com/schema/registry-item.json",
3
+ "name": "use-linked-scroll",
4
+ "type": "registry:ui",
5
+ "title": "Use Linked Scroll",
6
+ "description": "Use Linked Scroll component for @visulima/tui.",
7
+ "categories": [
8
+ "ui"
9
+ ],
10
+ "dependencies": [
11
+ "@visulima/tui"
12
+ ],
13
+ "registryDependencies": [
14
+ "https://visulima.com/r/scroll.json"
15
+ ],
16
+ "files": [
17
+ {
18
+ "content": "import type { RefObject } from \"react\";\nimport { useCallback, useEffect, useRef, useState } from \"react\";\n\nimport type { ScrollViewRef } from \"@/components/ui/scroll/scroll-view\";\n\nexport type UseLinkedScrollReturn = {\n /**\n * Callback to pass to ScrollView's `onScroll` prop.\n * Broadcasts the scroll position to all linked instances.\n */\n onScroll: (scrollOffset: number) => void;\n\n /**\n * Ref to attach to the ScrollView component.\n * Used internally to call `scrollTo()` when other instances scroll.\n */\n ref: RefObject<ScrollViewRef | null>;\n};\n\nexport type LinkedScrollGroup = {\n /**\n * Hook that returns a ref and onScroll callback for linking a ScrollView\n * to this group. Call once per ScrollView instance.\n */\n useLinkedScroll: () => UseLinkedScrollReturn;\n};\n\ntype Listener = {\n ref: RefObject<ScrollViewRef | null>;\n setOffset: (offset: number) => void;\n};\n\n/**\n * Creates a linked scroll group that synchronizes scroll position\n * across multiple ScrollView instances.\n * @example\n * ```tsx\n * const group = createLinkedScrollGroup();\n *\n * const PanelA = () => {\n * const { ref, onScroll } = group.useLinkedScroll();\n * return <ScrollView ref={ref} onScroll={onScroll}>...</ScrollView>;\n * };\n *\n * const PanelB = () => {\n * const { ref, onScroll } = group.useLinkedScroll();\n * return <ScrollView ref={ref} onScroll={onScroll}>...</ScrollView>;\n * };\n * ```\n */\nconst createLinkedScrollGroup = (): LinkedScrollGroup => {\n const listeners = new Set<Listener>();\n let broadcasting = false;\n\n const useLinkedScroll = (): UseLinkedScrollReturn => {\n const scrollRef = useRef<ScrollViewRef | null>(null);\n const [, setTick] = useState(0);\n\n const listener: Listener = {\n ref: scrollRef,\n setOffset: () => {\n setTick((t) => t + 1);\n },\n };\n\n const listenerRef = useRef(listener);\n\n listenerRef.current = listener;\n\n useEffect(() => {\n const { current } = listenerRef;\n\n listeners.add(current);\n\n return () => {\n listeners.delete(current);\n };\n }, []);\n\n const onScroll = useCallback((scrollOffset: number) => {\n if (broadcasting) {\n return;\n }\n\n broadcasting = true;\n\n try {\n for (const l of listeners) {\n if (l.ref !== scrollRef) {\n l.ref.current?.scrollTo(scrollOffset);\n }\n }\n } finally {\n broadcasting = false;\n }\n }, []);\n\n return { onScroll, ref: scrollRef };\n };\n\n return { useLinkedScroll };\n};\n\nexport default createLinkedScrollGroup;\n\nexport { createLinkedScrollGroup };\n",
19
+ "path": "registry/ui/use-linked-scroll.ts",
20
+ "target": "components/ui/use-linked-scroll.ts",
21
+ "type": "registry:ui"
22
+ }
23
+ ]
24
+ }
@@ -0,0 +1,22 @@
1
+ {
2
+ "$schema": "https://ui.shadcn.com/schema/registry-item.json",
3
+ "name": "variant-config",
4
+ "type": "registry:lib",
5
+ "title": "Variant Config",
6
+ "description": "Variant Config component for @visulima/tui.",
7
+ "categories": [
8
+ "ui"
9
+ ],
10
+ "dependencies": [
11
+ "@visulima/tui"
12
+ ],
13
+ "registryDependencies": [],
14
+ "files": [
15
+ {
16
+ "content": "/**\n * Shared variant configuration for Alert and StatusMessage components.\n */\ntype VariantConfig = {\n readonly color: string;\n readonly icon: string;\n};\n\nexport type Variant = \"error\" | \"info\" | \"success\" | \"warning\";\n\nconst VARIANT_CONFIG: Record<Variant, VariantConfig> = {\n error: { color: \"red\", icon: \"✖\" },\n info: { color: \"blue\", icon: \"ℹ\" },\n success: { color: \"green\", icon: \"✔\" },\n warning: { color: \"yellow\", icon: \"⚠\" },\n};\n\nexport default VARIANT_CONFIG;\n",
17
+ "path": "registry/lib/variant-config.ts",
18
+ "target": "lib/variant-config.ts",
19
+ "type": "registry:lib"
20
+ }
21
+ ]
22
+ }
@@ -0,0 +1,63 @@
1
+ import { AnsiColors } from '@visulima/colorize';
2
+ import { ReactElement } from 'react';
3
+ import { LiteralUnion } from 'type-fest';
4
+ type RadioOption = {
5
+ readonly description?: string;
6
+ readonly key?: string;
7
+ readonly label: string;
8
+ readonly value: string;
9
+ };
10
+ type Props = {
11
+ /**
12
+ * Accent color for the focused and selected indicator.
13
+ * @default "blue"
14
+ */
15
+ readonly accentColor?: LiteralUnion<AnsiColors, string>;
16
+ /**
17
+ * Auto-focus when mounted.
18
+ */
19
+ readonly autoFocus?: boolean;
20
+ /**
21
+ * When true, moving focus with arrow keys immediately updates the selected
22
+ * value (and fires `onChange`). When false, focus moves independently and
23
+ * selection commits only on Space or Enter.
24
+ * @default true
25
+ */
26
+ readonly commitOnNavigate?: boolean;
27
+ /**
28
+ * Initial selected value when uncontrolled.
29
+ */
30
+ readonly defaultValue?: string;
31
+ /**
32
+ * Disable input and dim labels.
33
+ */
34
+ readonly isDisabled?: boolean;
35
+ /**
36
+ * Called when selection changes.
37
+ */
38
+ readonly onChange?: (value: string) => void;
39
+ /**
40
+ * Called when the user submits by pressing Enter.
41
+ */
42
+ readonly onSubmit?: (value: string) => void;
43
+ /**
44
+ * Available options.
45
+ */
46
+ readonly options: ReadonlyArray<RadioOption>;
47
+ /**
48
+ * Orientation of options.
49
+ * @default "vertical"
50
+ */
51
+ readonly orientation?: "horizontal" | "vertical";
52
+ /**
53
+ * Controlled selected value.
54
+ */
55
+ readonly value?: string;
56
+ };
57
+ /**
58
+ * Single-choice radio group with keyboard navigation. By default, navigation
59
+ * commits selection; set `commitOnNavigate={false}` to require an explicit
60
+ * Space/Enter commit.
61
+ */
62
+ declare function RadioGroup({ accentColor, autoFocus, commitOnNavigate, defaultValue, isDisabled, onChange, onSubmit, options, orientation, value }: Props): ReactElement;
63
+ export { Props, RadioGroup, type Props as RadioGroupProps, RadioOption, RadioGroup as default };
@@ -0,0 +1 @@
1
+ import{jsx as h,jsxs as R}from"react/jsx-runtime";import z from"@visulima/tui/components/box";import w from"@visulima/tui/components/text";import G from"@visulima/tui/hooks/use-focus";import S from"@visulima/tui/hooks/use-input";import{useState as D,useRef as y,useCallback as F}from"react";const B=(x,s)=>{if(s===void 0)return 0;const l=x.findIndex(b=>b.value===s);return Math.max(l,0)};function H({accentColor:x="blue",autoFocus:s=!1,commitOnNavigate:l=!0,defaultValue:b,isDisabled:d=!1,onChange:A,onSubmit:j,options:t,orientation:e="vertical",value:m}){const{isFocused:c}=G({autoFocus:s,isActive:!d}),[C,M]=D(b??t[0]?.value),v=m??C,[g,k]=D(()=>B(t,v)),u=y(g);u.current=g;const a=F(r=>{const o=t[r];if(!o)return;const n=m??C;o.value!==n&&(m===void 0&&M(o.value),A?.(o.value))},[t,m,C,A]);return S(F((r,o)=>{if(!c)return;const n=t.length;if(n===0)return;const f=o.downArrow&&e==="vertical"||o.rightArrow&&e==="horizontal"||r==="j",p=o.upArrow&&e==="vertical"||o.leftArrow&&e==="horizontal"||r==="k";if(f){const i=Math.min(n-1,u.current+1);k(i),l&&a(i);return}if(p){const i=Math.max(0,u.current-1);k(i),l&&a(i);return}if(r===" "){a(u.current);return}if(o.return){l||a(u.current);const i=t[u.current]?.value??v;i!==void 0&&j?.(i)}},[c,t,e,a,l,v,j]),{isActive:!d&&c}),h(z,{flexDirection:e==="vertical"?"column":"row",gap:e==="vertical"?0:2,children:t.map((r,o)=>{const n=r.value===v,f=c&&o===g,p=(n||f)&&c?x:void 0;return R(z,{gap:1,children:[h(w,{color:p,dimColor:d,children:n?"●":"○"}),h(w,{bold:f,color:p,dimColor:d,children:r.label}),r.description===void 0?void 0:h(w,{dimColor:!0,children:r.description})]},r.key??r.value)})})}export{H as RadioGroup,H as default};
@@ -0,0 +1,24 @@
1
+ import { AnsiColors } from '@visulima/colorize';
2
+ import { ReactElement } from 'react';
3
+ import { LiteralUnion } from 'type-fest';
4
+ import { LineSeries } from "./line-chart.js";
5
+ import '@visulima/tui/canvas';
6
+ type Props = {
7
+ readonly axisColor?: LiteralUnion<AnsiColors, string>;
8
+ readonly height?: number;
9
+ readonly maxX?: number;
10
+ readonly maxY?: number;
11
+ readonly minX?: number;
12
+ readonly minY?: number;
13
+ readonly palette?: ReadonlyArray<LiteralUnion<AnsiColors, string>>;
14
+ readonly series: ReadonlyArray<LineSeries>;
15
+ readonly showLegend?: boolean;
16
+ readonly width?: number;
17
+ };
18
+ /**
19
+ * Scatter plot rendered on a braille pixel grid. Each sample lights one
20
+ * sub-cell pixel; overlapping points merge into denser braille glyphs.
21
+ * @returns A `ReactElement` containing the plot and an optional legend.
22
+ */
23
+ declare function ScatterPlot({ axisColor, height, maxX, maxY, minX, minY, palette, series, showLegend, width }: Props): ReactElement;
24
+ export { Props, ScatterPlot, type Props as ScatterPlotProps, ScatterPlot as default };
@@ -0,0 +1 @@
1
+ import{jsxs as N,jsx as t}from"react/jsx-runtime";import c from"@visulima/tui/components/box";import u from"@visulima/tui/components/canvas";import T from"@visulima/tui/components/text";import{useMemo as E}from"react";import{t as f,p as g,c as y,D as b,d as S}from"./packem_shared/line-chart-BbhMhcZg.js";const Y=b;function P({axisColor:s="gray",height:I=10,maxX:a,maxY:n,minX:m,minY:x,palette:o=Y,series:l,showLegend:M=!0,width:p=40}){const d=E(()=>{const e=l.map((i,h)=>({color:g(i,h,o),points:f(i.data),series:i})),r=y(e);return{axisColor:s,palette:o,seriesList:e,xMax:a??(r.xMax===Number.NEGATIVE_INFINITY?1:r.xMax),xMin:m??(r.xMin===Number.POSITIVE_INFINITY?0:r.xMin),yMax:n??(r.yMax===Number.NEGATIVE_INFINITY?1:r.yMax),yMin:x??(r.yMin===Number.POSITIVE_INFINITY?0:r.yMin)}},[l,s,o,m,a,x,n]);return N(c,{flexDirection:"column",children:[t(u,{draw:e=>{S(e,"scatter",d)},height:I,version:[l,p,I,m,a,x,n,s,o],width:p}),M?t(c,{gap:2,marginTop:1,children:d.seriesList.map(({color:e,series:r},i)=>N(c,{gap:1,children:[t(T,{color:e,children:"●"}),t(T,{children:r.label??`Series ${i+1}`})]},`${i}:${r.label??""}`))}):void 0]})}export{P as ScatterPlot,P as default};
@@ -0,0 +1,3 @@
1
+ export { S as ScrollBarBox, type a as ScrollBarBoxProps } from "./packem_shared/scroll-bar-box.d-CpTYEcTZ.js";
2
+ import '@visulima/tui/components/box';
3
+ import 'react';
@@ -0,0 +1 @@
1
+ import{ScrollBarBox as l}from"./packem_shared/ScrollBarBox-DZEC8YnV.js";export{l as ScrollBarBox};
@@ -0,0 +1,3 @@
1
+ export { S as ScrollBar, type a as ScrollBarPlacement, type b as ScrollBarProps, type c as ScrollBarStyle } from "./packem_shared/scroll-bar.d-B4sD1hBR.js";
2
+ import '@visulima/tui/components/box';
3
+ import 'react';
@@ -0,0 +1 @@
1
+ import{ScrollBar as l}from"./packem_shared/ScrollBar-C9i3z3Ft.js";export{l as ScrollBar};
@@ -0,0 +1,4 @@
1
+ export { type S as ScrollAlignment, a as ScrollList, type b as ScrollListProps, type c as ScrollListRef } from "./packem_shared/scroll-list.d-CPCF0RwC.js";
2
+ import 'react';
3
+ import "./packem_shared/scroll-view.d-GZcgAHl4.js";
4
+ import '@visulima/tui/components/box';
@@ -0,0 +1 @@
1
+ import{ScrollList as l}from"./packem_shared/ScrollList-Djtb2vVy.js";export{l as ScrollList};