@visulima/tui-kit 0.0.0 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (371) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/LICENSE.md +2095 -0
  3. package/README.md +115 -29
  4. package/dist/accordion.d.ts +59 -0
  5. package/dist/accordion.js +1 -0
  6. package/dist/alert.d.ts +26 -0
  7. package/dist/alert.js +1 -0
  8. package/dist/animate-presence.d.ts +18 -0
  9. package/dist/animate-presence.js +1 -0
  10. package/dist/approval-prompt.d.ts +65 -0
  11. package/dist/approval-prompt.js +1 -0
  12. package/dist/area-chart.d.ts +66 -0
  13. package/dist/area-chart.js +1 -0
  14. package/dist/badge.d.ts +22 -0
  15. package/dist/badge.js +1 -0
  16. package/dist/bar-chart.d.ts +67 -0
  17. package/dist/bar-chart.js +1 -0
  18. package/dist/big-text.d.ts +81 -0
  19. package/dist/big-text.js +1 -0
  20. package/dist/blink-dot.d.ts +36 -0
  21. package/dist/blink-dot.js +1 -0
  22. package/dist/breadcrumb.d.ts +33 -0
  23. package/dist/breadcrumb.js +1 -0
  24. package/dist/button.d.ts +45 -0
  25. package/dist/button.js +1 -0
  26. package/dist/calendar.d.ts +72 -0
  27. package/dist/calendar.js +1 -0
  28. package/dist/card.d.ts +57 -0
  29. package/dist/card.js +1 -0
  30. package/dist/checkbox-group.d.ts +59 -0
  31. package/dist/checkbox-group.js +1 -0
  32. package/dist/checkbox.d.ts +48 -0
  33. package/dist/checkbox.js +1 -0
  34. package/dist/code.d.ts +48 -0
  35. package/dist/code.js +2 -0
  36. package/dist/collapsible.d.ts +53 -0
  37. package/dist/collapsible.js +1 -0
  38. package/dist/color-picker.d.ts +71 -0
  39. package/dist/color-picker.js +1 -0
  40. package/dist/command-block.d.ts +47 -0
  41. package/dist/command-block.js +4 -0
  42. package/dist/command-palette.d.ts +82 -0
  43. package/dist/command-palette.js +1 -0
  44. package/dist/confirm-dialog.d.ts +75 -0
  45. package/dist/confirm-dialog.js +1 -0
  46. package/dist/confirm-input.d.ts +38 -0
  47. package/dist/confirm-input.js +1 -0
  48. package/dist/console-overlay.d.ts +54 -0
  49. package/dist/console-overlay.js +1 -0
  50. package/dist/content-switcher.d.ts +57 -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 +49 -0
  55. package/dist/data-grid.js +1 -0
  56. package/dist/date-picker.d.ts +65 -0
  57. package/dist/date-picker.js +1 -0
  58. package/dist/definition-list.d.ts +44 -0
  59. package/dist/definition-list.js +1 -0
  60. package/dist/dialog.d.ts +110 -0
  61. package/dist/dialog.js +1 -0
  62. package/dist/diff-view.d.ts +85 -0
  63. package/dist/diff-view.js +1 -0
  64. package/dist/digits.d.ts +30 -0
  65. package/dist/digits.js +1 -0
  66. package/dist/directory-tree.d.ts +51 -0
  67. package/dist/directory-tree.js +1 -0
  68. package/dist/divider.d.ts +49 -0
  69. package/dist/divider.js +1 -0
  70. package/dist/drawer.d.ts +53 -0
  71. package/dist/drawer.js +1 -0
  72. package/dist/file-change.d.ts +40 -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 +81 -0
  77. package/dist/form.js +1 -0
  78. package/dist/gauge.d.ts +87 -0
  79. package/dist/gauge.js +1 -0
  80. package/dist/gradient.d.ts +42 -0
  81. package/dist/gradient.js +1 -0
  82. package/dist/grid.d.ts +40 -0
  83. package/dist/grid.js +1 -0
  84. package/dist/heading.d.ts +33 -0
  85. package/dist/heading.js +1 -0
  86. package/dist/heatmap.d.ts +62 -0
  87. package/dist/heatmap.js +1 -0
  88. package/dist/help.d.ts +52 -0
  89. package/dist/help.js +1 -0
  90. package/dist/histogram.d.ts +62 -0
  91. package/dist/histogram.js +1 -0
  92. package/dist/image.d.ts +26 -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 +58 -0
  97. package/dist/json.js +1 -0
  98. package/dist/kbd.d.ts +31 -0
  99. package/dist/kbd.js +1 -0
  100. package/dist/line-chart.d.ts +109 -0
  101. package/dist/line-chart.js +1 -0
  102. package/dist/link.d.ts +38 -0
  103. package/dist/link.js +1 -0
  104. package/dist/loading-indicator.d.ts +29 -0
  105. package/dist/loading-indicator.js +1 -0
  106. package/dist/log.d.ts +38 -0
  107. package/dist/log.js +1 -0
  108. package/dist/markdown.d.ts +40 -0
  109. package/dist/markdown.js +1 -0
  110. package/dist/masked-input.d.ts +50 -0
  111. package/dist/masked-input.js +1 -0
  112. package/dist/menu.d.ts +78 -0
  113. package/dist/menu.js +1 -0
  114. package/dist/message-bubble.d.ts +43 -0
  115. package/dist/message-bubble.js +1 -0
  116. package/dist/model-badge.d.ts +39 -0
  117. package/dist/model-badge.js +1 -0
  118. package/dist/model-selector.d.ts +52 -0
  119. package/dist/model-selector.js +1 -0
  120. package/dist/multi-progress.d.ts +55 -0
  121. package/dist/multi-progress.js +1 -0
  122. package/dist/multi-select.d.ts +65 -0
  123. package/dist/multi-select.js +1 -0
  124. package/dist/notification-badge.d.ts +47 -0
  125. package/dist/notification-badge.js +1 -0
  126. package/dist/number-input.d.ts +85 -0
  127. package/dist/number-input.js +1 -0
  128. package/dist/operation-tree.d.ts +49 -0
  129. package/dist/operation-tree.js +1 -0
  130. package/dist/option-list.d.ts +47 -0
  131. package/dist/option-list.js +1 -0
  132. package/dist/ordered-list.d.ts +24 -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-vF4wevEJ.d.ts +54 -0
  144. package/dist/packem_shared/file-picker.d-Cie6aAMD.d.ts +104 -0
  145. package/dist/packem_shared/line-chart-BbhMhcZg.js +1 -0
  146. package/dist/packem_shared/scroll-bar-box.d-DSBbBsRc.d.ts +30 -0
  147. package/dist/packem_shared/scroll-bar.d-B4sD1hBR.d.ts +18 -0
  148. package/dist/packem_shared/scroll-list.d-BP0UZSY4.d.ts +12 -0
  149. package/dist/packem_shared/scroll-view.d-BqKpI-F-.d.ts +119 -0
  150. package/dist/packem_shared/treeViewTheme-Dq3ekRAC.js +1 -0
  151. package/dist/packem_shared/use-tree-view.d-dXTKoDPM.d.ts +350 -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 +69 -0
  157. package/dist/paginator.js +1 -0
  158. package/dist/paragraph.d.ts +38 -0
  159. package/dist/paragraph.js +1 -0
  160. package/dist/path-input.d.ts +67 -0
  161. package/dist/path-input.js +1 -0
  162. package/dist/pie-chart.d.ts +50 -0
  163. package/dist/pie-chart.js +1 -0
  164. package/dist/placeholder.d.ts +55 -0
  165. package/dist/placeholder.js +1 -0
  166. package/dist/popover.d.ts +46 -0
  167. package/dist/popover.js +1 -0
  168. package/dist/progress-bar.d.ts +51 -0
  169. package/dist/progress-bar.js +1 -0
  170. package/dist/progress-circle.d.ts +59 -0
  171. package/dist/progress-circle.js +1 -0
  172. package/dist/qr-code.d.ts +45 -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 +74 -0
  292. package/dist/radio-group.js +1 -0
  293. package/dist/scatter-plot.d.ts +35 -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 +55 -0
  304. package/dist/search-input.js +1 -0
  305. package/dist/select-input-indicator.d.ts +32 -0
  306. package/dist/select-input-indicator.js +1 -0
  307. package/dist/select-input-item.d.ts +41 -0
  308. package/dist/select-input-item.js +1 -0
  309. package/dist/select-input.d.ts +110 -0
  310. package/dist/select-input.js +1 -0
  311. package/dist/shimmer-text.d.ts +47 -0
  312. package/dist/shimmer-text.js +1 -0
  313. package/dist/sidebar.d.ts +47 -0
  314. package/dist/sidebar.js +1 -0
  315. package/dist/slider.d.ts +103 -0
  316. package/dist/slider.js +1 -0
  317. package/dist/sparkline.d.ts +42 -0
  318. package/dist/sparkline.js +1 -0
  319. package/dist/spinner.d.ts +17 -0
  320. package/dist/spinner.js +1 -0
  321. package/dist/status-line.d.ts +37 -0
  322. package/dist/status-line.js +1 -0
  323. package/dist/status-message.d.ts +21 -0
  324. package/dist/status-message.js +1 -0
  325. package/dist/stepper.d.ts +48 -0
  326. package/dist/stepper.js +1 -0
  327. package/dist/stopwatch.d.ts +49 -0
  328. package/dist/stopwatch.js +1 -0
  329. package/dist/streaming-text.d.ts +46 -0
  330. package/dist/streaming-text.js +1 -0
  331. package/dist/switch.d.ts +55 -0
  332. package/dist/switch.js +1 -0
  333. package/dist/tab.d.ts +19 -0
  334. package/dist/tab.js +1 -0
  335. package/dist/table.d.ts +119 -0
  336. package/dist/table.js +1 -0
  337. package/dist/tabs.d.ts +107 -0
  338. package/dist/tabs.js +1 -0
  339. package/dist/tag-input.d.ts +71 -0
  340. package/dist/tag-input.js +1 -0
  341. package/dist/tag.d.ts +35 -0
  342. package/dist/tag.js +1 -0
  343. package/dist/text-input.d.ts +48 -0
  344. package/dist/text-input.js +1 -0
  345. package/dist/textarea.d.ts +68 -0
  346. package/dist/textarea.js +2 -0
  347. package/dist/thinking-block.d.ts +57 -0
  348. package/dist/thinking-block.js +1 -0
  349. package/dist/time-picker.d.ts +67 -0
  350. package/dist/time-picker.js +1 -0
  351. package/dist/timer.d.ts +59 -0
  352. package/dist/timer.js +1 -0
  353. package/dist/toast.d.ts +49 -0
  354. package/dist/toast.js +1 -0
  355. package/dist/token-usage.d.ts +50 -0
  356. package/dist/token-usage.js +1 -0
  357. package/dist/tool-call.d.ts +33 -0
  358. package/dist/tool-call.js +1 -0
  359. package/dist/tooltip.d.ts +43 -0
  360. package/dist/tooltip.js +1 -0
  361. package/dist/transition.d.ts +74 -0
  362. package/dist/transition.js +1 -0
  363. package/dist/tree-select.d.ts +65 -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 +30 -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": "heading",
4
+ "type": "registry:ui",
5
+ "title": "Heading",
6
+ "description": "Heading 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 HeadingLevel = 1 | 2 | 3 | 4 | 5 | 6;\n\nexport type Props = {\n /**\n * Heading content.\n */\n readonly children: ReactNode;\n\n /**\n * Explicit color. By default the color is derived from the level.\n */\n readonly color?: LiteralUnion<AnsiColors, string>;\n\n /**\n * Heading level controls decoration and color.\n * @default 1\n */\n readonly level?: HeadingLevel;\n\n /**\n * When true, renders an underline on a second line.\n */\n readonly underline?: boolean;\n};\n\nconst LEVEL_COLOR: Record<HeadingLevel, string> = {\n 1: \"blueBright\",\n 2: \"cyanBright\",\n 3: \"magentaBright\",\n 4: \"yellowBright\",\n 5: \"green\",\n 6: \"white\",\n};\n\n// Markdown convention: H1 = \"# \", H2 = \"## \", … H6 = \"###### \".\nconst LEVEL_PREFIX: Record<HeadingLevel, string> = {\n 1: \"# \",\n 2: \"## \",\n 3: \"### \",\n 4: \"#### \",\n 5: \"##### \",\n 6: \"###### \",\n};\n\n/**\n * Semantic heading with level-based styling.\n */\nexport default function Heading({ children, color, level = 1, underline = false }: Props): ReactElement {\n const resolvedColor = color ?? LEVEL_COLOR[level];\n\n return (\n <Box flexDirection=\"column\">\n <Text bold={level <= 2} color={resolvedColor} underline={underline}>\n {LEVEL_PREFIX[level]}\n {children}\n </Text>\n </Box>\n );\n}\n\nexport { Heading };\nexport type { Props as HeadingProps };\n",
19
+ "path": "registry/ui/heading.tsx",
20
+ "target": "components/ui/heading.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": "heatmap",
4
+ "type": "registry:ui",
5
+ "title": "Heatmap",
6
+ "description": "Heatmap 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 type { CanvasContext } from \"@visulima/tui/canvas\";\nimport Box from \"@visulima/tui/components/box\";\nimport Canvas from \"@visulima/tui/components/canvas\";\nimport Text from \"@visulima/tui/components/text\";\nimport type { ReactElement } from \"react\";\nimport { useMemo } from \"react\";\nimport type { LiteralUnion } from \"type-fest\";\n\nexport type HeatmapProps = {\n /**\n * Width of each cell in character columns.\n * @default 2\n */\n readonly cellWidth?: number;\n\n /**\n * Glyph used to paint each heatmap cell. Defaults to `█` so the\n * background color fills the cell.\n * @default \"█\" (full block)\n */\n readonly character?: string;\n\n /**\n * Column labels rendered below the grid.\n */\n readonly columnLabels?: ReadonlyArray<string>;\n\n /**\n * 2D matrix, rows × cols.\n */\n readonly data: ReadonlyArray<ReadonlyArray<number>>;\n\n /**\n * Fixed maximum (for consistent scales across multiple heatmaps).\n */\n readonly max?: number;\n\n /**\n * Fixed minimum.\n */\n readonly min?: number;\n\n /**\n * Color palette, lowest-to-highest intensity. Values are mapped into the\n * palette by their position in the [min, max] range.\n * @default [\"#0b1e4f\", \"#1f3b96\", \"#3258c8\", \"#4e84ec\", \"#78b0ff\"]\n */\n readonly palette?: ReadonlyArray<string>;\n\n /**\n * Row labels rendered to the left of the grid.\n */\n readonly rowLabels?: ReadonlyArray<string>;\n\n /**\n * Foreground color override (rarely needed since the character is a full block).\n */\n readonly textColor?: LiteralUnion<AnsiColors, string>;\n};\n\nconst DEFAULT_PALETTE: ReadonlyArray<string> = [\"#0b1e4f\", \"#1f3b96\", \"#3258c8\", \"#4e84ec\", \"#78b0ff\"];\n\n/**\n * 2D heatmap. Each cell's background color is derived from its value's\n * position in the [min, max] range mapped into `palette`.\n */\nexport default function Heatmap({\n cellWidth = 2,\n character = \"█\",\n columnLabels,\n data,\n max: maxOverride,\n min: minOverride,\n palette = DEFAULT_PALETTE,\n rowLabels,\n textColor,\n}: HeatmapProps): ReactElement {\n const { cols, colWidth, extent, rows } = useMemo(() => {\n const rowCount = data.length;\n // eslint-disable-next-line unicorn/no-array-reduce -- finding max row length is naturally a reduce\n const colCount = data.reduce((widest, row) => Math.max(widest, row.length), 0);\n\n let min = minOverride ?? Infinity;\n let max = maxOverride ?? -Infinity;\n\n if (minOverride === undefined || maxOverride === undefined) {\n for (const row of data) {\n for (const value of row) {\n if (minOverride === undefined && value < min) {\n min = value;\n }\n\n if (maxOverride === undefined && value > max) {\n max = value;\n }\n }\n }\n }\n\n return {\n cols: colCount,\n colWidth: cellWidth,\n extent: { max, min },\n rows: rowCount,\n };\n }, [data, minOverride, maxOverride, cellWidth]);\n\n const width = cols * colWidth;\n const height = rows;\n\n return (\n <Box>\n {/* eslint-disable-next-line @stylistic/multiline-ternary -- prettier formats JSX ternaries on one line */}\n {rowLabels === undefined ? undefined : (\n <Box flexDirection=\"column\" marginRight={1}>\n {Array.from({ length: rows }, (_, index) => (\n <Text dimColor key={index}>\n {rowLabels[index] ?? \"\"}\n </Text>\n ))}\n </Box>\n )}\n <Box flexDirection=\"column\">\n <Canvas\n // eslint-disable-next-line react-perf/jsx-no-new-function-as-prop -- canvas re-renders on `version` change, not draw identity\n draw={(context: CanvasContext) => {\n context.clear();\n\n const { max, min } = extent;\n const range = max - min || 1;\n const paletteLast = palette.length - 1;\n\n for (let row = 0; row < rows; row += 1) {\n for (let col = 0; col < cols; col += 1) {\n const value = data[row]?.[col];\n // eslint-disable-next-line @stylistic/operator-linebreak -- prettier places `=` at end of line\n const background =\n value === undefined\n ? undefined\n : palette[Math.max(0, Math.min(paletteLast, Math.round(((value - min) / range) * paletteLast)))];\n\n for (let offset = 0; offset < colWidth; offset += 1) {\n context.setCell(col * colWidth + offset, row, character, {\n background,\n color: textColor,\n });\n }\n }\n }\n }}\n height={height}\n // eslint-disable-next-line react-perf/jsx-no-new-array-as-prop -- version array is the canvas redraw key\n version={[data, palette, cellWidth, minOverride, maxOverride]}\n width={width}\n />\n {/* eslint-disable-next-line @stylistic/multiline-ternary -- prettier formats JSX ternaries on one line */}\n {columnLabels === undefined ? undefined : (\n <Box>\n {columnLabels.map((label, index) => (\n // eslint-disable-next-line react-x/no-array-index-key -- column index is stable for the render\n <Box key={index} width={colWidth}>\n <Text dimColor wrap=\"truncate-end\">\n {label}\n </Text>\n </Box>\n ))}\n </Box>\n )}\n </Box>\n </Box>\n );\n}\n\nexport { Heatmap };\n",
19
+ "path": "registry/ui/heatmap.tsx",
20
+ "target": "components/ui/heatmap.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": "help",
4
+ "type": "registry:ui",
5
+ "title": "Help",
6
+ "description": "Help component for @visulima/tui.",
7
+ "categories": [
8
+ "ui"
9
+ ],
10
+ "dependencies": [
11
+ "@visulima/tui"
12
+ ],
13
+ "registryDependencies": [],
14
+ "files": [
15
+ {
16
+ "content": "import Box from \"@visulima/tui/components/box\";\nimport Text from \"@visulima/tui/components/text\";\nimport type { KeyBinding } from \"@visulima/tui/hooks/use-key-bindings\";\nimport useWindowSize from \"@visulima/tui/hooks/use-window-size\";\nimport type { ReactElement, ReactNode } from \"react\";\n\n/**\n * Human-readable labels for special key names.\n */\nconst keyLabels: Record<string, string> = {\n backspace: \"backspace\",\n delete: \"del\",\n downArrow: \"\\u2193\",\n end: \"end\",\n escape: \"esc\",\n home: \"home\",\n leftArrow: \"\\u2190\",\n pageDown: \"pgdn\",\n pageUp: \"pgup\",\n return: \"enter\",\n rightArrow: \"\\u2192\",\n tab: \"tab\",\n upArrow: \"\\u2191\",\n};\n\n/**\n * Format a key spec string into a human-readable label.\n * `\"ctrl+c\"` -> `\"ctrl+c\"`, `\"upArrow\"` -> `\"\\u2191\"`, `\"q\"` -> `\"q\"`\n */\nfunction formatKeyLabel(spec: string): string {\n const plusIndex = spec.indexOf(\"+\");\n\n if (plusIndex !== -1) {\n const modifier = spec.slice(0, plusIndex);\n const rest = spec.slice(plusIndex + 1);\n\n return `${modifier}+${keyLabels[rest] ?? rest}`;\n }\n\n return keyLabels[spec] ?? spec;\n}\n\n/**\n * Format all keys of a binding into a single display string.\n * `[\"upArrow\", \"k\"]` -> `\"\\u2191/k\"`\n */\nfunction formatKeys(key: string | ReadonlyArray<string>): string {\n const keys = typeof key === \"string\" ? [key] : key;\n\n return keys.map((k) => formatKeyLabel(k)).join(\"/\");\n}\n\nexport type Props = {\n /**\n * Key bindings to display. Typically the `bindings` array returned by `useKeyBindings`.\n */\n readonly bindings: ReadonlyArray<KeyBinding>;\n\n /**\n * Color for description text.\n * @default undefined (uses dimColor)\n */\n readonly descriptionColor?: string;\n\n /**\n * Color for key labels.\n * @default \"cyan\"\n */\n readonly keyColor?: string;\n\n /**\n * Maximum number of columns in full mode.\n * @default 3\n */\n readonly maxColumns?: number;\n\n /**\n * Display mode.\n * - `\"short\"`: Single-line horizontal bar, truncated to terminal width.\n * - `\"full\"`: Multi-column layout grouped by `group` field.\n * @default \"short\"\n */\n readonly mode?: \"full\" | \"short\";\n\n /**\n * Separator between key-description pairs in short mode.\n * @default \" \\u00b7 \"\n */\n readonly separator?: string;\n};\n\n/**\n * Renders a keybinding help bar from an array of `KeyBinding` definitions.\n *\n * ```tsx\n * &lt;Help bindings={bindings} />\n * &lt;Help bindings={bindings} mode=\"full\" />\n * ```\n */\nexport default function Help({ bindings, descriptionColor, keyColor = \"cyan\", maxColumns = 3, mode = \"short\", separator = \" \\u00B7 \" }: Props): ReactElement {\n const { columns } = useWindowSize();\n\n if (mode === \"full\") {\n return renderFull(bindings, { columns, descriptionColor, keyColor, maxColumns });\n }\n\n return renderShort(bindings, { columns, descriptionColor, keyColor, separator });\n}\n\nfunction renderShort(\n bindings: ReadonlyArray<KeyBinding>,\n options: { columns: number; descriptionColor: string | undefined; keyColor: string; separator: string },\n): ReactElement {\n const items: ReactNode[] = [];\n let totalWidth = 0;\n\n for (const [index, binding] of bindings.entries()) {\n const keys = formatKeys(binding.key);\n const pairText = `${keys} ${binding.description}`;\n const separatorWidth = index > 0 ? options.separator.length : 0;\n const pairWidth = separatorWidth + pairText.length;\n\n // Stop if adding this pair would exceed terminal width\n if (totalWidth + pairWidth > options.columns && items.length > 0) {\n break;\n }\n\n if (index > 0) {\n items.push(\n <Text dimColor key={`sep-${index}`}>\n {options.separator}\n </Text>,\n );\n }\n\n items.push(\n <Box key={`pair-${index}`}>\n <Text color={options.keyColor}>{keys}</Text>\n <Text> </Text>\n {options.descriptionColor ? <Text color={options.descriptionColor}>{binding.description}</Text> : <Text dimColor>{binding.description}</Text>}\n </Box>,\n );\n\n totalWidth += pairWidth;\n }\n\n return <Box flexDirection=\"row\">{items}</Box>;\n}\n\nfunction renderFull(\n bindings: ReadonlyArray<KeyBinding>,\n options: { columns: number; descriptionColor: string | undefined; keyColor: string; maxColumns: number },\n): ReactElement {\n // Group bindings\n const groups = new Map<string, KeyBinding[]>();\n\n for (const binding of bindings) {\n const group = binding.group ?? \"\";\n\n if (!groups.has(group)) {\n groups.set(group, []);\n }\n\n groups.get(group)!.push(binding);\n }\n\n const groupEntries = [...groups.entries()];\n const columnCount = Math.min(options.maxColumns, groupEntries.length || 1);\n const columnWidth = Math.floor(options.columns / columnCount);\n\n const columnNodes: ReactNode[] = [];\n\n for (const [index, [groupName, groupBindings]] of groupEntries.entries()) {\n const rows: ReactNode[] = [];\n\n if (groupName) {\n rows.push(\n <Text bold key=\"title\">\n {groupName}\n </Text>,\n );\n }\n\n for (const [bIndex, binding] of groupBindings.entries()) {\n const keys = formatKeys(binding.key);\n\n rows.push(\n <Box key={`b-${bIndex}`}>\n <Text color={options.keyColor}>{keys}</Text>\n <Text> </Text>\n {options.descriptionColor\n ? (\n <Text color={options.descriptionColor}>{binding.description}</Text>\n )\n : (\n <Text dimColor>{binding.description}</Text>\n )}\n </Box>,\n );\n }\n\n columnNodes.push(\n <Box flexDirection=\"column\" key={`col-${index}`} width={columnWidth}>\n {rows}\n </Box>,\n );\n }\n\n return <Box flexDirection=\"row\">{columnNodes}</Box>;\n}\n\nexport { Help };\nexport type { Props as HelpProps };\n",
17
+ "path": "registry/ui/help.tsx",
18
+ "target": "components/ui/help.tsx",
19
+ "type": "registry:ui"
20
+ }
21
+ ]
22
+ }
@@ -0,0 +1,26 @@
1
+ {
2
+ "$schema": "https://ui.shadcn.com/schema/registry-item.json",
3
+ "name": "histogram",
4
+ "type": "registry:ui",
5
+ "title": "Histogram",
6
+ "description": "Histogram component for @visulima/tui.",
7
+ "categories": [
8
+ "ui"
9
+ ],
10
+ "dependencies": [
11
+ "@visulima/colorize",
12
+ "@visulima/tui",
13
+ "type-fest"
14
+ ],
15
+ "registryDependencies": [
16
+ "https://visulima.com/r/bar-chart.json"
17
+ ],
18
+ "files": [
19
+ {
20
+ "content": "/* eslint-disable react/function-component-definition */\nimport type { AnsiColors } from \"@visulima/colorize\";\nimport type { ReactElement } from \"react\";\nimport { useMemo } from \"react\";\nimport type { LiteralUnion } from \"type-fest\";\n\nimport BarChart from \"@/components/ui/bar-chart\";\n\nexport type Props = {\n /**\n * Number of equal-width buckets. Ignored if `thresholds` is set.\n * @default 10\n */\n readonly bins?: number;\n\n /**\n * Bar color.\n */\n readonly color?: LiteralUnion<AnsiColors, string>;\n\n /**\n * Raw values to bin.\n */\n readonly data: ReadonlyArray<number>;\n\n /**\n * Plot height in character rows.\n * @default 10\n */\n readonly height?: number;\n\n /**\n * Optional fixed maximum.\n */\n readonly max?: number;\n\n /**\n * Optional fixed minimum.\n */\n readonly min?: number;\n\n /**\n * Render a numeric count above each bucket.\n * @default false\n */\n readonly showValues?: boolean;\n\n /**\n * Explicit bin edges. When provided, `bins`, `min`, and `max` are ignored.\n * Must be strictly increasing.\n */\n readonly thresholds?: ReadonlyArray<number>;\n\n /**\n * Plot width in character columns.\n */\n readonly width?: number;\n};\n\ntype Bucket = {\n readonly count: number;\n readonly label: string;\n};\n\nconst computeThresholds = (min: number, max: number, bins: number): ReadonlyArray<number> => {\n if (bins <= 0 || min >= max) {\n return [min, max];\n }\n\n const step = (max - min) / bins;\n\n return Array.from({ length: bins + 1 }, (_, index) => min + index * step);\n};\n\nconst binData = (data: ReadonlyArray<number>, thresholds: ReadonlyArray<number>): ReadonlyArray<Bucket> => {\n if (thresholds.length < 2) {\n return [];\n }\n\n const counts: number[] = Array.from<number>({ length: thresholds.length - 1 }).fill(0);\n const lowerBound = thresholds[0]!;\n const upperBound = thresholds[thresholds.length - 1]!;\n\n for (const value of data) {\n // Skip values that fall outside the configured range — both\n // underflow and overflow are dropped symmetrically.\n if (value < lowerBound || value > upperBound) {\n continue;\n }\n\n // Find the bucket via linear scan (fine for typical bin counts).\n for (let index = 0; index < thresholds.length - 1; index += 1) {\n const isLastBin = index === thresholds.length - 2;\n const upper = thresholds[index + 1]!;\n const withinBin = value < upper || (isLastBin && value <= upper);\n\n if (withinBin) {\n counts[index] = (counts[index] ?? 0) + 1;\n break;\n }\n }\n }\n\n return counts.map((count, index) => {\n const low = thresholds[index]!;\n const high = thresholds[index + 1]!;\n\n return {\n count,\n label: `${Math.round(low)}-${Math.round(high)}`,\n };\n });\n};\n\n/**\n * Histogram built on top of BarChart. Bins the raw values (via explicit\n * thresholds or equal-width bins) and hands the counts off to BarChart.\n * @returns A `ReactElement` rendering a BarChart of bucket counts.\n */\nexport default function Histogram({\n bins = 10,\n color,\n data,\n height = 10,\n max: maxOverride,\n min: minOverride,\n showValues = false,\n thresholds,\n width,\n}: Props): ReactElement {\n const buckets = useMemo(() => {\n if (thresholds && thresholds.length >= 2) {\n return binData(data, thresholds);\n }\n\n if (data.length === 0) {\n return [];\n }\n\n let min = minOverride ?? Infinity;\n let max = maxOverride ?? -Infinity;\n\n if (minOverride === undefined || maxOverride === undefined) {\n for (const value of data) {\n if (minOverride === undefined && value < min) {\n min = value;\n }\n\n if (maxOverride === undefined && value > max) {\n max = value;\n }\n }\n }\n\n if (min === max) {\n return [{ count: data.length, label: min.toString() }];\n }\n\n return binData(data, computeThresholds(min, max, bins));\n }, [data, thresholds, bins, minOverride, maxOverride]);\n\n return (\n <BarChart\n data={buckets.map((bucket) => {\n return { color, label: bucket.label, value: bucket.count };\n })}\n height={height}\n orientation=\"vertical\"\n showLabels\n showValues={showValues}\n width={width}\n />\n );\n}\n\nexport { Histogram };\nexport type { Props as HistogramProps };\n",
21
+ "path": "registry/ui/histogram.tsx",
22
+ "target": "components/ui/histogram.tsx",
23
+ "type": "registry:ui"
24
+ }
25
+ ]
26
+ }
@@ -0,0 +1,24 @@
1
+ {
2
+ "$schema": "https://ui.shadcn.com/schema/registry-item.json",
3
+ "name": "image",
4
+ "type": "registry:ui",
5
+ "title": "Image",
6
+ "description": "Image 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 Pixel = LiteralUnion<AnsiColors, string> | undefined;\n\nexport type Props = {\n /**\n * Character drawn for a transparent (undefined) pixel; defaults to a space.\n */\n readonly emptyChar?: string;\n\n /**\n * A row-major grid of colors, one entry per pixel. `undefined` is treated\n * as transparent. Two vertical pixels share one character cell via the\n * upper-half-block technique, so the rendered height is `ceil(rows / 2)`.\n */\n readonly pixels: ReadonlyArray<ReadonlyArray<Pixel>>;\n};\n\nconst UPPER_HALF = \"▀\";\nconst LOWER_HALF = \"▄\";\n\n/**\n * Render a small pixel grid using the upper-half-block technique: each text\n * cell packs two vertical pixels — the top pixel as the glyph's foreground and\n * the bottom pixel as its background — doubling vertical resolution.\n */\nexport default function Image({ emptyChar = \" \", pixels }: Props): ReactElement {\n let width = 0;\n\n for (const row of pixels) {\n width = Math.max(width, row.length);\n }\n\n const cellRows: ReactElement[] = [];\n\n for (let y = 0; y < pixels.length; y += 2) {\n const top = pixels[y] ?? [];\n const bottom = pixels[y + 1] ?? [];\n const spans: ReactElement[] = [];\n\n for (let x = 0; x < width; x += 1) {\n const topColor = top[x];\n const bottomColor = bottom[x];\n\n if (topColor === undefined && bottomColor === undefined) {\n spans.push(<Text key={x}>{emptyChar}</Text>);\n\n continue;\n }\n\n // A transparent top over an opaque bottom must not paint the upper\n // half in the default foreground — draw the lower-half block instead\n // so the top stays truly empty.\n if (topColor === undefined) {\n spans.push(\n <Text color={bottomColor} key={x}>\n {LOWER_HALF}\n </Text>,\n );\n\n continue;\n }\n\n spans.push(\n <Text backgroundColor={bottomColor} color={topColor} key={x}>\n {UPPER_HALF}\n </Text>,\n );\n }\n\n cellRows.push(\n\n <Box key={y}>{spans}</Box>,\n );\n }\n\n return <Box flexDirection=\"column\">{cellRows}</Box>;\n}\n\nexport { Image };\nexport type { Props as ImageProps };\n",
19
+ "path": "registry/ui/image.tsx",
20
+ "target": "components/ui/image.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": "json",
4
+ "type": "registry:ui",
5
+ "title": "Json",
6
+ "description": "Json 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, useState } from \"react\";\nimport type { LiteralUnion } from \"type-fest\";\n\ntype Color = LiteralUnion<AnsiColors, string>;\n\nexport type JsonTheme = {\n readonly boolean: Color;\n readonly bracket: Color;\n readonly key: Color;\n readonly null: Color;\n readonly number: Color;\n readonly string: Color;\n};\n\nconst DEFAULT_THEME: JsonTheme = {\n boolean: \"yellow\",\n bracket: \"gray\",\n key: \"cyan\",\n null: \"gray\",\n number: \"green\",\n string: \"yellow\",\n};\n\nexport type Props = {\n /**\n * Accent color for the focused row.\n * @default \"blue\"\n */\n readonly accentColor?: Color;\n\n /**\n * Auto-focus for keyboard navigation.\n */\n readonly autoFocus?: boolean;\n\n /**\n * Depth at which nested containers start collapsed. `0` collapses\n * everything below the root; `Infinity` expands all.\n * @default Infinity\n */\n readonly collapseDepth?: number;\n\n /**\n * The value to render.\n */\n readonly data: unknown;\n\n /**\n * When true, arrow keys move a cursor and Space toggles the focused\n * container. When false, the tree renders statically fully expanded down to\n * `collapseDepth`.\n * @default true\n */\n readonly interactive?: boolean;\n\n /**\n * Color theme for the different JSON token kinds.\n */\n readonly theme?: Partial<JsonTheme>;\n};\n\ntype Row = {\n readonly collapsible: boolean;\n readonly depth: number;\n readonly id: string;\n readonly key?: string;\n readonly summary?: string;\n readonly value: unknown;\n};\n\nconst kindOf = (value: unknown): \"array\" | \"boolean\" | \"null\" | \"number\" | \"object\" | \"string\" | \"undefined\" => {\n if (value === null) {\n return \"null\";\n }\n\n if (Array.isArray(value)) {\n return \"array\";\n }\n\n const type = typeof value;\n\n if (type === \"object\" || type === \"boolean\" || type === \"number\" || type === \"string\" || type === \"undefined\") {\n return type;\n }\n\n return \"string\";\n};\n\nconst isContainer = (value: unknown): boolean => {\n const kind = kindOf(value);\n\n return kind === \"array\" || kind === \"object\";\n};\n\nconst summarize = (value: unknown): string => {\n if (Array.isArray(value)) {\n return `[…${value.length}]`;\n }\n\n return `{…${Object.keys(value as object).length}}`;\n};\n\n/** Depth-first flatten of the value into visible rows, honouring collapse. */\nconst flatten = (value: unknown, key: string | undefined, depth: number, id: string, collapsed: ReadonlySet<string>, out: Row[]): void => {\n const collapsible = isContainer(value) && (Array.isArray(value) ? value.length > 0 : Object.keys(value as object).length > 0);\n\n out.push({ collapsible, depth, id, key, summary: collapsible ? summarize(value) : undefined, value });\n\n if (!collapsible || collapsed.has(id)) {\n return;\n }\n\n if (Array.isArray(value)) {\n value.forEach((item, index) => {\n flatten(item, String(index), depth + 1, `${id}.${index}`, collapsed, out);\n });\n\n return;\n }\n\n for (const [childKey, childValue] of Object.entries(value as Record<string, unknown>)) {\n flatten(childValue, childKey, depth + 1, `${id}.${childKey}`, collapsed, out);\n }\n};\n\nfunction wrap(value: number, size: number): number {\n return size === 0 ? 0 : ((value % size) + size) % size;\n}\n\n/**\n * A collapsible JSON viewer. Objects and arrays can be folded; when\n * `interactive`, arrow keys move a cursor and Space toggles the focused\n * container. Values are colored by type.\n */\nexport default function Json({\n accentColor = \"blue\",\n autoFocus = false,\n collapseDepth = Number.POSITIVE_INFINITY,\n data,\n interactive = true,\n theme,\n}: Props): ReactElement {\n const palette: JsonTheme = { ...DEFAULT_THEME, ...theme };\n const { isFocused } = useFocus({ autoFocus, isActive: interactive });\n\n const initialCollapsed = useMemo(() => {\n const set = new Set<string>();\n const walk = (value: unknown, depth: number, id: string): void => {\n if (!isContainer(value)) {\n return;\n }\n\n if (depth >= collapseDepth) {\n set.add(id);\n }\n\n const entries = Array.isArray(value) ? value.map((item, index) => [String(index), item] as const) : Object.entries(value as object);\n\n for (const [childKey, childValue] of entries) {\n walk(childValue, depth + 1, `${id}.${childKey}`);\n }\n };\n\n walk(data, 0, \"$\");\n\n return set;\n }, [collapseDepth, data]);\n\n const [collapsed, setCollapsed] = useState<ReadonlySet<string>>(initialCollapsed);\n const [cursor, setCursor] = useState(0);\n\n const rows = useMemo(() => {\n const out: Row[] = [];\n\n flatten(data, undefined, 0, \"$\", collapsed, out);\n\n return out;\n }, [collapsed, data]);\n\n const toggle = useCallback((id: string) => {\n setCollapsed((current) => {\n const next = new Set(current);\n\n if (next.has(id)) {\n next.delete(id);\n } else {\n next.add(id);\n }\n\n return next;\n });\n }, []);\n\n const inputHandler = useCallback(\n (input: string, key: { downArrow: boolean; leftArrow: boolean; rightArrow: boolean; upArrow: boolean }) => {\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 const row = rows[cursor];\n\n if (!row?.collapsible) {\n return;\n }\n\n const expand = key.rightArrow && collapsed.has(row.id);\n const collapse = key.leftArrow && !collapsed.has(row.id);\n\n if (input === \" \" || expand || collapse) {\n toggle(row.id);\n }\n },\n [collapsed, cursor, rows, toggle],\n );\n\n useInput(inputHandler, { isActive: interactive && isFocused });\n\n const renderValue = (row: Row): ReactElement => {\n if (row.collapsible) {\n const marker = collapsed.has(row.id) ? \"▸\" : \"▾\";\n\n return (\n <Text color={palette.bracket}>\n {marker}\n {\" \"}\n {row.summary}\n </Text>\n );\n }\n\n const kind = kindOf(row.value);\n\n // Empty containers are not collapsible, so they reach here — render them\n // as `[]`/`{}` rather than falling through to the null branch.\n if (kind === \"array\" || kind === \"object\") {\n return <Text color={palette.bracket}>{kind === \"array\" ? \"[]\" : \"{}\"}</Text>;\n }\n\n if (kind === \"string\") {\n return <Text color={palette.string}>{JSON.stringify(row.value)}</Text>;\n }\n\n if (kind === \"number\") {\n return <Text color={palette.number}>{String(row.value)}</Text>;\n }\n\n if (kind === \"boolean\") {\n return <Text color={palette.boolean}>{String(row.value)}</Text>;\n }\n\n return <Text color={palette.null}>{row.value === undefined ? \"undefined\" : \"null\"}</Text>;\n };\n\n return (\n <Box flexDirection=\"column\">\n {rows.map((row, index) => {\n const isActive = interactive && isFocused && index === cursor;\n\n return (\n <Box key={row.id}>\n <Text color={isActive ? accentColor : undefined}>{isActive ? \"❯\" : \" \"}</Text>\n <Text>{\" \".repeat(row.depth)}</Text>\n {row.key === undefined\n ? undefined\n : (\n <Text color={palette.key}>\n {row.key}\n :\n {\" \"}\n </Text>\n )}\n {renderValue(row)}\n </Box>\n );\n })}\n </Box>\n );\n}\n\nexport { Json };\nexport type { Props as JsonProps };\n",
19
+ "path": "registry/ui/json.tsx",
20
+ "target": "components/ui/json.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": "kbd",
4
+ "type": "registry:ui",
5
+ "title": "Kbd",
6
+ "description": "Kbd 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 Props = {\n /**\n * Key cap content.\n */\n readonly children: ReactNode;\n\n /**\n * Background color of the key cap.\n * @default \"gray\"\n */\n readonly color?: LiteralUnion<AnsiColors, string>;\n\n /**\n * Visual style.\n * - `solid` (default): background filled\n * - `outline`: border only\n * - `bare`: no decoration, just dimmed text\n */\n readonly variant?: \"bare\" | \"outline\" | \"solid\";\n};\n\n/**\n * Render a keyboard key (e.g. `&lt;Kbd>Enter&lt;/Kbd>`).\n * @returns A `ReactElement` styled as a key cap (solid / outline / bare).\n */\nexport default function Kbd({ children, color = \"gray\", variant = \"solid\" }: Props): ReactElement {\n if (variant === \"solid\") {\n return (\n <Text backgroundColor={color} color=\"black\">\n {\" \"}\n {children}\n{\" \"}\n </Text>\n );\n }\n\n if (variant === \"outline\") {\n return (\n <Box borderColor={color} borderStyle=\"round\" paddingX={1}>\n <Text color={color}>{children}</Text>\n </Box>\n );\n }\n\n return <Text dimColor>{children}</Text>;\n}\n\nexport { Kbd };\nexport type { Props as KbdProps };\n",
19
+ "path": "registry/ui/kbd.tsx",
20
+ "target": "components/ui/kbd.tsx",
21
+ "type": "registry:ui"
22
+ }
23
+ ]
24
+ }
@@ -0,0 +1,26 @@
1
+ {
2
+ "$schema": "https://ui.shadcn.com/schema/registry-item.json",
3
+ "name": "line-chart",
4
+ "type": "registry:ui",
5
+ "title": "Line Chart",
6
+ "description": "Line Chart component for @visulima/tui.",
7
+ "categories": [
8
+ "ui"
9
+ ],
10
+ "dependencies": [
11
+ "@visulima/colorize",
12
+ "@visulima/tui",
13
+ "type-fest"
14
+ ],
15
+ "registryDependencies": [
16
+ "https://visulima.com/r/chart-utils.json"
17
+ ],
18
+ "files": [
19
+ {
20
+ "content": "/* eslint-disable react/function-component-definition */\nimport type { AnsiColors } from \"@visulima/colorize\";\nimport type { CanvasContext } from \"@visulima/tui/canvas\";\nimport { createBrailleGrid } from \"@visulima/tui/canvas\";\nimport Box from \"@visulima/tui/components/box\";\nimport Canvas from \"@visulima/tui/components/canvas\";\nimport Text from \"@visulima/tui/components/text\";\nimport type { ReactElement } from \"react\";\nimport { useMemo } from \"react\";\nimport type { LiteralUnion } from \"type-fest\";\n\nimport type { Point } from \"@/lib/chart-utils\";\nimport { computeExtents, DEFAULT_CHART_PALETTE, pickSeriesColor, toPoints } from \"@/lib/chart-utils\";\n\nexport type LineSeries = {\n /**\n * Per-series color override. Falls back to the chart-level palette.\n */\n readonly color?: LiteralUnion<AnsiColors, string>;\n\n /**\n * Numeric samples to plot. Pass either a list of y-values (x is the\n * index) or {x, y} tuples.\n */\n readonly data: ReadonlyArray<number> | ReadonlyArray<{ readonly x: number; readonly y: number }>;\n\n /**\n * Optional label shown in the legend.\n */\n readonly label?: string;\n};\n\nexport type LineChartProps = {\n /**\n * Color for axis ticks / labels.\n * @default \"gray\"\n */\n readonly axisColor?: LiteralUnion<AnsiColors, string>;\n\n /**\n * Chart body height in character rows.\n * @default 10\n */\n readonly height?: number;\n\n /**\n * Upper bound for the y-axis. When omitted the chart auto-scales.\n */\n readonly maxY?: number;\n\n /**\n * Lower bound for the y-axis. When omitted the chart auto-scales.\n */\n readonly minY?: number;\n\n /**\n * Fallback palette cycled through when a series does not set `color`.\n * @default [\"cyan\", \"magenta\", \"yellow\", \"green\", \"blue\", \"red\"]\n */\n readonly palette?: ReadonlyArray<LiteralUnion<AnsiColors, string>>;\n\n /**\n * One or more line series.\n */\n readonly series: ReadonlyArray<LineSeries>;\n\n /**\n * Render the legend row under the plot.\n * @default true\n */\n readonly showLegend?: boolean;\n\n /**\n * Chart body width in character columns.\n * @default 40\n */\n readonly width?: number;\n};\n\nconst DEFAULT_PALETTE = DEFAULT_CHART_PALETTE;\n\ntype RenderConfig = {\n readonly axisColor: LiteralUnion<AnsiColors, string>;\n readonly palette: ReadonlyArray<LiteralUnion<AnsiColors, string>>;\n readonly seriesList: ReadonlyArray<{ color: LiteralUnion<AnsiColors, string>; points: ReadonlyArray<Point>; series: LineSeries }>;\n readonly xMax: number;\n readonly xMin: number;\n readonly yMax: number;\n readonly yMin: number;\n};\n\n/**\n * Project an (x, y) data point into pixel-grid coordinates for the given\n * canvas size. Used by every chart that paints on top of a braille grid.\n */\n// eslint-disable-next-line react-refresh/only-export-components -- shared chart helper used by area-chart and scatter-plot\nexport const projectPoint = (\n point: Point,\n config: Pick<RenderConfig, \"xMax\" | \"xMin\" | \"yMax\" | \"yMin\">,\n pixelWidth: number,\n pixelHeight: number,\n): { px: number; py: number } => {\n const xRange = config.xMax - config.xMin || 1;\n const yRange = config.yMax - config.yMin || 1;\n const nx = (point.x - config.xMin) / xRange;\n const ny = (point.y - config.yMin) / yRange;\n\n return {\n px: Math.max(0, Math.min(pixelWidth - 1, Math.round(nx * (pixelWidth - 1)))),\n py: Math.max(0, Math.min(pixelHeight - 1, Math.round((1 - ny) * (pixelHeight - 1)))),\n };\n};\n\n/**\n * Shared drawing path used by LineChart and ScatterPlot. AreaChart wraps\n * this with a fill pass.\n */\n// eslint-disable-next-line react-refresh/only-export-components -- shared chart helper used by area-chart and scatter-plot\nexport const drawSeriesOnCanvas = (context: CanvasContext, mode: \"line\" | \"scatter\", config: RenderConfig): void => {\n context.clear();\n\n const { axisColor, seriesList } = config;\n const pixelWidth = Math.max(1, context.width * 2);\n const pixelHeight = Math.max(1, context.height * 4);\n\n // Dashed baseline at the bottom of the plot in axis color.\n for (let cellX = 0; cellX < context.width; cellX += 2) {\n context.setCell(cellX, context.height - 1, \"·\", { color: axisColor, dim: true });\n }\n\n for (const { color, points } of seriesList) {\n if (points.length === 0) {\n continue;\n }\n\n const grid = createBrailleGrid(context.width, context.height);\n\n if (mode === \"scatter\") {\n for (const point of points) {\n const projected = projectPoint(point, config, pixelWidth, pixelHeight);\n\n grid.plotPoint(projected.px, projected.py);\n }\n } else {\n let previous = projectPoint(points[0]!, config, pixelWidth, pixelHeight);\n\n grid.plotPoint(previous.px, previous.py);\n\n for (let index = 1; index < points.length; index += 1) {\n const current = projectPoint(points[index]!, config, pixelWidth, pixelHeight);\n\n grid.plotLine(previous.px, previous.py, current.px, current.py);\n previous = current;\n }\n }\n\n grid.flush(context, { color });\n }\n};\n\n/**\n * Smooth multi-series line chart rendered at 2x4 sub-cell resolution using\n * Unicode braille patterns. Auto-scales both axes by default.\n */\nexport default function LineChart({\n axisColor = \"gray\",\n height = 10,\n maxY,\n minY,\n palette = DEFAULT_PALETTE,\n series,\n showLegend = true,\n width = 40,\n}: LineChartProps): ReactElement {\n const config = useMemo<RenderConfig>(() => {\n const seriesList = series.map((input, index) => {\n return {\n color: pickSeriesColor(input, index, palette),\n points: toPoints(input.data),\n series: input,\n };\n });\n\n const extents = computeExtents(seriesList);\n\n return {\n axisColor,\n palette,\n seriesList,\n xMax: extents.xMax === Number.NEGATIVE_INFINITY ? 1 : extents.xMax,\n xMin: extents.xMin === Number.POSITIVE_INFINITY ? 0 : extents.xMin,\n yMax: maxY ?? (extents.yMax === Number.NEGATIVE_INFINITY ? 1 : extents.yMax),\n yMin: minY ?? (extents.yMin === Number.POSITIVE_INFINITY ? 0 : extents.yMin),\n };\n }, [series, axisColor, palette, minY, maxY]);\n\n return (\n <Box flexDirection=\"column\">\n <Canvas\n // eslint-disable-next-line react-perf/jsx-no-new-function-as-prop -- canvas re-renders on `version` change, not draw identity\n draw={(context: CanvasContext) => {\n drawSeriesOnCanvas(context, \"line\", config);\n }}\n height={height}\n // eslint-disable-next-line react-perf/jsx-no-new-array-as-prop -- version array is the canvas redraw key\n version={[series, width, height, minY, maxY, axisColor, palette]}\n width={width}\n />\n {/* eslint-disable-next-line @stylistic/multiline-ternary -- prettier formats JSX ternaries on one line */}\n {showLegend ? (\n <Box gap={2} marginTop={1}>\n {config.seriesList.map(({ color, series: input }, index) => (\n // Composite key: series index plus label so two\n // series sharing a label don't collide.\n // eslint-disable-next-line react-x/no-array-index-key -- series index is stable for the render\n <Box gap={1} key={`${index}:${input.label ?? \"\"}`}>\n <Text color={color}>●</Text>\n <Text>{input.label ?? `Series ${index + 1}`}</Text>\n </Box>\n ))}\n </Box>\n ) : undefined}\n </Box>\n );\n}\n\nexport type { RenderConfig as LineChartRenderConfig };\n\nexport { LineChart };\n",
21
+ "path": "registry/ui/line-chart.tsx",
22
+ "target": "components/ui/line-chart.tsx",
23
+ "type": "registry:ui"
24
+ }
25
+ ]
26
+ }
@@ -0,0 +1,23 @@
1
+ {
2
+ "$schema": "https://ui.shadcn.com/schema/registry-item.json",
3
+ "name": "link",
4
+ "type": "registry:ui",
5
+ "title": "Link",
6
+ "description": "Link component for @visulima/tui.",
7
+ "categories": [
8
+ "ui"
9
+ ],
10
+ "dependencies": [
11
+ "@visulima/ansi",
12
+ "@visulima/tui"
13
+ ],
14
+ "registryDependencies": [],
15
+ "files": [
16
+ {
17
+ "content": "/* eslint-disable react/function-component-definition */\n\n/**\n * Clickable terminal link component for Ink.\n *\n * Based on ink-link by Sindre Sorhus.\n * @see https://github.com/sindresorhus/ink-link\n *\n * MIT License\n * Copyright (c) Sindre Sorhus (https://sindresorhus.com)\n */\nimport { hyperlink } from \"@visulima/ansi\";\nimport Text from \"@visulima/tui/components/text\";\nimport Transform from \"@visulima/tui/components/transform\";\nimport type { ReactElement, ReactNode } from \"react\";\nimport { useCallback } from \"react\";\n\ntype Props = {\n readonly children: ReactNode;\n\n /**\n * Determines whether the URL should be printed for unsupported terminals.\n *\n * - `true` (default): appends the URL after the text, e.g. `\"My website https://example.com\"`\n * - `false`: renders just the text with no URL\n * - `function`: custom fallback formatting, e.g. `(text, url) => \\`[\\${text}](\\${url})\\``\n * @default true\n */\n readonly fallback?: ((text: string, url: string) => string) | boolean;\n\n /**\n * The URL to link to.\n */\n readonly url: string;\n};\n\n/**\n * Lightweight check for OSC 8 hyperlink support.\n *\n * Covers the most common terminals. For exhaustive detection,\n * use the `supports-hyperlinks` package externally.\n */\nlet cachedSupportsHyperlinks: boolean | undefined;\n\nconst detectHyperlinkSupport = (): boolean => {\n // Not a TTY — no hyperlink support\n if (!process.stdout.isTTY) {\n return false;\n }\n\n const term = process.env[\"TERM_PROGRAM\"] ?? \"\";\n const termVersion = process.env[\"TERM_PROGRAM_VERSION\"] ?? \"\";\n\n // Known supporting terminals\n if (term === \"iTerm.app\") {\n const major = Number.parseInt(termVersion.split(\".\")[0] ?? \"0\", 10);\n\n return major >= 3;\n }\n\n if (term === \"WezTerm\" || term === \"ghostty\") {\n return true;\n }\n\n if (term === \"vscode\") {\n const parts = termVersion.split(\".\");\n const major = Number.parseInt(parts[0] ?? \"0\", 10);\n const minor = Number.parseInt(parts[1] ?? \"0\", 10);\n\n return major > 1 || (major === 1 && minor >= 72);\n }\n\n // Windows Terminal\n if (process.env[\"WT_SESSION\"]) {\n return true;\n }\n\n // VTE-based terminals (GNOME Terminal, Tilix, etc.)\n const vteVersion = process.env[\"VTE_VERSION\"];\n\n if (vteVersion) {\n const v = Number.parseInt(vteVersion, 10);\n\n return v >= 5000 && v !== 5000;\n }\n\n return false;\n};\n\nconst supportsHyperlinks = (): boolean => {\n // Skip cache when FORCE_HYPERLINK is set (allows runtime override)\n if (process.env[\"FORCE_HYPERLINK\"]) {\n return process.env[\"FORCE_HYPERLINK\"] !== \"0\";\n }\n\n if (cachedSupportsHyperlinks !== undefined) {\n return cachedSupportsHyperlinks;\n }\n\n cachedSupportsHyperlinks = detectHyperlinkSupport();\n\n return cachedSupportsHyperlinks;\n};\n\n/**\n * An Ink component that creates clickable links in the terminal using OSC 8 hyperlink sequences.\n *\n * [Supported terminals.](https://gist.github.com/egmontkob/eb114294efbcd5adb1944c9f3cb5feda)\n *\n * For unsupported terminals, the link will be printed after the text: `My website https://example.com`.\n *\n * ```tsx\n * import { Link, Text } from \"@visulima/tui/ink\";\n *\n * &lt;Link url=\"https://example.com\"&gt;\n * &lt;Text color=\"cyan\"&gt;My Website&lt;/Text&gt;\n * &lt;/Link&gt;\n * ```\n */\nexport default function Link({ children, fallback = true, url }: Props): ReactElement {\n const transform = useCallback(\n (text: string) => {\n // Check if the terminal supports hyperlinks via OSC 8.\n // If FORCE_HYPERLINK is set, always use the hyperlink sequence.\n // Otherwise, check common terminal environment indicators.\n if (supportsHyperlinks()) {\n return hyperlink(text, url);\n }\n\n if (fallback === false) {\n return text;\n }\n\n if (typeof fallback === \"function\") {\n return fallback(text, url);\n }\n\n return `${text} ${url}`;\n },\n [fallback, url],\n );\n\n return (\n <Transform transform={transform}>\n <Text>{children}</Text>\n </Transform>\n );\n}\n\nexport type { Props };\n\nexport { Link };\nexport type { Props as LinkProps };\n",
18
+ "path": "registry/ui/link.tsx",
19
+ "target": "components/ui/link.tsx",
20
+ "type": "registry:ui"
21
+ }
22
+ ]
23
+ }
@@ -0,0 +1,27 @@
1
+ {
2
+ "$schema": "https://ui.shadcn.com/schema/registry-item.json",
3
+ "name": "loading-indicator",
4
+ "type": "registry:ui",
5
+ "title": "Loading Indicator",
6
+ "description": "Loading Indicator component for @visulima/tui.",
7
+ "categories": [
8
+ "ui"
9
+ ],
10
+ "dependencies": [
11
+ "@visulima/colorize",
12
+ "@visulima/spinner",
13
+ "@visulima/tui",
14
+ "type-fest"
15
+ ],
16
+ "registryDependencies": [
17
+ "https://visulima.com/r/spinner.json"
18
+ ],
19
+ "files": [
20
+ {
21
+ "content": "/* eslint-disable react/function-component-definition */\nimport type { AnsiColors } from \"@visulima/colorize\";\nimport type { SpinnerName } from \"@visulima/spinner\";\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\nimport Spinner from \"@/components/ui/spinner\";\n\nexport type Props = {\n /**\n * Optional message rendered after the spinner.\n */\n readonly children?: ReactNode;\n\n /**\n * Color used for the spinner and label.\n * @default \"cyan\"\n */\n readonly color?: LiteralUnion<AnsiColors, string>;\n\n /**\n * Spinner preset.\n * @default \"dots\"\n */\n readonly type?: SpinnerName;\n};\n\n/**\n * Compact spinner + label for loading states.\n */\nexport default function LoadingIndicator({ children, color = \"cyan\", type = \"dots\" }: Props): ReactElement {\n return (\n <Box gap={1}>\n <Text color={color}>\n <Spinner type={type} />\n </Text>\n {children === undefined ? undefined : <Text color={color}>{children}</Text>}\n </Box>\n );\n}\n\nexport { LoadingIndicator };\nexport type { Props as LoadingIndicatorProps };\n",
22
+ "path": "registry/ui/loading-indicator.tsx",
23
+ "target": "components/ui/loading-indicator.tsx",
24
+ "type": "registry:ui"
25
+ }
26
+ ]
27
+ }
@@ -0,0 +1,24 @@
1
+ {
2
+ "$schema": "https://ui.shadcn.com/schema/registry-item.json",
3
+ "name": "log",
4
+ "type": "registry:ui",
5
+ "title": "Log",
6
+ "description": "Log 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\nexport type LogLevel = \"debug\" | \"error\" | \"info\" | \"success\" | \"warn\";\n\nexport type LogEntry = {\n readonly level?: LogLevel;\n readonly message: string;\n readonly timestamp?: string;\n};\n\nconst LEVEL_META: Record<LogLevel, { readonly color: LiteralUnion<AnsiColors, string>; readonly label: string }> = {\n debug: { color: \"gray\", label: \"DBG\" },\n error: { color: \"red\", label: \"ERR\" },\n info: { color: \"blue\", label: \"INF\" },\n success: { color: \"green\", label: \"OK \" },\n warn: { color: \"yellow\", label: \"WRN\" },\n};\n\nexport type Props = {\n /**\n * The log entries, oldest first.\n */\n readonly entries: ReadonlyArray<LogEntry>;\n\n /**\n * Keep only the last N entries (tail). Omit to render all.\n */\n readonly maxLines?: number;\n\n /**\n * Render the `[LEVEL]` badge before each message.\n * @default true\n */\n readonly showLevel?: boolean;\n\n /**\n * Render each entry's timestamp, when present.\n * @default true\n */\n readonly showTimestamp?: boolean;\n};\n\n/**\n * A leveled log view. Renders entries with a colored level badge and optional\n * timestamp; `maxLines` tails the buffer to the most recent entries.\n */\nexport default function Log({ entries, maxLines, showLevel = true, showTimestamp = true }: Props): ReactElement {\n const visible = maxLines !== undefined && entries.length > maxLines ? entries.slice(entries.length - maxLines) : entries;\n\n return (\n <Box flexDirection=\"column\">\n {visible.map((entry, index) => {\n const meta = LEVEL_META[entry.level ?? \"info\"];\n\n return (\n // eslint-disable-next-line react-x/no-array-index-key -- entry index is stable for the render\n <Box key={index}>\n {showTimestamp && entry.timestamp !== undefined\n ? (\n <Box marginRight={1}>\n <Text dimColor>{entry.timestamp}</Text>\n </Box>\n )\n : undefined}\n {showLevel\n ? (\n <Box marginRight={1}>\n <Text bold color={meta.color}>\n {meta.label}\n </Text>\n </Box>\n )\n : undefined}\n <Text>{entry.message}</Text>\n </Box>\n );\n })}\n </Box>\n );\n}\n\nexport { Log };\nexport type { Props as LogProps };\n",
19
+ "path": "registry/ui/log.tsx",
20
+ "target": "components/ui/log.tsx",
21
+ "type": "registry:ui"
22
+ }
23
+ ]
24
+ }
@@ -0,0 +1,29 @@
1
+ {
2
+ "$schema": "https://ui.shadcn.com/schema/registry-item.json",
3
+ "name": "markdown",
4
+ "type": "registry:ui",
5
+ "title": "Markdown",
6
+ "description": "Markdown component for @visulima/tui.",
7
+ "categories": [
8
+ "ui"
9
+ ],
10
+ "dependencies": [
11
+ "@visulima/tui",
12
+ "marked"
13
+ ],
14
+ "registryDependencies": [
15
+ "https://visulima.com/r/code.json",
16
+ "https://visulima.com/r/link.json",
17
+ "https://visulima.com/r/ordered-list.json",
18
+ "https://visulima.com/r/table.json",
19
+ "https://visulima.com/r/unordered-list.json"
20
+ ],
21
+ "files": [
22
+ {
23
+ "content": "/* eslint-disable @typescript-eslint/naming-convention, @typescript-eslint/no-unnecessary-condition, jsdoc/match-description, jsx-a11y/anchor-is-valid, react-x/no-array-index-key, react/function-component-definition, sonarjs/slow-regex */\n\n/**\n * Markdown rendering component for Ink.\n *\n * Parses Markdown with `marked` and maps tokens to Ink React elements.\n * Code blocks are rendered with syntax highlighting via the Code component.\n */\nimport Box from \"@visulima/tui/components/box\";\nimport Newline from \"@visulima/tui/components/newline\";\nimport Text from \"@visulima/tui/components/text\";\nimport useWindowSize from \"@visulima/tui/hooks/use-window-size\";\nimport type { Token, Tokens } from \"marked\";\nimport { Lexer } from \"marked\";\nimport type { ReactElement, ReactNode } from \"react\";\nimport React, { useMemo } from \"react\";\n\nimport Code from \"@/components/ui/code\";\nimport Link from \"@/components/ui/link\";\nimport type { OrderedListEntry } from \"@/components/ui/ordered-list\";\nimport OrderedList from \"@/components/ui/ordered-list\";\nimport Table from \"@/components/ui/table\";\nimport type { UnorderedListEntry } from \"@/components/ui/unordered-list\";\nimport UnorderedList from \"@/components/ui/unordered-list\";\n\nexport type Props = {\n /**\n * The Markdown source string.\n */\n readonly children: string;\n\n /**\n * Shiki theme for code block syntax highlighting.\n * @default \"github-dark-default\"\n */\n readonly codeTheme?: string;\n\n /**\n * Maximum width for text wrapping. Defaults to terminal width.\n */\n readonly maxWidth?: number;\n\n /**\n * Enable streaming mode for progressive rendering.\n * When true, the component re-renders on every prop change and handles\n * incomplete Markdown gracefully (e.g., unclosed code fences, partial lists).\n * Useful for rendering AI-generated text arriving token-by-token.\n * @default false\n */\n readonly streaming?: boolean;\n};\n\n// Heading colors by depth\nconst HEADING_COLORS: Record<number, string> = {\n 1: \"cyan\",\n 2: \"green\",\n 3: \"yellow\",\n 4: \"blue\",\n 5: \"magenta\",\n 6: \"gray\",\n};\n\ntype InlineToken = Tokens.Strong | Tokens.Em | Tokens.Del | Tokens.Codespan | Tokens.Link | Tokens.Br | Tokens.Image | Tokens.Escape | Tokens.Text;\n\n/**\n * Render inline tokens (text, bold, italic, code, links, etc.)\n */\nfunction renderInlineTokens(tokens: InlineToken[] | Token[] | undefined): ReactNode {\n if (!tokens || tokens.length === 0) {\n return null;\n }\n\n return tokens.map((token, index) => {\n switch (token.type) {\n case \"br\": {\n return <Newline key={index} />;\n }\n\n case \"codespan\": {\n return (\n <Text inverse key={index}>\n {\" \"}\n {token.text}\n{\" \"}\n </Text>\n );\n }\n\n case \"del\": {\n return (\n <Text key={index} strikethrough>\n {renderInlineTokens(token.tokens)}\n </Text>\n );\n }\n\n case \"em\": {\n return (\n <Text italic key={index}>\n {renderInlineTokens(token.tokens)}\n </Text>\n );\n }\n\n case \"escape\": {\n return <React.Fragment key={index}>{token.text}</React.Fragment>;\n }\n\n case \"image\": {\n const img = token as Tokens.Image;\n\n return (\n <Text dimColor key={index}>\n [image:\n{\" \"}\n{img.text || img.href}\n]\n </Text>\n );\n }\n\n case \"link\": {\n const link = token as Tokens.Link;\n\n return (\n <Link key={index} url={link.href}>\n {renderInlineTokens(link.tokens) ?? link.text}\n </Link>\n );\n }\n\n case \"strong\": {\n return (\n <Text bold key={index}>\n {renderInlineTokens(token.tokens)}\n </Text>\n );\n }\n\n case \"text\": {\n return <React.Fragment key={index}>{token.text}</React.Fragment>;\n }\n\n default: {\n // Unknown inline token — render raw text\n const rawText = \"raw\" in token ? (token as { raw: string }).raw : \"\";\n\n return <React.Fragment key={index}>{rawText}</React.Fragment>;\n }\n }\n });\n}\n\n/**\n * Map marked list items to OrderedListEntry or UnorderedListEntry.\n */\nfunction mapListItems(items: Token[]): (OrderedListEntry | UnorderedListEntry)[] {\n return items.map((item: Token) => {\n const listItem = item as Tokens.ListItem;\n const listItemTokens = listItem.tokens ?? [];\n\n // marked list_item has block-level tokens; the inline content is inside the first \"text\" token\n const textToken = listItemTokens.find((t) => t.type === \"text\") as Tokens.Text | undefined;\n const label = renderInlineTokens(textToken?.tokens ?? listItemTokens);\n\n // Check for nested lists within the item's tokens\n const nestedList = listItemTokens.find((t) => t.type === \"list\") as Tokens.List | undefined;\n const children = nestedList ? mapListItems(nestedList.items) : undefined;\n\n return { children, label };\n });\n}\n\n/**\n * Render block-level tokens recursively.\n */\nfunction renderBlockTokens(tokens: Token[], codeTheme: string, maxWidth: number): ReactNode[] {\n const elements: ReactNode[] = [];\n\n for (const [index, token_] of tokens.entries()) {\n const token = token_;\n const marginTop = index > 0 ? 1 : 0;\n\n switch (token.type) {\n case \"blockquote\": {\n const bq = token as Tokens.Blockquote;\n\n elements.push(\n <Box borderLeft borderLeftColor=\"gray\" key={index} marginTop={marginTop} paddingLeft={1}>\n <Box flexDirection=\"column\">{renderBlockTokens(bq.tokens, codeTheme, maxWidth - 3)}</Box>\n </Box>,\n );\n break;\n }\n\n case \"code\": {\n const code = token as Tokens.Code;\n\n elements.push(\n <Box key={index} marginTop={marginTop}>\n <Code code={code.text} language={code.lang} theme={codeTheme} />\n </Box>,\n );\n break;\n }\n\n case \"heading\": {\n const heading = token as Tokens.Heading;\n const color = HEADING_COLORS[heading.depth] ?? \"white\";\n const prefix = `${\"#\".repeat(heading.depth)} `;\n\n elements.push(\n <Box key={index} marginTop={marginTop}>\n <Text bold color={color}>\n {prefix}\n {renderInlineTokens(heading.tokens)}\n </Text>\n </Box>,\n );\n break;\n }\n\n case \"hr\": {\n elements.push(\n <Box key={index} marginTop={marginTop}>\n <Text dimColor>{\"─\".repeat(Math.min(maxWidth, 40))}</Text>\n </Box>,\n );\n break;\n }\n\n case \"html\": {\n const html = token as Tokens.HTML;\n // Strip HTML tags and render as plain text\n const stripped = html.text.replaceAll(/<[^>]*>/g, \"\");\n\n if (stripped.trim()) {\n elements.push(\n <Box key={index} marginTop={marginTop}>\n <Text dimColor>{stripped}</Text>\n </Box>,\n );\n }\n\n break;\n }\n\n case \"list\": {\n const list = token as Tokens.List;\n const items = mapListItems(list.items);\n\n if (list.ordered) {\n elements.push(\n <Box key={index} marginTop={marginTop}>\n <OrderedList items={items} />\n </Box>,\n );\n } else {\n elements.push(\n <Box key={index} marginTop={marginTop}>\n <UnorderedList items={items} />\n </Box>,\n );\n }\n\n break;\n }\n\n case \"paragraph\": {\n const para = token as Tokens.Paragraph;\n\n elements.push(\n <Box key={index} marginTop={marginTop}>\n <Text wrap=\"wrap\">{renderInlineTokens(para.tokens)}</Text>\n </Box>,\n );\n break;\n }\n\n case \"space\": {\n elements.push(<Newline key={index} />);\n break;\n }\n\n case \"table\": {\n const table = token as Tokens.Table;\n const headers: string[] = (table.header ?? []).map((cell) => cell.text);\n const rows: string[][] = (table.rows ?? []).map((row) => row.map((cell) => cell.text));\n const data = rows.map((row: string[]) => {\n const record: Record<string, string> = {};\n\n headers.forEach((header: string, colIndex: number) => {\n record[header] = row[colIndex] ?? \"\";\n });\n\n return record;\n });\n\n elements.push(\n <Box key={index} marginTop={marginTop}>\n <Table data={data} />\n </Box>,\n );\n break;\n }\n\n default: {\n // Unknown block token — render raw text\n const raw = \"raw\" in token ? (token as { raw: string }).raw : \"\";\n const text = \"text\" in token ? (token as { text: string }).text : raw;\n\n if (text) {\n elements.push(\n <Box key={index} marginTop={marginTop}>\n <Text>{text}</Text>\n </Box>,\n );\n }\n\n break;\n }\n }\n }\n\n return elements;\n}\n\n/**\n * Detect if markdown ends with an incomplete block (unclosed code fence, etc.)\n * and append the trailing text as a paragraph so it renders during streaming.\n */\nfunction lexWithStreamingFallback(source: string, streaming: boolean): Token[] {\n const tokens = Lexer.lex(source);\n\n if (!streaming || source.length === 0) {\n return tokens;\n }\n\n // Check if there's unrendered trailing text after the last token.\n // marked's Lexer silently drops text after an unclosed code fence.\n let consumedLength = 0;\n\n for (const token of tokens) {\n consumedLength += token.raw?.length ?? 0;\n }\n\n if (consumedLength < source.length) {\n const trailing = source.slice(consumedLength);\n\n if (trailing.trim().length > 0) {\n // Append trailing text as a paragraph token so it renders\n\n tokens.push({ raw: trailing, text: trailing, tokens: [{ raw: trailing, text: trailing, type: \"text\" }], type: \"paragraph\" });\n }\n }\n\n return tokens;\n}\n\n/**\n * Render Markdown content as Ink terminal UI elements.\n *\n * ```tsx\n * &lt;Markdown>{\"# Hello\\n\\nThis is **bold** and *italic*.\"}&lt;/Markdown>\n * &lt;Markdown codeTheme=\"github-dark-default\">{\"```js\\nconst x = 1;\\n```\"}&lt;/Markdown>\n * &lt;Markdown streaming>{\"# Streaming\\n\\nText arriving...\"}&lt;/Markdown>\n * ```\n */\nexport default function Markdown({ children, codeTheme = \"github-dark-default\", maxWidth: maxWidthProp, streaming = false }: Props): ReactElement {\n const { columns } = useWindowSize();\n const maxWidth = maxWidthProp ?? columns ?? 80;\n\n const tokens = useMemo(() => lexWithStreamingFallback(children, streaming), [children, streaming]);\n const elements = useMemo(() => renderBlockTokens(tokens, codeTheme, maxWidth), [tokens, codeTheme, maxWidth]);\n\n return <Box flexDirection=\"column\">{elements}</Box>;\n}\n\nexport { Markdown };\nexport type { Props as MarkdownProps };\n",
24
+ "path": "registry/ui/markdown.tsx",
25
+ "target": "components/ui/markdown.tsx",
26
+ "type": "registry:ui"
27
+ }
28
+ ]
29
+ }
@@ -0,0 +1,22 @@
1
+ {
2
+ "$schema": "https://ui.shadcn.com/schema/registry-item.json",
3
+ "name": "masked-input",
4
+ "type": "registry:ui",
5
+ "title": "Masked Input",
6
+ "description": "Masked Input 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 useInput from \"@visulima/tui/hooks/use-input\";\nimport type { ReactElement } from \"react\";\nimport { useCallback, useMemo, useState } from \"react\";\n\nexport type Props = {\n /**\n * Initial raw value (digits/characters only, without mask symbols).\n */\n readonly defaultValue?: string;\n\n /**\n * Disable input and dim the display.\n */\n readonly isDisabled?: boolean;\n\n /**\n * Mask definition. Each `#` is replaced with a user character; any other\n * character is rendered literally. Example: `(###) ###-####`.\n */\n readonly mask: string;\n\n /**\n * Called whenever the underlying raw value changes.\n */\n readonly onChange?: (value: string) => void;\n\n /**\n * Called when the user presses Enter with the current raw value.\n */\n readonly onSubmit?: (value: string) => void;\n\n /**\n * Placeholder character used in the mask for empty positions.\n * @default \"_\"\n */\n readonly placeholderChar?: string;\n\n /**\n * Sentinel character in the mask string used to mark editable positions.\n * @default \"#\" (hash)\n */\n readonly token?: string;\n};\n\ntype TokenIndex = {\n readonly maskColumn: number;\n readonly rawPosition: number;\n};\n\n/**\n * Compute the mask column for every raw (editable) position.\n */\nconst buildTokenIndex = (mask: string, token: string): ReadonlyArray<TokenIndex> => {\n const indices: TokenIndex[] = [];\n let rawPosition = 0;\n\n // eslint-disable-next-line @typescript-eslint/no-misused-spread -- mask templates are ASCII-only token characters\n for (const [maskColumn, maskChar] of [...mask].entries()) {\n if (maskChar === token) {\n indices.push({ maskColumn, rawPosition });\n rawPosition += 1;\n }\n }\n\n return indices;\n};\n\nconst formatValue = (raw: string, mask: string, token: string, placeholderChar: string): string => {\n let rawIndex = 0;\n let output = \"\";\n\n for (const maskChar of mask) {\n if (maskChar === token) {\n const rawChar = raw[rawIndex];\n\n if (rawChar === undefined) {\n output += placeholderChar;\n } else {\n output += rawChar;\n rawIndex += 1;\n }\n } else {\n output += maskChar;\n }\n }\n\n return output;\n};\n\n/**\n * Input with a fixed-width mask. The mask token (`#` by default) marks\n * positions where the user can type; every other character is preserved.\n * Supports ←/→ to move within the mask, Home/End to jump, and\n * Backspace/Delete to clear characters.\n */\nexport default function MaskedInput({\n defaultValue = \"\",\n isDisabled = false,\n mask,\n onChange,\n onSubmit,\n placeholderChar = \"_\",\n token = \"#\",\n}: Props): ReactElement {\n const tokenIndex = useMemo(() => buildTokenIndex(mask, token), [mask, token]);\n const maxLength = tokenIndex.length;\n\n const clampedDefault = defaultValue.slice(0, maxLength);\n const [raw, setRaw] = useState(clampedDefault);\n const [cursor, setCursor] = useState(clampedDefault.length);\n\n const emit = useCallback(\n (next: string, nextCursor: number) => {\n setRaw(next);\n setCursor(Math.max(0, Math.min(maxLength, nextCursor)));\n onChange?.(next);\n },\n [maxLength, onChange],\n );\n\n useInput(\n useCallback(\n (input, key) => {\n if (key.return) {\n onSubmit?.(raw);\n\n return;\n }\n\n if (key.leftArrow) {\n setCursor((previous) => Math.max(0, previous - 1));\n\n return;\n }\n\n if (key.rightArrow) {\n setCursor((previous) => Math.min(raw.length, previous + 1));\n\n return;\n }\n\n if (key.home || (key.ctrl && input === \"a\")) {\n setCursor(0);\n\n return;\n }\n\n if (key.end || (key.ctrl && input === \"e\")) {\n setCursor(raw.length);\n\n return;\n }\n\n if (key.backspace) {\n if (cursor === 0) {\n return;\n }\n\n const next = raw.slice(0, cursor - 1) + raw.slice(cursor);\n\n emit(next, cursor - 1);\n\n return;\n }\n\n if (key.delete) {\n if (cursor >= raw.length) {\n return;\n }\n\n const next = raw.slice(0, cursor) + raw.slice(cursor + 1);\n\n emit(next, cursor);\n\n return;\n }\n\n if (input.length !== 1 || raw.length >= maxLength) {\n return;\n }\n\n const next = raw.slice(0, cursor) + input + raw.slice(cursor);\n\n emit(next, cursor + 1);\n },\n [raw, cursor, maxLength, emit, onSubmit],\n ),\n { isActive: !isDisabled },\n );\n\n const display = formatValue(raw, mask, token, placeholderChar);\n // Map the raw cursor position onto a mask column so the caret lands on\n // an editable slot, even when the next character is a literal separator.\n const cursorMaskColumn = tokenIndex[cursor]?.maskColumn ?? mask.length;\n const before = display.slice(0, cursorMaskColumn);\n const atCursor = display.charAt(cursorMaskColumn) || \" \";\n const after = display.slice(cursorMaskColumn + 1);\n\n return (\n <Box>\n <Text dimColor={isDisabled}>{before}</Text>\n <Text dimColor={isDisabled} inverse={!isDisabled}>\n {atCursor}\n </Text>\n <Text dimColor={isDisabled}>{after}</Text>\n </Box>\n );\n}\n\nexport { MaskedInput };\nexport type { Props as MaskedInputProps };\n",
17
+ "path": "registry/ui/masked-input.tsx",
18
+ "target": "components/ui/masked-input.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": "menu",
4
+ "type": "registry:ui",
5
+ "title": "Menu",
6
+ "description": "Menu 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, ReactNode } from \"react\";\nimport { useCallback, useEffect, useMemo, useRef, useState } from \"react\";\nimport type { LiteralUnion } from \"type-fest\";\n\nexport type MenuItem = {\n /**\n * Optional hotkey rendered on the right side of the row (e.g. `\"Ctrl+S\"`).\n */\n readonly hotkey?: string;\n\n /**\n * Optional icon rendered before the label.\n */\n readonly icon?: ReactNode;\n\n /**\n * Identifier returned by `onSelect`.\n */\n readonly id: string;\n\n /**\n * When true, renders the item dimmed and skips it during navigation.\n */\n readonly isDisabled?: boolean;\n\n /**\n * Human-readable label.\n */\n readonly label: ReactNode;\n};\n\nexport type MenuSection = {\n readonly id: string;\n readonly items: ReadonlyArray<MenuItem>;\n readonly title?: string;\n};\n\nexport type Props = {\n /**\n * Accent color for the focused item.\n * @default \"blue\"\n */\n readonly accentColor?: LiteralUnion<AnsiColors, string>;\n\n /**\n * Auto-focus the menu on mount.\n */\n readonly autoFocus?: boolean;\n\n /**\n * Render a border around the menu.\n * @default true\n */\n readonly bordered?: boolean;\n\n /**\n * Flat list of items. Ignored if `sections` is provided.\n */\n readonly items?: ReadonlyArray<MenuItem>;\n\n /**\n * Called with the item id when the user presses Enter.\n */\n readonly onSelect: (id: string) => void;\n\n /**\n * Grouped items. Takes precedence over `items`.\n */\n readonly sections?: ReadonlyArray<MenuSection>;\n\n /**\n * Optional title rendered at the top of the menu.\n */\n readonly title?: string;\n};\n\ntype FlatRow\n = | { readonly item: MenuItem; readonly kind: \"item\"; readonly sectionId: string | undefined }\n | { readonly kind: \"section-header\"; readonly sectionId: string; readonly title: string };\n\nconst flatten = (items: ReadonlyArray<MenuItem> | undefined, sections: ReadonlyArray<MenuSection> | undefined): ReadonlyArray<FlatRow> => {\n if (sections && sections.length > 0) {\n const rows: FlatRow[] = [];\n\n for (const section of sections) {\n if (section.title !== undefined) {\n rows.push({ kind: \"section-header\", sectionId: section.id, title: section.title });\n }\n\n for (const item of section.items) {\n rows.push({ item, kind: \"item\", sectionId: section.id });\n }\n }\n\n return rows;\n }\n\n return (items ?? []).map((item) => ({ item, kind: \"item\", sectionId: undefined }) as const);\n};\n\nconst findNextIndex = (rows: ReadonlyArray<FlatRow>, from: number, direction: 1 | -1): number => {\n const total = rows.length;\n\n if (total === 0) {\n return from;\n }\n\n for (let step = 1; step <= total; step += 1) {\n const candidate = (from + direction * step + total * step) % total;\n const row = rows[candidate];\n\n if (row?.kind === \"item\" && !row.item.isDisabled) {\n return candidate;\n }\n }\n\n return from;\n};\n\nconst firstEnabledIndex = (rows: ReadonlyArray<FlatRow>): number => {\n for (const [index, row] of rows.entries()) {\n if (row.kind === \"item\" && !row.item.isDisabled) {\n return index;\n }\n }\n\n return 0;\n};\n\n/**\n * Dropdown / context menu with optional sections and disabled items.\n * Keyboard: ↑/↓ (or j/k) to navigate, Enter to select, Esc to cancel.\n * @returns A `ReactElement` rendering the menu, optionally wrapped in a\n * rounded border when `bordered` is true.\n */\nexport default function Menu({ accentColor = \"blue\", autoFocus = false, bordered = true, items, onSelect, sections, title }: Props): ReactElement {\n const { isFocused } = useFocus({ autoFocus });\n const rows = useMemo(() => flatten(items, sections), [items, sections]);\n const initialIndex = useMemo(() => firstEnabledIndex(rows), [rows]);\n const [focusedIndex, setFocusedIndex] = useState(initialIndex);\n const focusedIndexRef = useRef(focusedIndex);\n\n focusedIndexRef.current = focusedIndex;\n\n // Re-clamp focus when the set of rows changes underneath us — otherwise a\n // stale index can land on a removed/disabled row and Enter hits nothing.\n useEffect(() => {\n const { current } = focusedIndexRef;\n const row = rows[current];\n const stillValid = row?.kind === \"item\" && !row.item.isDisabled;\n\n if (!stillValid) {\n const next = firstEnabledIndex(rows);\n\n // eslint-disable-next-line react-x/set-state-in-effect -- focusedIndex is also driven by keystrokes via ref; cannot be derived during render\n setFocusedIndex(next);\n focusedIndexRef.current = next;\n }\n }, [rows]);\n\n useInput(\n useCallback(\n (input, key) => {\n if (!isFocused) {\n return;\n }\n\n if (key.downArrow || input === \"j\") {\n setFocusedIndex(findNextIndex(rows, focusedIndexRef.current, 1));\n } else if (key.upArrow || input === \"k\") {\n setFocusedIndex(findNextIndex(rows, focusedIndexRef.current, -1));\n } else if (key.return) {\n const row = rows[focusedIndexRef.current];\n\n if (row?.kind === \"item\" && !row.item.isDisabled) {\n onSelect(row.item.id);\n }\n }\n },\n [isFocused, rows, onSelect],\n ),\n { isActive: isFocused },\n );\n\n const content = (\n <Box flexDirection=\"column\">\n {title === undefined\n ? undefined\n : (\n <Box marginBottom={1}>\n <Text bold color={accentColor}>\n {title}\n </Text>\n </Box>\n )}\n {rows.map((row, index) => {\n if (row.kind === \"section-header\") {\n return (\n <Box key={`header:${row.sectionId}`} marginTop={index === 0 ? 0 : 1}>\n <Text dimColor>{row.title.toUpperCase()}</Text>\n </Box>\n );\n }\n\n const { item } = row;\n const isRowFocused = isFocused && index === focusedIndex;\n const color = isRowFocused ? accentColor : undefined;\n\n return (\n <Box key={`item:${item.id}`}>\n <Text color={color} dimColor={item.isDisabled}>\n {isRowFocused ? \"▸ \" : \" \"}\n </Text>\n {item.icon === undefined\n ? undefined\n : (\n <Text color={color} dimColor={item.isDisabled}>\n {item.icon}\n{\" \"}\n </Text>\n )}\n <Box flexGrow={1} flexShrink={1} minWidth={0}>\n <Text bold={isRowFocused} color={color} dimColor={item.isDisabled} wrap=\"truncate-end\">\n {item.label}\n </Text>\n </Box>\n {item.hotkey === undefined\n ? undefined\n : (\n <Box flexShrink={0}>\n <Text dimColor>\n{\" \"}\n{item.hotkey}\n </Text>\n </Box>\n )}\n </Box>\n );\n })}\n </Box>\n );\n\n if (!bordered) {\n return content;\n }\n\n return (\n <Box borderColor={isFocused ? accentColor : undefined} borderStyle=\"round\" paddingX={1}>\n {content}\n </Box>\n );\n}\n\nexport { Menu };\nexport type { Props as MenuProps };\n",
19
+ "path": "registry/ui/menu.tsx",
20
+ "target": "components/ui/menu.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": "message-bubble",
4
+ "type": "registry:ui",
5
+ "title": "Message Bubble",
6
+ "description": "Message Bubble 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 MessageRole = \"assistant\" | \"system\" | \"tool\" | \"user\";\n\nexport type Props = {\n /**\n * Bubble body.\n */\n readonly children: ReactNode;\n\n /**\n * Color override.\n */\n readonly color?: LiteralUnion<AnsiColors, string>;\n\n /**\n * When true, draws the bubble without a border.\n */\n readonly flat?: boolean;\n\n /**\n * Text rendered above the bubble (name, model, etc.).\n */\n readonly label?: string;\n\n /**\n * Optional metadata rendered next to the label (e.g. timestamp).\n */\n readonly meta?: string;\n\n /**\n * Determines the alignment, color, and icon of the bubble.\n * @default \"assistant\"\n */\n readonly role?: MessageRole;\n};\n\nconst ROLE_CONFIG: Record<MessageRole, { align: \"flex-start\" | \"flex-end\"; color: string; icon: string }> = {\n assistant: { align: \"flex-start\", color: \"blueBright\", icon: \"\" },\n system: { align: \"flex-start\", color: \"gray\", icon: \"\" },\n tool: { align: \"flex-start\", color: \"magentaBright\", icon: \"\" },\n user: { align: \"flex-end\", color: \"greenBright\", icon: \"\" },\n};\n\n/**\n * Chat-style message bubble for AI agent UIs.\n */\nexport default function MessageBubble({ children, color, flat = false, label, meta, role = \"assistant\" }: Props): ReactElement {\n const preset = ROLE_CONFIG[role];\n const accent = color ?? preset.color;\n\n return (\n <Box alignSelf={preset.align} flexDirection=\"column\" marginBottom={1}>\n {label === undefined && meta === undefined\n ? undefined\n : (\n <Box gap={1} paddingX={1}>\n {label === undefined\n ? undefined\n : (\n <Text bold color={accent}>\n {label}\n </Text>\n )}\n {meta === undefined ? undefined : <Text dimColor>{meta}</Text>}\n </Box>\n )}\n {flat\n ? (\n <Box paddingX={1}>\n <Text color={accent}>│ </Text>\n <Box flexDirection=\"column\" flexGrow={1}>\n {typeof children === \"string\" ? <Text>{children}</Text> : children}\n </Box>\n </Box>\n )\n : (\n <Box borderColor={accent} borderStyle=\"round\" flexDirection=\"column\" paddingX={1}>\n {typeof children === \"string\" ? <Text>{children}</Text> : children}\n </Box>\n )}\n </Box>\n );\n}\n\nexport { MessageBubble };\nexport type { Props as MessageBubbleProps };\n",
19
+ "path": "registry/ui/message-bubble.tsx",
20
+ "target": "components/ui/message-bubble.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": "model-badge",
4
+ "type": "registry:ui",
5
+ "title": "Model Badge",
6
+ "description": "Model Badge 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 Props = {\n /**\n * Background color of the badge.\n * @default \"magenta\"\n */\n readonly color?: LiteralUnion<AnsiColors, string>;\n\n /**\n * Optional icon/symbol (e.g. `✨`, `◈`) shown before the label.\n */\n readonly icon?: ReactNode;\n\n /**\n * Short model identifier, e.g. `claude-opus-4.6`.\n */\n readonly model: string;\n\n /**\n * Optional provider name rendered as a separate chip before the model.\n */\n readonly provider?: string;\n\n /**\n * Visual style.\n * @default \"solid\"\n */\n readonly variant?: \"outline\" | \"solid\" | \"subtle\";\n};\n\n/**\n * Pill-shaped chip identifying the active model / provider. Pair with\n * `MessageBubble` to label assistant output.\n */\nexport default function ModelBadge({ color = \"magenta\", icon, model, provider, variant = \"solid\" }: Props): ReactElement {\n const chip = (content: ReactNode): ReactElement => {\n const renderedIcon = icon === undefined\n ? undefined\n : (\n<>\n{icon}\n{\" \"}\n</>\n );\n\n if (variant === \"outline\") {\n return (\n <Box borderColor={color} borderStyle=\"round\" paddingX={1}>\n <Text color={color}>\n {renderedIcon}\n {content}\n </Text>\n </Box>\n );\n }\n\n if (variant === \"subtle\") {\n return (\n <Text color={color}>\n {renderedIcon}\n {content}\n </Text>\n );\n }\n\n return (\n <Text backgroundColor={color} color=\"black\">\n {\" \"}\n {renderedIcon}\n {content}\n{\" \"}\n </Text>\n );\n };\n\n if (provider === undefined) {\n return chip(model);\n }\n\n return (\n <Box>\n <Text dimColor>{provider}</Text>\n <Text dimColor>/</Text>\n {chip(model)}\n </Box>\n );\n}\n\nexport { ModelBadge };\nexport type { Props as ModelBadgeProps };\n",
19
+ "path": "registry/ui/model-badge.tsx",
20
+ "target": "components/ui/model-badge.tsx",
21
+ "type": "registry:ui"
22
+ }
23
+ ]
24
+ }