@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,23 @@
1
+ {
2
+ "$schema": "https://ui.shadcn.com/schema/registry-item.json",
3
+ "name": "spinner",
4
+ "type": "registry:ui",
5
+ "title": "Spinner",
6
+ "description": "Spinner component for @visulima/tui.",
7
+ "categories": [
8
+ "ui"
9
+ ],
10
+ "dependencies": [
11
+ "@visulima/spinner",
12
+ "@visulima/tui"
13
+ ],
14
+ "registryDependencies": [],
15
+ "files": [
16
+ {
17
+ "content": "/* eslint-disable react/function-component-definition */\n\n/**\n * Spinner component for Ink.\n *\n * Based on ink-spinner by Vadim Demedes.\n * @see https://github.com/vadimdemedes/ink-spinner\n *\n * MIT License\n * Copyright (c) Vadym Demedes (github.com/vadimdemedes)\n *\n * Permission is hereby granted, free of charge, to any person obtaining a copy of\n * this software and associated documentation files (the \"Software\"), to deal in the\n * Software without restriction, including without limitation the rights to use, copy,\n * modify, merge, publish, distribute, sublicense, and/or sell copies of the Software,\n * and to permit persons to whom the Software is furnished to do so, subject to the\n * following conditions:\n *\n * The above copyright notice and this permission notice shall be included in all\n * copies or substantial portions of the Software.\n *\n * THE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED,\n * INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A\n * PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT\n * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION\n * OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE\n * SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.\n */\nimport type { SpinnerName } from \"@visulima/spinner\";\nimport { getSpinner } from \"@visulima/spinner\";\nimport Text from \"@visulima/tui/components/text\";\nimport useAnimation from \"@visulima/tui/hooks/use-animation\";\nimport type { ReactElement } from \"react\";\n\nexport type Props = {\n /**\n * Type of a spinner.\n * Accepts any preset from `@visulima/spinner` (cli-spinners + custom frame sets).\n * @default \"dots\"\n */\n readonly type?: SpinnerName;\n};\n\n/**\n * Spinner component that renders an animated loading indicator.\n */\nexport default function Spinner({ type = \"dots\" }: Props): ReactElement {\n // `getSpinner` returns `undefined` for an unknown name; fall back to the\n // \"dots\" preset and, defensively, to a single blank frame.\n const spinner = getSpinner(type) ?? getSpinner(\"dots\");\n const frames = spinner?.frames ?? [\" \"];\n const { frame } = useAnimation({ interval: spinner?.interval ?? 80 });\n\n return <Text>{frames[frame % frames.length]}</Text>;\n}\n\nexport { Spinner };\nexport type { Props as SpinnerProps };\n",
18
+ "path": "registry/ui/spinner.tsx",
19
+ "target": "components/ui/spinner.tsx",
20
+ "type": "registry:ui"
21
+ }
22
+ ]
23
+ }
@@ -0,0 +1,24 @@
1
+ {
2
+ "$schema": "https://ui.shadcn.com/schema/registry-item.json",
3
+ "name": "status-line",
4
+ "type": "registry:ui",
5
+ "title": "Status Line",
6
+ "description": "Status Line 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 spanning the whole row.\n */\n readonly backgroundColor?: LiteralUnion<AnsiColors, string>;\n\n /**\n * Content rendered in the center. Optional.\n */\n readonly center?: ReactNode;\n\n /**\n * Default foreground for text rendered inside the row.\n */\n readonly color?: LiteralUnion<AnsiColors, string>;\n\n /**\n * Content rendered flush-left.\n */\n readonly left?: ReactNode;\n\n /**\n * Content rendered flush-right.\n */\n readonly right?: ReactNode;\n};\n\n// eslint-disable-next-line sonarjs/function-return-type -- legitimate union return based on content type\nconst renderSlot = (content: ReactNode, color: LiteralUnion<AnsiColors, string> | undefined): ReactNode => {\n if (content === undefined) {\n return undefined;\n }\n\n if (typeof content === \"string\" || typeof content === \"number\") {\n return <Text color={color}>{content}</Text>;\n }\n\n return content;\n};\n\n/**\n * Full-width status bar with left / center / right slots. Typically pinned to\n * the bottom of the UI.\n */\nexport default function StatusLine({ backgroundColor, center, color, left, right }: Props): ReactElement {\n return (\n <Box backgroundColor={backgroundColor} paddingX={1} width=\"100%\">\n <Box flexGrow={1} flexShrink={1} minWidth={0}>\n {renderSlot(left, color)}\n </Box>\n {center === undefined\n ? undefined\n : (\n <Box flexGrow={0} flexShrink={0} justifyContent=\"center\">\n {renderSlot(center, color)}\n </Box>\n )}\n <Box flexGrow={1} flexShrink={1} justifyContent=\"flex-end\" minWidth={0}>\n {renderSlot(right, color)}\n </Box>\n </Box>\n );\n}\n\nexport { StatusLine };\nexport type { Props as StatusLineProps };\n",
19
+ "path": "registry/ui/status-line.tsx",
20
+ "target": "components/ui/status-line.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": "status-message",
4
+ "type": "registry:ui",
5
+ "title": "Status Message",
6
+ "description": "Status Message component for @visulima/tui.",
7
+ "categories": [
8
+ "ui"
9
+ ],
10
+ "dependencies": [
11
+ "@visulima/tui"
12
+ ],
13
+ "registryDependencies": [
14
+ "https://visulima.com/r/variant-config.json"
15
+ ],
16
+ "files": [
17
+ {
18
+ "content": "/* eslint-disable react/function-component-definition */\n\n/**\n * Status message component for Ink.\n *\n * Inspired by ink-ui StatusMessage by Vadim Demedes.\n * @see https://github.com/vadimdemedes/ink-ui\n *\n * MIT License\n * Copyright (c) Vadym Demedes (github.com/vadimdemedes)\n */\nimport Box from \"@visulima/tui/components/box\";\nimport Text from \"@visulima/tui/components/text\";\nimport type { ReactElement, ReactNode } from \"react\";\n\nimport type { Variant } from \"@/lib/variant-config\";\nimport VARIANT_CONFIG from \"@/lib/variant-config\";\n\nexport type StatusMessageVariant = Variant;\n\nexport type Props = {\n /**\n * Message content.\n */\n readonly children: ReactNode;\n\n /**\n * Determines the icon and color of the status message.\n */\n readonly variant: StatusMessageVariant;\n};\n\n/**\n * Renders a status notification with a colored icon and message text.\n */\nexport default function StatusMessage({ children, variant }: Props): ReactElement {\n const { color, icon } = VARIANT_CONFIG[variant];\n\n return (\n <Box gap={1}>\n <Text color={color}>{icon}</Text>\n <Text>{children}</Text>\n </Box>\n );\n}\n\nexport { StatusMessage };\nexport type { Props as StatusMessageProps };\n",
19
+ "path": "registry/ui/status-message.tsx",
20
+ "target": "components/ui/status-message.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": "stepper",
4
+ "type": "registry:ui",
5
+ "title": "Stepper",
6
+ "description": "Stepper 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 { Fragment } from \"react\";\nimport type { LiteralUnion } from \"type-fest\";\n\nexport type StepStatus = \"active\" | \"completed\" | \"error\" | \"pending\";\n\nexport type StepperStep = {\n readonly description?: string;\n readonly key?: string;\n readonly label: ReactNode;\n readonly status?: StepStatus;\n};\n\nexport type Props = {\n /**\n * Color used for active and completed steps.\n * @default \"blue\"\n */\n readonly accentColor?: LiteralUnion<AnsiColors, string>;\n\n /**\n * Index of the current step (used when steps do not provide a status).\n * @default 0\n */\n readonly activeIndex?: number;\n\n /**\n * Color used for errors.\n * @default \"red\"\n */\n readonly errorColor?: LiteralUnion<AnsiColors, string>;\n\n /**\n * Orientation of the stepper.\n * @default \"horizontal\"\n */\n readonly orientation?: \"horizontal\" | \"vertical\";\n\n /**\n * List of steps.\n */\n readonly steps: ReadonlyArray<StepperStep>;\n};\n\nconst STATUS_ICON: Record<StepStatus, string> = {\n active: \"●\",\n completed: \"✔\",\n error: \"✖\",\n pending: \"○\",\n};\n\nconst resolveStatus = (step: StepperStep, index: number, activeIndex: number): StepStatus => {\n if (step.status !== undefined) {\n return step.status;\n }\n\n if (index < activeIndex) {\n return \"completed\";\n }\n\n if (index === activeIndex) {\n return \"active\";\n }\n\n return \"pending\";\n};\n\n/**\n * Step indicator for multi-stage flows.\n * @returns A `ReactElement` rendering the steps horizontally or vertically.\n */\nexport default function Stepper({ accentColor = \"blue\", activeIndex = 0, errorColor = \"red\", orientation = \"horizontal\", steps }: Props): ReactElement {\n if (orientation === \"vertical\") {\n return (\n <Box flexDirection=\"column\">\n {steps.map((step, index) => {\n const status = resolveStatus(step, index, activeIndex);\n const isLast = index === steps.length - 1;\n // eslint-disable-next-line sonarjs/no-nested-conditional -- minimal three-way status mapping\n const color = status === \"error\" ? errorColor : status === \"pending\" ? undefined : accentColor;\n\n return (\n // eslint-disable-next-line react-x/no-array-index-key -- steps expose an optional stable `key`; index is the documented fallback for keyless entries\n <Box flexDirection=\"column\" key={step.key ?? index}>\n <Box>\n <Text color={color} dimColor={status === \"pending\"}>\n {STATUS_ICON[status]}\n{\" \"}\n </Text>\n <Text bold={status === \"active\"} color={color} dimColor={status === \"pending\"}>\n {step.label}\n </Text>\n </Box>\n {step.description === undefined\n ? undefined\n : (\n <Box marginLeft={2}>\n <Text dimColor>{step.description}</Text>\n </Box>\n )}\n {isLast\n ? undefined\n : (\n <Box marginLeft={0}>\n <Text dimColor>│</Text>\n </Box>\n )}\n </Box>\n );\n })}\n </Box>\n );\n }\n\n return (\n <Box>\n {steps.map((step, index) => {\n const status = resolveStatus(step, index, activeIndex);\n // eslint-disable-next-line sonarjs/no-nested-conditional -- minimal three-way status mapping\n const color = status === \"error\" ? errorColor : status === \"pending\" ? undefined : accentColor;\n const isLast = index === steps.length - 1;\n\n return (\n // eslint-disable-next-line react-x/no-array-index-key -- steps expose an optional stable `key`; index is the documented fallback for keyless entries\n <Fragment key={step.key ?? index}>\n <Box>\n <Text color={color} dimColor={status === \"pending\"}>\n {STATUS_ICON[status]}\n{\" \"}\n </Text>\n <Text bold={status === \"active\"} color={color} dimColor={status === \"pending\"}>\n {step.label}\n </Text>\n </Box>\n {isLast ? undefined : <Text dimColor> ── </Text>}\n </Fragment>\n );\n })}\n </Box>\n );\n}\n\nexport { Stepper };\nexport type { Props as StepperProps };\n",
19
+ "path": "registry/ui/stepper.tsx",
20
+ "target": "components/ui/stepper.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": "stopwatch",
4
+ "type": "registry:ui",
5
+ "title": "Stopwatch",
6
+ "description": "Stopwatch component for @visulima/tui.",
7
+ "categories": [
8
+ "ui"
9
+ ],
10
+ "dependencies": [
11
+ "@visulima/tui"
12
+ ],
13
+ "registryDependencies": [],
14
+ "files": [
15
+ {
16
+ "content": "import Text from \"@visulima/tui/components/text\";\nimport type { UseStopwatchResult } from \"@visulima/tui/hooks/use-stopwatch\";\nimport useStopwatch from \"@visulima/tui/hooks/use-stopwatch\";\nimport { formatDuration } from \"@visulima/tui/utils\";\nimport type { ReactElement, Ref } from \"react\";\nimport { useImperativeHandle } from \"react\";\n\nexport type StopwatchRef = Omit<UseStopwatchResult, \"elapsed\" | \"isRunning\" | \"laps\">;\n\nexport type Props = {\n /**\n * Whether to start counting immediately.\n * @default false\n */\n readonly autoStart?: boolean;\n\n /**\n * Make the rendered text bold.\n */\n readonly bold?: boolean;\n\n /**\n * Text color for the rendered time.\n */\n readonly color?: string;\n\n /**\n * Custom formatter for the elapsed time.\n * Receives elapsed milliseconds, returns a display string.\n * @default formatDuration (MM:SS or HH:MM:SS)\n */\n readonly format?: (elapsed: number) => string;\n\n /**\n * Tick interval in milliseconds.\n * @default 1000\n */\n readonly interval?: number;\n\n readonly ref?: Ref<StopwatchRef>;\n};\n\n/**\n * A count-up stopwatch component.\n *\n * Exposes `start`, `stop`, `toggle`, `reset`, and `lap` methods via ref.\n *\n * ```tsx\n * const ref = useRef&lt;StopwatchRef>(null);\n * &lt;Stopwatch ref={ref} autoStart />\n * ```\n */\nconst Stopwatch = ({ autoStart, bold, color, format = formatDuration, interval, ref }: Props): ReactElement => {\n const sw = useStopwatch({ autoStart, interval });\n\n useImperativeHandle(ref, () => {\n return {\n lap: sw.lap,\n reset: sw.reset,\n start: sw.start,\n stop: sw.stop,\n toggle: sw.toggle,\n };\n }, [sw.lap, sw.reset, sw.start, sw.stop, sw.toggle]);\n\n return (\n <Text bold={bold} color={color}>\n {format(sw.elapsed)}\n </Text>\n );\n};\n\nexport default Stopwatch;\n\nexport { Stopwatch };\nexport type { Props as StopwatchProps };\n",
17
+ "path": "registry/ui/stopwatch.tsx",
18
+ "target": "components/ui/stopwatch.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": "streaming-text",
4
+ "type": "registry:ui",
5
+ "title": "Streaming Text",
6
+ "description": "Streaming Text 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 Text from \"@visulima/tui/components/text\";\nimport type { ReactElement } from \"react\";\nimport { useEffect, useRef, useState } from \"react\";\nimport type { LiteralUnion } from \"type-fest\";\n\nexport type Props = {\n /**\n * Color applied to the streamed output.\n */\n readonly color?: LiteralUnion<AnsiColors, string>;\n\n /**\n * Glyph rendered at the streaming caret while characters are still being revealed.\n * @default \"▊\" (left three quarters block)\n */\n readonly cursor?: string;\n\n /**\n * Milliseconds between characters.\n * @default 20\n */\n readonly interval?: number;\n\n /**\n * Called exactly once per `text` value, after the full text has been revealed.\n */\n readonly onComplete?: () => void;\n\n /**\n * Whether to display the cursor after streaming completes.\n * @default false\n */\n readonly showCursorWhenDone?: boolean;\n\n /**\n * Full text to stream into the output.\n */\n readonly text: string;\n};\n\n/**\n * Typewriter animation that reveals text one character at a time.\n * Automatically resets and replays when `text` changes. `onComplete`\n * fires exactly once per `text` value once it is fully revealed.\n */\nexport default function StreamingText({ color, cursor = \"▊\", interval = 20, onComplete, showCursorWhenDone = false, text }: Props): ReactElement {\n const [visibleLength, setVisibleLength] = useState(0);\n const onCompleteRef = useRef(onComplete);\n const hasCompletedRef = useRef(false);\n\n onCompleteRef.current = onComplete;\n\n useEffect(() => {\n // eslint-disable-next-line react-x/set-state-in-effect, react-you-might-not-need-an-effect/no-adjust-state-on-prop-change -- reset stream progress when source text changes; resetting during render would create an infinite loop\n setVisibleLength(0);\n hasCompletedRef.current = false;\n }, [text]);\n\n useEffect(() => {\n if (visibleLength >= text.length) {\n if (!hasCompletedRef.current) {\n hasCompletedRef.current = true;\n onCompleteRef.current?.();\n }\n\n return undefined;\n }\n\n const timer = setTimeout(() => {\n setVisibleLength((previous) => Math.min(text.length, previous + 1));\n }, interval);\n\n return () => {\n clearTimeout(timer);\n };\n }, [visibleLength, text, interval]);\n\n const done = visibleLength >= text.length;\n const showCursor = !done || showCursorWhenDone;\n const visible = text.slice(0, visibleLength);\n\n return (\n <Text color={color}>\n {visible}\n {showCursor ? cursor : \"\"}\n </Text>\n );\n}\n\nexport { StreamingText };\nexport type { Props as StreamingTextProps };\n",
19
+ "path": "registry/ui/streaming-text.tsx",
20
+ "target": "components/ui/streaming-text.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": "switch",
4
+ "type": "registry:ui",
5
+ "title": "Switch",
6
+ "description": "Switch 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, useState } from \"react\";\nimport type { LiteralUnion } from \"type-fest\";\n\nexport type Props = {\n /**\n * Accent color for the on state.\n * @default \"green\"\n */\n readonly accentColor?: LiteralUnion<AnsiColors, string>;\n\n /**\n * Auto-focus when mounted if nothing else is focused.\n */\n readonly autoFocus?: boolean;\n\n /**\n * Optional label rendered after the switch.\n */\n readonly children?: ReactNode;\n\n /**\n * Initial value when uncontrolled.\n */\n readonly defaultValue?: boolean;\n\n /**\n * Disable input and dim the switch.\n */\n readonly isDisabled?: boolean;\n\n /**\n * Labels displayed inside the switch track.\n * @default [\"off\", \"on\"]\n */\n readonly labels?: readonly [string, string];\n\n /**\n * Fired when the switch is toggled.\n */\n readonly onChange?: (value: boolean) => void;\n\n /**\n * Controlled value. Pair with `onChange` to update the parent.\n */\n readonly value?: boolean;\n};\n\nconst DEFAULT_LABELS: readonly [string, string] = [\"off\", \"on\"];\n\n/**\n * Focusable on/off switch. Toggles with Space or Enter.\n * @returns A `ReactElement` rendering two labeled segments plus an optional\n * trailing label.\n */\nexport default function Switch({\n accentColor = \"green\",\n autoFocus = false,\n children,\n defaultValue = false,\n isDisabled = false,\n labels = DEFAULT_LABELS,\n onChange,\n value,\n}: Props): ReactElement {\n const { isFocused } = useFocus({ autoFocus, isActive: !isDisabled });\n const [internal, setInternal] = useState(defaultValue);\n const current = value ?? internal;\n\n useInput(\n useCallback(\n (input, key) => {\n if (!isFocused) {\n return;\n }\n\n if (key.return || input === \" \") {\n const next = !current;\n\n if (value === undefined) {\n setInternal(next);\n }\n\n onChange?.(next);\n }\n },\n [isFocused, current, value, onChange],\n ),\n { isActive: !isDisabled && isFocused },\n );\n\n const off = labels[0];\n const on = labels[1];\n const focusColor = isFocused ? accentColor : undefined;\n\n return (\n <Box gap={1}>\n <Box gap={0}>\n <Text backgroundColor={current ? undefined : \"gray\"} color={current ? undefined : \"black\"} dimColor={isDisabled}>\n {\" \"}\n {off}\n{\" \"}\n </Text>\n <Text backgroundColor={current ? accentColor : undefined} color={current ? \"black\" : undefined} dimColor={isDisabled}>\n {\" \"}\n {on}\n{\" \"}\n </Text>\n </Box>\n {children === undefined\n ? undefined\n : (\n <Text color={focusColor} dimColor={isDisabled}>\n {children}\n </Text>\n )}\n </Box>\n );\n}\n\nexport { Switch };\nexport type { Props as SwitchProps };\n",
19
+ "path": "registry/ui/switch.tsx",
20
+ "target": "components/ui/switch.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": "tab",
4
+ "type": "registry:ui",
5
+ "title": "Tab",
6
+ "description": "Tab 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 */\n\n/**\n * Tab component for Ink.\n *\n * Inspired by ink-tab by Julien Deniau.\n * @see https://github.com/jdeniau/ink-tab\n *\n * MIT License\n * Copyright (c) Julien Deniau (github.com/jdeniau)\n */\nimport type { ReactElement, ReactNode } from \"react\";\n\nexport type Props = {\n /**\n * Tab content to render when the tab is active.\n */\n readonly children: ReactNode;\n\n /**\n * Unique name identifying this tab.\n */\n // eslint-disable-next-line react/no-unused-prop-types, react-x/no-unused-props\n readonly name: string;\n};\n\n/**\n * A single tab within a Tabs container.\n * The `name` prop identifies the tab; `children` is rendered when active.\n */\nexport default function Tab({ children }: Props): ReactElement {\n return <>{children}</>;\n}\n\nexport { Tab };\nexport type { Props as TabProps };\n",
17
+ "path": "registry/ui/tab.tsx",
18
+ "target": "components/ui/tab.tsx",
19
+ "type": "registry:ui"
20
+ }
21
+ ]
22
+ }
@@ -0,0 +1,23 @@
1
+ {
2
+ "$schema": "https://ui.shadcn.com/schema/registry-item.json",
3
+ "name": "table",
4
+ "type": "registry:ui",
5
+ "title": "Table",
6
+ "description": "Table component for @visulima/tui.",
7
+ "categories": [
8
+ "ui"
9
+ ],
10
+ "dependencies": [
11
+ "@visulima/tabular",
12
+ "@visulima/tui"
13
+ ],
14
+ "registryDependencies": [],
15
+ "files": [
16
+ {
17
+ "content": "import type { BorderStyle, HorizontalAlignment } from \"@visulima/tabular\";\nimport { Table as TabularTable } from \"@visulima/tabular\";\nimport {\n ASCII_BORDER,\n BLOCK_BORDER,\n DEFAULT_BORDER,\n DOTS_BORDER,\n DOUBLE_BORDER,\n MARKDOWN_BORDER,\n MINIMAL_BORDER,\n NO_BORDER,\n ROUNDED_BORDER,\n THICK_BORDER,\n} from \"@visulima/tabular/style\";\nimport Text from \"@visulima/tui/components/text\";\nimport type { ReactElement } from \"react\";\nimport { useMemo } from \"react\";\n\n/**\n * Scalar value that can be displayed in a table cell.\n */\ntype Scalar = bigint | boolean | number | string | null | undefined;\n\n/**\n * Dictionary with scalar values, representing a single row of data.\n */\ntype ScalarDict = Record<string, Scalar>;\n\n/**\n * Configuration for a single table column.\n */\ntype ColumnConfig<T extends ScalarDict> = {\n /**\n * Horizontal alignment of cell content.\n */\n readonly align?: HorizontalAlignment;\n\n /**\n * Custom header label. Defaults to the key name.\n */\n readonly header?: string;\n\n /**\n * Key from the data object to display in this column.\n */\n readonly key: keyof T & string;\n\n /**\n * Fixed width for this column in characters.\n */\n readonly width?: number;\n};\n\ntype Props<T extends ScalarDict> = {\n /**\n * Border style preset name or a custom `BorderStyle` object from `@visulima/tabular`.\n * @default \"default\"\n */\n readonly borderStyle?: \"ascii\" | \"block\" | \"default\" | \"dots\" | \"double\" | \"markdown\" | \"minimal\" | \"none\" | \"rounded\" | \"thick\" | BorderStyle;\n\n /**\n * Columns to display and their order.\n * Accepts simple key strings or `ColumnConfig` objects for advanced control.\n * If omitted, all keys from the first data item are used.\n */\n readonly columns?: ((keyof T & string) | ColumnConfig<T>)[];\n\n /**\n * Array of objects to display as rows.\n */\n readonly data: T[];\n\n /**\n * Format a cell value before rendering. Return a string to display in the cell.\n */\n readonly formatCell?: (value: Scalar, column: keyof T & string, rowIndex: number) => string;\n\n /**\n * Format a header label before rendering. Return a string to display as the column header.\n */\n readonly formatHeader?: (column: keyof T & string) => string;\n\n /**\n * Maximum width of the table in characters. Columns will be truncated or wrapped to fit.\n */\n readonly maxWidth?: number;\n\n /**\n * Cell padding (left and right) in characters.\n * @default 1\n */\n readonly padding?: number;\n\n /**\n * Whether to show the header row.\n * @default true\n */\n readonly showHeader?: boolean;\n\n /** Placeholder shown in place of `null` or `undefined` cell values. Defaults to an empty string. */\n readonly skeleton?: string;\n\n /**\n * Enable word wrap instead of truncation when cells exceed their width.\n * @default false\n */\n readonly wordWrap?: boolean;\n};\n\nconst BORDER_PRESETS: Record<string, BorderStyle> = {\n ascii: ASCII_BORDER,\n block: BLOCK_BORDER,\n default: DEFAULT_BORDER,\n dots: DOTS_BORDER,\n double: DOUBLE_BORDER,\n markdown: MARKDOWN_BORDER,\n minimal: MINIMAL_BORDER,\n none: NO_BORDER,\n rounded: ROUNDED_BORDER,\n thick: THICK_BORDER,\n};\n\n/**\n * Render a data table in the terminal.\n *\n * Uses `@visulima/tabular` as the rendering engine, supporting multiple border styles,\n * column configuration, cell formatting, and word wrap.\n * @example\n * ```tsx\n * import { Table } from \"@visulima/tui/ink\";\n *\n * const data = [\n * { name: \"Alice\", age: 30 },\n * { name: \"Bob\", age: 25 },\n * ];\n *\n * <Table data={data} />\n * <Table data={data} borderStyle=\"rounded\" padding={2} />\n * ```\n */\nconst toColumnConfig = <T extends ScalarDict>(col: (keyof T & string) | ColumnConfig<T>): ColumnConfig<T> => {\n if (typeof col === \"string\") {\n return { key: col };\n }\n\n return col;\n};\n\nconst TableComponent = <T extends ScalarDict>({\n borderStyle = \"default\",\n columns: columnsProp,\n data,\n formatCell,\n formatHeader,\n maxWidth,\n padding = 1,\n showHeader = true,\n skeleton = \"\",\n wordWrap = false,\n}: Props<T>): ReactElement => {\n const tableString = useMemo((): string => {\n if (data.length === 0) {\n return \"\";\n }\n\n // Resolve column configs\n let resolvedColumns: ColumnConfig<T>[];\n\n if (columnsProp) {\n resolvedColumns = columnsProp.map((col) => toColumnConfig(col));\n } else {\n resolvedColumns = (Object.keys(data[0] as object) as (keyof T & string)[]).map((key) => {\n return { key };\n });\n }\n\n if (resolvedColumns.length === 0) {\n return \"\";\n }\n\n // Resolve border style\n const border = typeof borderStyle === \"string\" ? BORDER_PRESETS[borderStyle] ?? DEFAULT_BORDER : borderStyle;\n\n // Build column widths array (undefined entries let tabular auto-calculate)\n const columnWidths: (number | undefined)[] = resolvedColumns.map((col) => col.width);\n const hasFixedWidths = columnWidths.some((w) => w !== undefined);\n\n const table = new TabularTable({\n columnWidths: hasFixedWidths ? columnWidths : undefined,\n maxWidth,\n showHeader,\n style: {\n border,\n paddingLeft: padding,\n paddingRight: padding,\n },\n wordWrap,\n });\n\n // Set headers\n if (showHeader) {\n const headers = resolvedColumns.map((col) => {\n const label = col.header ?? col.key;\n\n return formatHeader ? formatHeader(col.key) : label;\n });\n\n table.setHeaders(headers);\n }\n\n // Add data rows\n for (const [rowIndex, row] of data.entries()) {\n const cells = resolvedColumns.map((col) => {\n const value = row[col.key];\n\n if (formatCell) {\n return formatCell(value, col.key, rowIndex);\n }\n\n // eslint-disable-next-line sonarjs/different-types-comparison, @typescript-eslint/no-unnecessary-condition\n return value === undefined || value === null ? skeleton : String(value);\n });\n\n table.addRow(cells);\n }\n\n return table.toString();\n }, [borderStyle, columnsProp, data, formatCell, formatHeader, maxWidth, padding, showHeader, skeleton, wordWrap]);\n\n return <Text>{tableString}</Text>;\n};\n\nconst Table = TableComponent as <T extends ScalarDict>(props: Props<T>) => ReactElement;\n\nexport default Table;\nexport { Table };\nexport type { ColumnConfig, Props, Scalar, ScalarDict };\nexport type { ColumnConfig as TableColumnConfig, Props as TableProps, Scalar as TableScalar, ScalarDict as TableScalarDict };\n",
18
+ "path": "registry/ui/table.tsx",
19
+ "target": "components/ui/table.tsx",
20
+ "type": "registry:ui"
21
+ }
22
+ ]
23
+ }
@@ -0,0 +1,26 @@
1
+ {
2
+ "$schema": "https://ui.shadcn.com/schema/registry-item.json",
3
+ "name": "tabs",
4
+ "type": "registry:ui",
5
+ "title": "Tabs",
6
+ "description": "Tabs 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/tab.json"
17
+ ],
18
+ "files": [
19
+ {
20
+ "content": "/* eslint-disable react-you-might-not-need-an-effect/no-event-handler, react/function-component-definition */\n\n/**\n * Tabs component for Ink.\n *\n * Inspired by ink-tab by Julien Deniau.\n * @see https://github.com/jdeniau/ink-tab\n *\n * MIT License\n * Copyright (c) Julien Deniau (github.com/jdeniau)\n */\nimport type { AnsiColors } from \"@visulima/colorize\";\nimport Box from \"@visulima/tui/components/box\";\nimport Text from \"@visulima/tui/components/text\";\nimport type { Key } from \"@visulima/tui/hooks/use-input\";\nimport useInput from \"@visulima/tui/hooks/use-input\";\nimport type { ReactElement, ReactNode } from \"react\";\nimport { Children, useCallback, useEffect, useMemo, useRef, useState } from \"react\";\nimport type { LiteralUnion } from \"type-fest\";\n\nimport type { Props as TabProps } from \"@/components/ui/tab\";\n\nconst NUMBER_KEY_PATTERN = /^\\d$/u;\n\n/**\n * Keyboard navigation configuration for Tabs.\n */\nexport type KeyMap = {\n /**\n * Key names that move to the next tab.\n * Defaults to `[\"rightArrow\"]` for row layouts and `[\"downArrow\"]` for column layouts.\n */\n readonly next?: ReadonlyArray<string>;\n\n /**\n * Key names that move to the previous tab.\n * Defaults to `[\"leftArrow\"]` for row layouts and `[\"upArrow\"]` for column layouts.\n */\n readonly previous?: ReadonlyArray<string>;\n\n /**\n * Whether Meta/Cmd + 1–9 jumps to a specific tab.\n * @default true\n */\n readonly useNumbers?: boolean;\n\n /**\n * Whether the Tab key cycles through tabs (only when `isFocused` is not managed by Ink).\n * @default true\n */\n readonly useTab?: boolean;\n};\n\n/**\n * Color customization for active tab styling.\n */\nexport type TabColors = {\n readonly activeTab?: {\n readonly backgroundColor?: LiteralUnion<AnsiColors, string>;\n readonly color?: LiteralUnion<AnsiColors, string>;\n };\n};\n\nexport type Props = {\n /**\n * Tab elements to render.\n */\n readonly children: ReactNode;\n\n /**\n * Active tab colors. When `isFocused` is `false`, the active tab uses gray.\n */\n readonly colors?: TabColors;\n\n /**\n * Name of the tab to select initially (uncontrolled mode).\n * Ignored when `value` is provided.\n */\n readonly defaultValue?: string;\n\n /**\n * Layout direction for the tab bar.\n * @default \"row\"\n */\n readonly flexDirection?: \"column\" | \"column-reverse\" | \"row\" | \"row-reverse\";\n\n /**\n * Whether the tabs are focused and respond to keyboard input.\n * - `true`: focused, uses active colors.\n * - `false`: unfocused, uses gray active color, ignores input.\n * - `undefined`/`null`: always active (focus not managed by Ink).\n * @default undefined\n */\n readonly isFocused?: boolean | null;\n\n /**\n * Custom keyboard mappings.\n */\n readonly keyMap?: KeyMap;\n\n /**\n * Called when the active tab changes.\n */\n readonly onChange: (name: string, activeTab: ReactElement<TabProps>) => void;\n\n /**\n * Whether to display 1-based index numbers before each tab name.\n * @default true\n */\n readonly showIndex?: boolean;\n\n /**\n * Controlled active tab name. When provided, the component is controlled —\n * the parent owns the active tab state and must update it via `onChange`.\n * When omitted, the component manages its own state (uncontrolled mode).\n */\n readonly value?: string;\n\n /**\n * Width of the container Box. Also determines the separator width in column layouts.\n */\n readonly width?: number | string;\n};\n\nconst isTabElement = (child: unknown): child is ReactElement<TabProps> =>\n child != null && typeof child === \"object\" && \"props\" in (child as ReactElement) && \"name\" in (child as ReactElement<TabProps>).props;\n\nconst collectTabs = (children: ReactNode): ReactElement<TabProps>[] => {\n const result: ReactElement<TabProps>[] = [];\n\n // Children.toArray flattens fragments and filters out nulls/booleans\n // eslint-disable-next-line react-x/no-children-to-array\n for (const child of Children.toArray(children)) {\n if (isTabElement(child)) {\n result.push(child);\n }\n }\n\n return result;\n};\n\nconst matchesAnyKey = (keys: ReadonlyArray<string>, key: Key): boolean => keys.some((keyName) => key[keyName as keyof Key]);\n\nconst getActiveTabColors = (\n isActive: boolean,\n focused: boolean,\n colorsProp: TabColors | undefined,\n): { backgroundColor: string | undefined; color: string | undefined } => {\n if (!isActive) {\n return { backgroundColor: undefined, color: undefined };\n }\n\n if (focused) {\n return {\n backgroundColor: colorsProp?.activeTab?.backgroundColor ?? \"green\",\n color: colorsProp?.activeTab?.color ?? \"black\",\n };\n }\n\n return { backgroundColor: \"gray\", color: \"black\" };\n};\n\n/**\n * Tabbed navigation component. Renders a row or column of tabs with keyboard\n * navigation (arrow keys, Tab, number keys) and calls `onChange` when the\n * active tab changes.\n */\nexport default function Tabs({\n children,\n colors: colorsProp,\n defaultValue,\n flexDirection = \"row\",\n isFocused,\n keyMap: keyMapProp,\n onChange,\n showIndex = true,\n value,\n width,\n}: Props): ReactElement {\n const tabs = collectTabs(children);\n const tabCount = tabs.length;\n const tabsRef = useRef(tabs);\n\n tabsRef.current = tabs;\n\n const isColumn = flexDirection === \"column\" || flexDirection === \"column-reverse\";\n const isControlled = value !== undefined;\n\n const [internalTab, setInternalTab] = useState(() => {\n const initial = value ?? defaultValue;\n\n if (initial == null || tabCount === 0) {\n return 0;\n }\n\n const foundIndex = tabs.findIndex((tab) => tab.props.name === initial);\n\n return foundIndex === -1 ? 0 : foundIndex;\n });\n\n // In controlled mode, derive activeTab from value prop\n const activeTab = isControlled\n ? Math.max(\n 0,\n tabs.findIndex((tab) => tab.props.name === value),\n )\n : internalTab;\n\n // no-op in controlled mode; parent drives state via onChange.\n const noopSetTab = useCallback(() => {}, []);\n const setActiveTab = isControlled ? noopSetTab : setInternalTab;\n\n const onChangeRef = useRef(onChange);\n\n onChangeRef.current = onChange;\n\n // Notify initial tab on mount (uncontrolled only)\n useEffect(() => {\n if (isControlled) {\n return;\n }\n\n const tab = tabsRef.current[activeTab];\n\n if (tab) {\n onChangeRef.current(tab.props.name, tab);\n }\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, []);\n\n // Resolve key map with directional defaults\n const previousKeys = useMemo(() => keyMapProp?.previous ?? [isColumn ? \"upArrow\" : \"leftArrow\"], [keyMapProp?.previous, isColumn]);\n const nextKeys = useMemo(() => keyMapProp?.next ?? [isColumn ? \"downArrow\" : \"rightArrow\"], [keyMapProp?.next, isColumn]);\n const useNumbers = keyMapProp?.useNumbers ?? true;\n const useTab = keyMapProp?.useTab ?? true;\n\n // Use a ref for activeTab so callbacks always see the latest value\n const activeTabRef = useRef(activeTab);\n\n activeTabRef.current = activeTab;\n\n const handleTabChange = useCallback(\n (index: number) => {\n setActiveTab(index);\n\n const tab = tabsRef.current[index];\n\n if (tab) {\n onChangeRef.current(tab.props.name, tab);\n }\n },\n [setActiveTab],\n );\n\n const moveToNext = useCallback(() => {\n const next = (activeTabRef.current + 1) % tabCount;\n\n setActiveTab(next);\n\n const tab = tabsRef.current[next];\n\n if (tab) {\n onChangeRef.current(tab.props.name, tab);\n }\n }, [tabCount, setActiveTab]);\n\n const moveToPrevious = useCallback(() => {\n const previous = (activeTabRef.current - 1 + tabCount) % tabCount;\n\n setActiveTab(previous);\n\n const tab = tabsRef.current[previous];\n\n if (tab) {\n onChangeRef.current(tab.props.name, tab);\n }\n }, [tabCount, setActiveTab]);\n\n useInput(\n useCallback(\n (input: string, key) => {\n if (matchesAnyKey(previousKeys, key)) {\n moveToPrevious();\n\n return;\n }\n\n if (matchesAnyKey(nextKeys, key)) {\n moveToNext();\n\n return;\n }\n\n // Tab key navigation (only when focus is not managed by Ink)\n if (key.tab && useTab && isFocused == null) {\n if (key.shift) {\n moveToPrevious();\n } else {\n moveToNext();\n }\n\n return;\n }\n\n // Number key navigation (Meta/Cmd + 1-9)\n if (useNumbers && key.meta && NUMBER_KEY_PATTERN.test(input)) {\n const tabIndex = input === \"0\" ? 9 : Number.parseInt(input, 10) - 1;\n\n if (tabIndex < tabCount) {\n handleTabChange(tabIndex);\n }\n }\n },\n [previousKeys, nextKeys, useTab, useNumbers, isFocused, moveToPrevious, moveToNext, tabCount, handleTabChange],\n ),\n { isActive: isFocused !== false },\n );\n\n const separatorWidth = typeof width === \"number\" ? width : 6;\n\n const separator = useMemo(\n // eslint-disable-next-line @stylistic/no-extra-parens\n () => (isColumn ? \"─\".repeat(separatorWidth) : \" | \"),\n [isColumn, separatorWidth],\n );\n\n return (\n <Box flexDirection={flexDirection} width={width}>\n {tabs.map((child, index) => {\n const { name } = child.props;\n const textColors = getActiveTabColors(activeTab === index, isFocused !== false, colorsProp);\n\n let indexLabel: ReactElement | null = null;\n\n if (showIndex) {\n indexLabel = (\n <Text color=\"grey\">\n {/* eslint-disable-next-line @stylistic/jsx-one-expression-per-line */}\n {index + 1}.{\" \"}\n </Text>\n );\n }\n\n return (\n <Box flexDirection={flexDirection} key={name}>\n {index !== 0 && <Text color=\"dim\">{separator}</Text>}\n <Box>\n {indexLabel}\n <Text backgroundColor={textColors.backgroundColor} color={textColors.color}>\n {child}\n </Text>\n </Box>\n </Box>\n );\n })}\n </Box>\n );\n}\n\nexport { Tabs };\nexport type { Props as TabsProps };\nexport type { KeyMap as TabsKeyMap };\n",
21
+ "path": "registry/ui/tabs.tsx",
22
+ "target": "components/ui/tabs.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": "tag-input",
4
+ "type": "registry:ui",
5
+ "title": "Tag Input",
6
+ "description": "Tag Input 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, useRef, useState } from \"react\";\nimport type { LiteralUnion } from \"type-fest\";\n\nconst EMPTY_TAGS: ReadonlyArray<string> = [];\n\nexport type Props = {\n /**\n * Accent color for the chips and focused border.\n * @default \"blue\"\n */\n readonly accentColor?: LiteralUnion<AnsiColors, string>;\n\n /**\n * Auto-focus on mount.\n */\n readonly autoFocus?: boolean;\n\n /**\n * Reject duplicate tags (case-sensitive).\n * @default true\n */\n readonly dedupe?: boolean;\n\n /**\n * Tags present when uncontrolled.\n */\n readonly defaultValue?: ReadonlyArray<string>;\n\n /**\n * Disable input and dim the display.\n */\n readonly isDisabled?: boolean;\n\n /**\n * Maximum number of tags. Further input is ignored once reached.\n */\n readonly max?: number;\n\n /**\n * Fires whenever the set of tags changes.\n */\n readonly onChange?: (tags: ReadonlyArray<string>) => void;\n\n /**\n * Fires on Enter with an empty draft (i.e. \"done adding tags\").\n */\n readonly onSubmit?: (tags: ReadonlyArray<string>) => void;\n\n /**\n * Placeholder shown when there are no tags and no draft.\n * @default \"Add tags…\"\n */\n readonly placeholder?: string;\n\n /**\n * Besides Enter, which typed characters commit the current draft (default comma and space).\n */\n readonly separators?: ReadonlyArray<string>;\n\n /**\n * Controlled tags. When provided, `defaultValue` is ignored.\n */\n readonly value?: ReadonlyArray<string>;\n};\n\nconst DEFAULT_SEPARATORS: ReadonlyArray<string> = [\",\", \" \"];\n\n/**\n * A chip-style multi-value input. Type a tag and press Enter (or a separator)\n * to commit it; Backspace on an empty draft removes the last tag. Renders each\n * committed value as a colored chip followed by the live draft.\n */\nexport default function TagInput({\n accentColor = \"blue\",\n autoFocus = false,\n dedupe = true,\n defaultValue = EMPTY_TAGS,\n isDisabled = false,\n max,\n onChange,\n onSubmit,\n placeholder = \"Add tags…\",\n separators = DEFAULT_SEPARATORS,\n value: controlledValue,\n}: Props): ReactElement {\n const { isFocused } = useFocus({ autoFocus, isActive: !isDisabled });\n const isControlled = controlledValue !== undefined;\n\n const [internal, setInternal] = useState<ReadonlyArray<string>>(defaultValue);\n const [draft, setDraft] = useState(\"\");\n\n const tags = controlledValue ?? internal;\n\n const onChangeRef = useRef(onChange);\n\n onChangeRef.current = onChange;\n\n const setTags = useCallback(\n (next: ReadonlyArray<string>) => {\n if (!isControlled) {\n setInternal(next);\n }\n\n onChangeRef.current?.(next);\n },\n [isControlled],\n );\n\n const commitDraft = useCallback(\n (raw: string) => {\n const trimmed = raw.trim();\n\n if (trimmed.length === 0) {\n return;\n }\n\n if (max !== undefined && tags.length >= max) {\n return;\n }\n\n if (dedupe && tags.includes(trimmed)) {\n setDraft(\"\");\n\n return;\n }\n\n setTags([...tags, trimmed]);\n setDraft(\"\");\n },\n [dedupe, max, setTags, tags],\n );\n\n const inputHandler = useCallback(\n (input: string, key: { backspace: boolean; delete: boolean; return: boolean }) => {\n if (key.return) {\n if (draft.trim().length > 0) {\n commitDraft(draft);\n } else {\n onSubmit?.(tags);\n }\n\n return;\n }\n\n if ((key.backspace || key.delete) && draft.length === 0) {\n if (tags.length > 0) {\n setTags(tags.slice(0, -1));\n }\n\n return;\n }\n\n if (key.backspace || key.delete) {\n setDraft(draft.slice(0, -1));\n\n return;\n }\n\n if (separators.includes(input)) {\n commitDraft(draft);\n\n return;\n }\n\n // Ignore control bytes; accept printable input.\n if (input.length > 0 && input.codePointAt(0)! >= 0x20) {\n setDraft(`${draft}${input}`);\n }\n },\n [commitDraft, draft, onSubmit, separators, setTags, tags],\n );\n\n useInput(inputHandler, { isActive: isFocused && !isDisabled });\n\n const isEmpty = tags.length === 0 && draft.length === 0;\n\n return (\n <Box borderColor={isFocused ? accentColor : undefined} borderStyle=\"round\" flexWrap=\"wrap\" gap={1} paddingX={1}>\n {tags.map((tag, index) => (\n // eslint-disable-next-line react-x/no-array-index-key -- tag index is stable for the render\n <Text backgroundColor={accentColor} key={index}>\n {` ${tag} `}\n </Text>\n ))}\n {isEmpty ? <Text dimColor>{placeholder}</Text> : <Text>{draft}</Text>}\n {isFocused && !isEmpty ? <Text color={accentColor}>▏</Text> : undefined}\n </Box>\n );\n}\n\nexport { TagInput };\nexport type { Props as TagInputProps };\n",
19
+ "path": "registry/ui/tag-input.tsx",
20
+ "target": "components/ui/tag-input.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": "tag",
4
+ "type": "registry:ui",
5
+ "title": "Tag",
6
+ "description": "Tag 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 TagVariant = \"outline\" | \"solid\" | \"subtle\";\n\nexport type Props = {\n /**\n * Tag content.\n */\n readonly children: ReactNode;\n\n /**\n * Base color.\n * @default \"blue\"\n */\n readonly color?: LiteralUnion<AnsiColors, string>;\n\n /**\n * Optional symbol rendered before the label (e.g. `●`, `✔`, `★`).\n */\n readonly icon?: ReactNode;\n\n /**\n * Visual style.\n * @default \"subtle\"\n */\n readonly variant?: TagVariant;\n};\n\n/**\n * Render a short pill-shaped label.\n * @returns A `ReactElement` rendering the tag in the requested variant.\n */\nexport default function Tag({ children, color = \"blue\", icon, variant = \"subtle\" }: Props): ReactElement {\n if (variant === \"outline\") {\n return (\n <Box borderColor={color} borderStyle=\"round\" paddingX={1}>\n <Text color={color}>\n {icon === undefined\n ? undefined\n : (\n<>\n{icon}\n{\" \"}\n</>\n )}\n {children}\n </Text>\n </Box>\n );\n }\n\n if (variant === \"solid\") {\n return (\n <Text backgroundColor={color} color=\"black\">\n {\" \"}\n {icon === undefined\n ? undefined\n : (\n<>\n{icon}\n{\" \"}\n</>\n )}\n {children}\n{\" \"}\n </Text>\n );\n }\n\n return (\n <Text color={color}>\n {icon === undefined\n ? undefined\n : (\n<>\n{icon}\n{\" \"}\n</>\n )}\n {children}\n </Text>\n );\n}\n\nexport { Tag };\nexport type { Props as TagProps };\n",
19
+ "path": "registry/ui/tag.tsx",
20
+ "target": "components/ui/tag.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": "text-input",
4
+ "type": "registry:ui",
5
+ "title": "Text Input",
6
+ "description": "Text 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 */\n\n/**\n * Text input component for Ink.\n *\n * Inspired by ink-ui TextInput by Vadim Demedes and ink-text-input by Sindre Sorhus.\n * @see https://github.com/vadimdemedes/ink-ui\n *\n * MIT License\n * Copyright (c) Vadym Demedes (github.com/vadimdemedes)\n */\nimport Box from \"@visulima/tui/components/box\";\nimport Text from \"@visulima/tui/components/text\";\nimport useInput from \"@visulima/tui/hooks/use-input\";\nimport { isInsertableInput } from \"@visulima/tui/input\";\nimport type { ReactElement } from \"react\";\nimport { useCallback, useRef, useState } from \"react\";\n\nexport type Props = {\n // eslint-disable-next-line jsdoc/informative-docs -- @default tag triggers false positive\n /** @default \"\" */\n readonly defaultValue?: string;\n\n /**\n * When true, all input is ignored and text is dimmed.\n * @default false\n */\n readonly isDisabled?: boolean;\n\n /**\n * When true, renders asterisks instead of the actual characters.\n * @default false\n */\n readonly mask?: boolean;\n\n /**\n * Fires on every keystroke with the updated text.\n */\n readonly onChange?: (value: string) => void;\n\n /**\n * Fires when the user presses Enter.\n */\n readonly onSubmit?: (value: string) => void;\n\n /**\n * Greyed-out text shown when the input is empty.\n */\n readonly placeholder?: string;\n\n /**\n * Autocomplete candidates. The first case-sensitive prefix match is shown\n * dimmed after the current input. Press right-arrow or Enter to accept.\n */\n readonly suggestions?: ReadonlyArray<string>;\n};\n\n// eslint-disable-next-line sonarjs/slow-regex -- false positive: linear backtracking on short input lines\nconst KILL_WORD_PATTERN = /\\S+\\s*$/;\n\n/**\n * Find the first suggestion suffix that matches the current value.\n */\nconst findSuggestion = (value: string, suggestions: ReadonlyArray<string> | undefined): string | undefined => {\n if (value.length === 0 || !suggestions) {\n return undefined;\n }\n\n const match = suggestions.find((s) => s.startsWith(value));\n\n return match?.slice(value.length) ?? undefined;\n};\n\nconst NAV_NOT_HANDLED = -1;\n\n/**\n * Handle cursor navigation keys (arrows, Home, End, Ctrl+A/E).\n * Returns the new cursor position, or NAV_NOT_HANDLED if the key was not a navigation key.\n */\nconst handleNavigation = (\n input: string,\n key: { ctrl: boolean; end: boolean; home: boolean; leftArrow: boolean; rightArrow: boolean },\n cursor: number,\n valueLength: number,\n): number => {\n if (key.leftArrow) {\n return Math.max(0, cursor - 1);\n }\n\n if (key.home || (key.ctrl && input === \"a\")) {\n return 0;\n }\n\n if (key.end || (key.ctrl && input === \"e\")) {\n return valueLength;\n }\n\n // rightArrow without suggestion acceptance is handled by caller\n return NAV_NOT_HANDLED;\n};\n\n/**\n * Handle editing keys (Backspace, Delete, Ctrl+U/K/W).\n * Returns [newValue, newCursor] or undefined if the key was not an editing key.\n */\nconst handleEditing = (\n input: string,\n key: { backspace: boolean; ctrl: boolean; delete: boolean },\n currentValue: string,\n cursor: number,\n): [string, number] | undefined => {\n if (key.backspace && cursor > 0) {\n return [currentValue.slice(0, cursor - 1) + currentValue.slice(cursor), cursor - 1];\n }\n\n if (key.delete && cursor < currentValue.length) {\n return [currentValue.slice(0, cursor) + currentValue.slice(cursor + 1), cursor];\n }\n\n if (key.ctrl && input === \"u\") {\n return [currentValue.slice(cursor), 0];\n }\n\n if (key.ctrl && input === \"k\") {\n return [currentValue.slice(0, cursor), cursor];\n }\n\n if (key.ctrl && input === \"w\") {\n const before = currentValue.slice(0, cursor);\n const trimmed = before.replace(KILL_WORD_PATTERN, \"\");\n\n return [trimmed + currentValue.slice(cursor), trimmed.length];\n }\n\n return undefined;\n};\n\n/**\n * A single-line text input with cursor navigation, optional placeholder,\n * autocomplete suggestions, and password masking.\n *\n * Supports arrow keys, Home/End, Ctrl+A/E, Ctrl+U/K/W, Backspace, Delete.\n */\nexport default function TextInput({ defaultValue = \"\", isDisabled = false, mask = false, onChange, onSubmit, placeholder, suggestions }: Props): ReactElement {\n const [value, setValueRaw] = useState(defaultValue);\n const [cursor, setCursor] = useState(defaultValue.length);\n\n const valueRef = useRef(value);\n\n valueRef.current = value;\n const cursorRef = useRef(cursor);\n\n cursorRef.current = cursor;\n\n const onChangeRef = useRef(onChange);\n\n onChangeRef.current = onChange;\n const onSubmitRef = useRef(onSubmit);\n\n onSubmitRef.current = onSubmit;\n\n const suggestion = findSuggestion(value, suggestions);\n\n const inputHandler = useCallback(\n (\n input: string,\n key: {\n backspace: boolean;\n ctrl: boolean;\n delete: boolean;\n end: boolean;\n escape: boolean;\n home: boolean;\n leftArrow: boolean;\n meta: boolean;\n return: boolean;\n rightArrow: boolean;\n },\n ) => {\n const { current } = cursorRef;\n const currentValue = valueRef.current;\n\n if (key.return) {\n const currentSuggestion = findSuggestion(currentValue, suggestions);\n const finalValue = currentSuggestion ? currentValue + currentSuggestion : currentValue;\n\n if (currentSuggestion) {\n setValueRaw(finalValue);\n setCursor(finalValue.length);\n onChangeRef.current?.(finalValue);\n }\n\n onSubmitRef.current?.(finalValue);\n\n return;\n }\n\n if (key.escape) {\n return;\n }\n\n // Right arrow: accept suggestion if at end, otherwise move cursor\n if (key.rightArrow) {\n const currentSuggestion = findSuggestion(currentValue, suggestions);\n\n if (current === currentValue.length && currentSuggestion) {\n const completed = currentValue + currentSuggestion;\n\n setValueRaw(completed);\n setCursor(completed.length);\n onChangeRef.current?.(completed);\n\n return;\n }\n\n setCursor(Math.min(currentValue.length, current + 1));\n\n return;\n }\n\n // Navigation keys\n const navResult = handleNavigation(input, key, current, currentValue.length);\n\n if (navResult !== NAV_NOT_HANDLED) {\n setCursor(navResult);\n\n return;\n }\n\n // Editing keys\n const editResult = handleEditing(input, key, currentValue, current);\n\n if (editResult) {\n const [newValue, newCursor] = editResult;\n\n setValueRaw(newValue);\n setCursor(newCursor);\n onChangeRef.current?.(newValue);\n\n return;\n }\n\n // Printable character — insert at cursor (filters out ctrl/meta chords,\n // but allows AltGr symbols like @ and € on international keyboards)\n if (isInsertableInput(input, key)) {\n const next = currentValue.slice(0, current) + input + currentValue.slice(current);\n\n setValueRaw(next);\n setCursor(current + input.length);\n onChangeRef.current?.(next);\n }\n },\n [suggestions],\n );\n\n useInput(inputHandler, { isActive: !isDisabled });\n\n // Build the display string\n const displayValue = mask ? \"*\".repeat(value.length) : value;\n\n // Render with cursor\n const beforeCursor = displayValue.slice(0, cursor);\n const atCursor = displayValue[cursor];\n const afterCursor = displayValue.slice(cursor + 1);\n\n if (isDisabled) {\n return (\n <Box>\n {/* eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing -- intentional: empty string should fall through to placeholder */}\n <Text dimColor>{displayValue || placeholder || \"\"}</Text>\n </Box>\n );\n }\n\n if (value.length === 0 && placeholder) {\n return (\n <Box>\n <Text dimColor inverse>\n {placeholder[0]}\n </Text>\n <Text dimColor>{placeholder.slice(1)}</Text>\n </Box>\n );\n }\n\n return (\n <Box>\n <Text>\n {beforeCursor}\n <Text inverse>{atCursor ?? \" \"}</Text>\n {afterCursor}\n </Text>\n {suggestion ? <Text dimColor>{suggestion}</Text> : undefined}\n </Box>\n );\n}\n\nexport { TextInput };\nexport type { Props as TextInputProps };\n",
17
+ "path": "registry/ui/text-input.tsx",
18
+ "target": "components/ui/text-input.tsx",
19
+ "type": "registry:ui"
20
+ }
21
+ ]
22
+ }
@@ -0,0 +1,22 @@
1
+ {
2
+ "$schema": "https://ui.shadcn.com/schema/registry-item.json",
3
+ "name": "textarea",
4
+ "type": "registry:ui",
5
+ "title": "Textarea",
6
+ "description": "Textarea component for @visulima/tui.",
7
+ "categories": [
8
+ "ui"
9
+ ],
10
+ "dependencies": [
11
+ "@visulima/tui"
12
+ ],
13
+ "registryDependencies": [],
14
+ "files": [
15
+ {
16
+ "content": "/* eslint-disable no-useless-assignment, react-x/no-array-index-key, react-x/set-state-in-effect, react/function-component-definition, unicorn/prefer-single-call */\n\n/**\n * Multi-line text input component for Ink.\n *\n * Provides a full-featured textarea with cursor navigation, selection,\n * clipboard support, undo/redo, and viewport scrolling.\n */\nimport Box from \"@visulima/tui/components/box\";\nimport Text from \"@visulima/tui/components/text\";\nimport useClipboard from \"@visulima/tui/hooks/use-clipboard\";\nimport useInput from \"@visulima/tui/hooks/use-input\";\nimport usePaste from \"@visulima/tui/hooks/use-paste\";\nimport useTextBuffer from \"@visulima/tui/hooks/use-text-buffer\";\nimport { isInsertableInput } from \"@visulima/tui/input\";\nimport type { ReactElement } from \"react\";\nimport { useCallback, useEffect, useMemo, useRef, useState } from \"react\";\n\nexport type Props = {\n // eslint-disable-next-line jsdoc/informative-docs -- @default tag triggers false positive\n /** @default \"\" */\n readonly defaultValue?: string;\n\n /**\n * When true, all input is ignored and text is dimmed.\n * @default false\n */\n readonly isDisabled?: boolean;\n\n /**\n * When true, the component responds to keyboard input.\n * @default true\n */\n readonly isFocused?: boolean;\n\n /**\n * Maximum visible rows before viewport scrolling kicks in.\n * When set, the textarea will grow from `rows` to `maxRows` before scrolling.\n */\n readonly maxRows?: number;\n\n /**\n * Fires on every edit with the updated text.\n */\n readonly onChange?: (value: string) => void;\n\n /**\n * Fires when the user presses Meta+Enter or Ctrl+Enter.\n */\n readonly onSubmit?: (value: string) => void;\n\n /**\n * Greyed-out text shown when the textarea is empty.\n */\n readonly placeholder?: string;\n\n /**\n * Number of visible rows (viewport height).\n * @default 5\n */\n readonly rows?: number;\n\n /**\n * Show line numbers in the left gutter.\n * @default false\n */\n readonly showLineNumbers?: boolean;\n\n /**\n * Number of spaces inserted for a Tab key press.\n * @default 2\n */\n readonly tabSize?: number;\n};\n\ntype SelectionRange = {\n endCol: number;\n endLine: number;\n startCol: number;\n startLine: number;\n};\n\nconst getOrderedSelection = (cursor: { col: number; line: number }, anchor: { col: number; line: number }): SelectionRange => {\n if (anchor.line < cursor.line || (anchor.line === cursor.line && anchor.col < cursor.col)) {\n return { endCol: cursor.col, endLine: cursor.line, startCol: anchor.col, startLine: anchor.line };\n }\n\n return { endCol: anchor.col, endLine: anchor.line, startCol: cursor.col, startLine: cursor.line };\n};\n\n/**\n * A multi-line text input with cursor navigation, selection, clipboard, undo/redo,\n * paste support, and viewport scrolling.\n *\n * ```tsx\n * &lt;Textarea defaultValue=\"Hello\\nWorld\" rows={10} onChange={setValue} onSubmit={handleSubmit} />\n * ```\n */\nexport default function Textarea({\n defaultValue = \"\",\n isDisabled = false,\n isFocused = true,\n maxRows,\n onChange,\n onSubmit,\n placeholder,\n rows = 5,\n showLineNumbers = false,\n tabSize = 2,\n}: Props): ReactElement {\n const buffer = useTextBuffer(defaultValue);\n const bufferRef = useRef(buffer);\n\n bufferRef.current = buffer;\n\n const { copy } = useClipboard();\n const [scrollOffset, setScrollOffset] = useState(0);\n\n const onChangeRef = useRef(onChange);\n\n onChangeRef.current = onChange;\n const onSubmitRef = useRef(onSubmit);\n\n onSubmitRef.current = onSubmit;\n\n // Fire onChange on value changes\n const previousValueRef = useRef(defaultValue);\n\n useEffect(() => {\n if (buffer.value !== previousValueRef.current) {\n previousValueRef.current = buffer.value;\n onChangeRef.current?.(buffer.value);\n }\n }, [buffer.value]);\n\n // Calculate visible rows\n const visibleRows = useMemo(() => {\n if (maxRows) {\n return Math.min(Math.max(rows, buffer.lines.length), maxRows);\n }\n\n return rows;\n }, [rows, maxRows, buffer.lines.length]);\n\n // Keep cursor in viewport\n useEffect(() => {\n setScrollOffset((offset) => {\n if (buffer.cursor.line < offset) {\n return buffer.cursor.line;\n }\n\n if (buffer.cursor.line >= offset + visibleRows) {\n return buffer.cursor.line - visibleRows + 1;\n }\n\n return offset;\n });\n }, [buffer.cursor.line, visibleRows]);\n\n const inputHandler = useCallback(\n (\n input: string,\n key: {\n backspace: boolean;\n ctrl: boolean;\n delete: boolean;\n downArrow: boolean;\n end: boolean;\n escape: boolean;\n home: boolean;\n leftArrow: boolean;\n meta: boolean;\n return: boolean;\n rightArrow: boolean;\n shift: boolean;\n tab: boolean;\n upArrow: boolean;\n },\n ) => {\n const b = bufferRef.current;\n\n // Submit: Meta+Enter or Ctrl+Enter\n if (key.return && (key.meta || key.ctrl)) {\n onSubmitRef.current?.(b.value);\n\n return;\n }\n\n // Enter: insert newline\n if (key.return) {\n b.newline();\n\n return;\n }\n\n if (key.escape) {\n b.clearSelection();\n\n return;\n }\n\n // Tab: insert spaces\n if (key.tab) {\n b.insert(\" \".repeat(tabSize));\n\n return;\n }\n\n // Undo: Ctrl+Z\n if (key.ctrl && input === \"z\") {\n b.undo();\n\n return;\n }\n\n // Redo: Ctrl+Y or Ctrl+Shift+Z\n if (key.ctrl && (input === \"y\" || (key.shift && input === \"Z\"))) {\n b.redo();\n\n return;\n }\n\n // Copy: Ctrl+C (with selection)\n if (key.ctrl && input === \"c\" && b.hasSelection()) {\n copy(b.getSelectedText());\n\n return;\n }\n\n // Select all: Ctrl+Shift+A (Shift produces uppercase \"A\")\n if (key.ctrl && (input === \"A\" || (input === \"a\" && key.shift))) {\n b.selectAll();\n\n return;\n }\n\n // Navigation\n if (key.upArrow) {\n b.moveCursor(\"up\", key.shift);\n\n return;\n }\n\n if (key.downArrow) {\n b.moveCursor(\"down\", key.shift);\n\n return;\n }\n\n if (key.leftArrow) {\n b.moveCursor(\"left\", key.shift);\n\n return;\n }\n\n if (key.rightArrow) {\n b.moveCursor(\"right\", key.shift);\n\n return;\n }\n\n if (key.home) {\n if (key.ctrl) {\n b.moveToStart(key.shift);\n } else {\n b.moveToLineStart(key.shift);\n }\n\n return;\n }\n\n if (key.end) {\n if (key.ctrl) {\n b.moveToEnd(key.shift);\n } else {\n b.moveToLineEnd(key.shift);\n }\n\n return;\n }\n\n // Ctrl+A (no shift): home\n if (key.ctrl && input === \"a\") {\n b.moveToLineStart(key.shift);\n\n return;\n }\n\n // Ctrl+E: end\n if (key.ctrl && input === \"e\") {\n b.moveToLineEnd(key.shift);\n\n return;\n }\n\n // Editing\n if (key.backspace) {\n b.deleteBack();\n\n return;\n }\n\n if (key.delete) {\n b.deleteForward();\n\n return;\n }\n\n if (key.ctrl && input === \"u\") {\n b.deleteToLineStart();\n\n return;\n }\n\n if (key.ctrl && input === \"k\") {\n b.deleteToLineEnd();\n\n return;\n }\n\n if (key.ctrl && input === \"w\") {\n b.deleteWord();\n\n return;\n }\n\n // Printable character\n if (isInsertableInput(input, key)) {\n if (b.hasSelection()) {\n b.replaceSelection(input);\n } else {\n b.insert(input);\n }\n }\n },\n [copy, tabSize],\n );\n\n useInput(inputHandler, { isActive: isFocused && !isDisabled });\n\n // Paste handler\n const pasteHandler = useCallback((text: string) => {\n const b = bufferRef.current;\n\n if (b.hasSelection()) {\n b.replaceSelection(text);\n } else {\n b.insert(text);\n }\n }, []);\n\n usePaste(pasteHandler, { isActive: isFocused && !isDisabled });\n\n // Render\n const visibleLines = buffer.lines.slice(scrollOffset, scrollOffset + visibleRows);\n const lineNumberWidth = showLineNumbers ? String(buffer.lines.length).length + 1 : 0;\n\n // Selection range (ordered)\n const sel = buffer.anchor ? getOrderedSelection(buffer.cursor, buffer.anchor) : null;\n\n if (isDisabled) {\n return (\n <Box flexDirection=\"column\">\n {visibleLines.map((line, index) => {\n const lineNumber = scrollOffset + index;\n\n return (\n <Box key={lineNumber}>\n {showLineNumbers\n ? (\n<Text dimColor>\n{String(lineNumber + 1).padStart(lineNumberWidth)}\n{\" \"}\n</Text>\n )\n : undefined}\n {}\n <Text dimColor>{line || (lineNumber === 0 && placeholder ? placeholder : \"\")}</Text>\n </Box>\n );\n })}\n </Box>\n );\n }\n\n // Empty with placeholder\n if (buffer.value.length === 0 && placeholder) {\n const placeholderLines = placeholder.split(\"\\n\").slice(0, visibleRows);\n\n return (\n <Box flexDirection=\"column\">\n {placeholderLines.map((pLine, index) => (\n <Box key={index}>\n {showLineNumbers\n ? (\n<Text dimColor>\n{String(index + 1).padStart(lineNumberWidth)}\n{\" \"}\n</Text>\n )\n : undefined}\n {index === 0\n ? (\n <Text>\n <Text dimColor inverse>\n {pLine[0] ?? \" \"}\n </Text>\n <Text dimColor>{pLine.slice(1)}</Text>\n </Text>\n )\n : (\n <Text dimColor>{pLine}</Text>\n )}\n </Box>\n ))}\n </Box>\n );\n }\n\n return (\n <Box flexDirection=\"column\">\n {visibleLines.map((line, index) => {\n const lineNumber = scrollOffset + index;\n const isCursorLine = lineNumber === buffer.cursor.line;\n\n return (\n <Box key={lineNumber}>\n {showLineNumbers\n ? (\n<Text dimColor={!isCursorLine}>\n{String(lineNumber + 1).padStart(lineNumberWidth)}\n{\" \"}\n</Text>\n )\n : undefined}\n <Text>{renderLine(line, lineNumber, buffer.cursor, sel)}</Text>\n </Box>\n );\n })}\n </Box>\n );\n}\n\n/**\n * Render a single line with cursor and selection highlighting.\n */\nfunction renderLine(line: string, lineNumber: number, cursor: { col: number; line: number }, sel: SelectionRange | null): ReactElement {\n const isCursorLine = lineNumber === cursor.line;\n const cursorCol = isCursorLine ? cursor.col : -1;\n\n // Determine if this line has any selection\n const lineSelected = sel && lineNumber >= sel.startLine && lineNumber <= sel.endLine;\n\n if (!lineSelected && !isCursorLine) {\n // No cursor, no selection — plain text\n return <>{line}</>;\n }\n\n if (!lineSelected) {\n // Cursor on this line, no selection\n const before = line.slice(0, cursorCol);\n const at = line[cursorCol];\n const after = line.slice(cursorCol + 1);\n\n return (\n <>\n {before}\n <Text inverse>{at ?? \" \"}</Text>\n {after}\n </>\n );\n }\n\n // Selection on this line\n const selStart = lineNumber === sel.startLine ? sel.startCol : 0;\n const selEnd = lineNumber === sel.endLine ? sel.endCol : line.length;\n\n const beforeSel = line.slice(0, selStart);\n const selected = line.slice(selStart, selEnd);\n const afterSel = line.slice(selEnd);\n\n if (isCursorLine && (cursorCol < selStart || cursorCol >= selEnd)) {\n // Cursor is outside the selection on this line\n const parts: ReactElement[] = [];\n let key = 0;\n\n if (cursorCol < selStart) {\n parts.push(<Text key={key++}>{line.slice(0, cursorCol)}</Text>);\n parts.push(\n <Text inverse key={key++}>\n {line[cursorCol] ?? \" \"}\n </Text>,\n );\n parts.push(<Text key={key++}>{line.slice(cursorCol + 1, selStart)}</Text>);\n parts.push(\n <Text inverse key={key++}>\n {selected}\n </Text>,\n );\n parts.push(<Text key={key++}>{afterSel}</Text>);\n } else {\n parts.push(<Text key={key++}>{beforeSel}</Text>);\n parts.push(\n <Text inverse key={key++}>\n {selected}\n </Text>,\n );\n parts.push(<Text key={key++}>{line.slice(selEnd, cursorCol)}</Text>);\n parts.push(\n <Text inverse key={key++}>\n {line[cursorCol] ?? \" \"}\n </Text>,\n );\n parts.push(<Text key={key++}>{line.slice(cursorCol + 1)}</Text>);\n }\n\n return <>{parts}</>;\n }\n\n // Cursor is within or no cursor — just show selection\n return (\n <>\n {beforeSel}\n <Text inverse>{selected}</Text>\n {afterSel}\n </>\n );\n}\n\nexport { Textarea };\nexport type { Props as TextareaProps };\n",
17
+ "path": "registry/ui/textarea.tsx",
18
+ "target": "components/ui/textarea.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": "thinking-block",
4
+ "type": "registry:ui",
5
+ "title": "Thinking Block",
6
+ "description": "Thinking Block 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, useState } from \"react\";\nimport type { LiteralUnion } from \"type-fest\";\n\nexport type Props = {\n /**\n * Accent color for the header and border.\n * @default \"magenta\"\n */\n readonly accentColor?: LiteralUnion<AnsiColors, string>;\n\n /**\n * Auto-focus so Space/Enter can toggle the block.\n */\n readonly autoFocus?: boolean;\n\n /**\n * The reasoning text.\n */\n readonly children: string;\n\n /**\n * Start expanded when uncontrolled.\n * @default true\n */\n readonly defaultExpanded?: boolean;\n\n /**\n * Controlled expanded state. When set, `defaultExpanded` is ignored.\n */\n readonly expanded?: boolean;\n\n /**\n * Header label.\n * @default \"Thinking\"\n */\n readonly label?: string;\n\n /**\n * Fires when the expanded state toggles.\n */\n readonly onToggle?: (expanded: boolean) => void;\n\n /**\n * Show a pulsing indicator to signal in-progress reasoning.\n * @default false\n */\n readonly streaming?: boolean;\n};\n\n/**\n * A collapsible block for an assistant's reasoning. The dimmed body is hidden\n * when collapsed; Space or Enter toggles it while focused. Set `streaming` to\n * mark reasoning still in progress.\n */\nexport default function ThinkingBlock({\n accentColor = \"magenta\",\n autoFocus = false,\n children,\n defaultExpanded = true,\n expanded: controlledExpanded,\n label = \"Thinking\",\n onToggle,\n streaming = false,\n}: Props): ReactElement {\n const { isFocused } = useFocus({ autoFocus });\n const isControlled = controlledExpanded !== undefined;\n\n const [internalExpanded, setInternalExpanded] = useState(defaultExpanded);\n const expanded = controlledExpanded ?? internalExpanded;\n\n const toggle = useCallback(() => {\n const next = !expanded;\n\n if (!isControlled) {\n setInternalExpanded(next);\n }\n\n onToggle?.(next);\n }, [expanded, isControlled, onToggle]);\n\n const inputHandler = useCallback(\n (input: string, key: { return: boolean }) => {\n if (input === \" \" || key.return) {\n toggle();\n }\n },\n [toggle],\n );\n\n useInput(inputHandler, { isActive: isFocused });\n\n return (\n <Box borderColor={accentColor} borderStyle=\"round\" flexDirection=\"column\" paddingX={1}>\n <Box gap={1}>\n <Text color={accentColor}>{expanded ? \"▾\" : \"▸\"}</Text>\n <Text color={accentColor} italic>\n {label}\n </Text>\n {streaming ? <Text color={accentColor}>…</Text> : undefined}\n {isFocused ? <Text dimColor>{expanded ? \"(space to collapse)\" : \"(space to expand)\"}</Text> : undefined}\n </Box>\n {expanded\n ? (\n <Box marginTop={1}>\n <Text dimColor italic>\n {children}\n </Text>\n </Box>\n )\n : undefined}\n </Box>\n );\n}\n\nexport { ThinkingBlock };\nexport type { Props as ThinkingBlockProps };\n",
19
+ "path": "registry/ui/thinking-block.tsx",
20
+ "target": "components/ui/thinking-block.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": "time-picker",
4
+ "type": "registry:ui",
5
+ "title": "Time Picker",
6
+ "description": "Time Picker 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, useRef, useState } from \"react\";\nimport type { LiteralUnion } from \"type-fest\";\n\nexport type TimeValue = {\n readonly hours: number;\n readonly minutes: number;\n readonly seconds?: number;\n};\n\nexport type Props = {\n /**\n * Accent color for the focused segment.\n * @default \"blue\"\n */\n readonly accentColor?: LiteralUnion<AnsiColors, string>;\n\n /**\n * Auto-focus on mount.\n */\n readonly autoFocus?: boolean;\n\n /**\n * Initial value when uncontrolled.\n * @default { hours: 0, minutes: 0 }\n */\n readonly defaultValue?: TimeValue;\n\n /**\n * Use a 12-hour clock with an AM/PM segment instead of 24-hour.\n * @default false\n */\n readonly hour12?: boolean;\n\n /**\n * Disable input and dim the display.\n */\n readonly isDisabled?: boolean;\n\n /**\n * Fires whenever any segment changes.\n */\n readonly onChange?: (value: TimeValue) => void;\n\n /**\n * Fires on Enter.\n */\n readonly onSubmit?: (value: TimeValue) => void;\n\n /**\n * Show a seconds segment.\n * @default false\n */\n readonly showSeconds?: boolean;\n\n /**\n * Controlled value. When provided, `defaultValue` is ignored.\n */\n readonly value?: TimeValue;\n};\n\ntype Segment = \"ampm\" | \"hours\" | \"minutes\" | \"seconds\";\n\nconst DIGIT_PATTERN = /^\\d$/;\nconst DEFAULT_TIME: TimeValue = { hours: 0, minutes: 0 };\n\nfunction wrap(value: number, size: number): number {\n return size === 0 ? 0 : ((value % size) + size) % size;\n}\n\nconst pad = (value: number): string => String(value).padStart(2, \"0\");\n\n/**\n * A keyboard-driven time input with independent hour/minute (and optional\n * second) segments. ←/→ move between segments, ↑/↓ adjust the focused segment\n * (wrapping), digits type directly, and Enter submits.\n */\nexport default function TimePicker({\n accentColor = \"blue\",\n autoFocus = false,\n defaultValue = DEFAULT_TIME,\n hour12 = false,\n isDisabled = false,\n onChange,\n onSubmit,\n showSeconds = false,\n value: controlledValue,\n}: Props): ReactElement {\n const { isFocused } = useFocus({ autoFocus, isActive: !isDisabled });\n const isControlled = controlledValue !== undefined;\n\n const [internal, setInternal] = useState<TimeValue>(defaultValue);\n const current = controlledValue ?? internal;\n\n const segments: Segment[] = [\"hours\", \"minutes\", ...showSeconds ? ([\"seconds\"] as Segment[]) : [], ...hour12 ? ([\"ampm\"] as Segment[]) : []];\n const [segmentIndex, setSegmentIndex] = useState(0);\n const focusedSegment = segments[Math.min(segmentIndex, segments.length - 1)]!;\n\n const onChangeRef = useRef(onChange);\n\n onChangeRef.current = onChange;\n\n // Tracks in-progress two-digit entry for the focused segment; reset whenever\n // focus moves or a non-digit key is pressed.\n const typedRef = useRef<{ segment: Segment; value: number } | null>(null);\n\n const update = useCallback(\n (next: TimeValue) => {\n if (!isControlled) {\n setInternal(next);\n }\n\n onChangeRef.current?.(next);\n },\n [isControlled],\n );\n\n const adjust = useCallback(\n (segment: Segment, delta: number) => {\n switch (segment) {\n case \"hours\": {\n update({ ...current, hours: wrap(current.hours + delta, 24) });\n\n break;\n }\n case \"minutes\": {\n update({ ...current, minutes: wrap(current.minutes + delta, 60) });\n\n break;\n }\n case \"seconds\": {\n update({ ...current, seconds: wrap((current.seconds ?? 0) + delta, 60) });\n\n break;\n }\n default: {\n // AM/PM: flip by ±12 hours.\n update({ ...current, hours: wrap(current.hours + 12, 24) });\n }\n }\n },\n [current, update],\n );\n\n const applyDigit = useCallback(\n (segment: Segment, digit: number) => {\n const previous = typedRef.current;\n const combine = previous !== null && previous.segment === segment;\n\n if (segment === \"minutes\" || segment === \"seconds\") {\n let next = combine ? previous.value * 10 + digit : digit;\n\n if (next > 59) {\n next = digit;\n }\n\n typedRef.current = { segment, value: next };\n update(segment === \"minutes\" ? { ...current, minutes: next } : { ...current, seconds: next });\n\n return;\n }\n\n // Hours. In 12-hour mode digits address the 1..12 range and the\n // current AM/PM is preserved; in 24-hour mode they address 0..23.\n const maxHour = hour12 ? 12 : 23;\n let next = combine ? previous.value * 10 + digit : digit;\n\n if (next > maxHour) {\n next = digit;\n }\n\n typedRef.current = { segment, value: next };\n\n if (hour12) {\n const isPm = current.hours >= 12;\n\n update({ ...current, hours: (next % 12) + (isPm ? 12 : 0) });\n } else {\n update({ ...current, hours: next });\n }\n },\n [current, hour12, update],\n );\n\n const inputHandler = useCallback(\n (input: string, key: { downArrow: boolean; leftArrow: boolean; return: boolean; rightArrow: boolean; upArrow: boolean }) => {\n if (key.leftArrow) {\n typedRef.current = null;\n setSegmentIndex((index) => wrap(index - 1, segments.length));\n\n return;\n }\n\n if (key.rightArrow) {\n typedRef.current = null;\n setSegmentIndex((index) => wrap(index + 1, segments.length));\n\n return;\n }\n\n if (key.upArrow) {\n typedRef.current = null;\n adjust(focusedSegment, 1);\n\n return;\n }\n\n if (key.downArrow) {\n typedRef.current = null;\n adjust(focusedSegment, -1);\n\n return;\n }\n\n if (key.return) {\n onSubmit?.(current);\n\n return;\n }\n\n if (DIGIT_PATTERN.test(input)) {\n if (focusedSegment !== \"ampm\") {\n applyDigit(focusedSegment, Number.parseInt(input, 10));\n }\n\n return;\n }\n\n if ((input === \"a\" || input === \"p\") && hour12) {\n typedRef.current = null;\n\n const isPm = current.hours >= 12;\n const wantPm = input === \"p\";\n\n if (isPm !== wantPm) {\n update({ ...current, hours: wrap(current.hours + 12, 24) });\n }\n }\n },\n [adjust, applyDigit, current, focusedSegment, hour12, onSubmit, segments.length, update],\n );\n\n useInput(inputHandler, { isActive: isFocused && !isDisabled });\n\n const hours12 = current.hours % 12 === 0 ? 12 : current.hours % 12;\n const displayHours = hour12 ? hours12 : current.hours;\n const meridiem = current.hours >= 12 ? \"PM\" : \"AM\";\n\n function segmentColor(segment: Segment): LiteralUnion<AnsiColors, string> | undefined {\n return isFocused && focusedSegment === segment ? accentColor : undefined;\n }\n\n function segmentInverse(segment: Segment): boolean {\n return isFocused && focusedSegment === segment;\n }\n\n return (\n <Box borderColor={isFocused ? accentColor : undefined} borderStyle=\"round\" paddingX={1}>\n <Text color={segmentColor(\"hours\")} dimColor={isDisabled} inverse={segmentInverse(\"hours\")}>\n {pad(displayHours)}\n </Text>\n <Text dimColor>:</Text>\n <Text color={segmentColor(\"minutes\")} dimColor={isDisabled} inverse={segmentInverse(\"minutes\")}>\n {pad(current.minutes)}\n </Text>\n {showSeconds\n ? (\n <>\n <Text dimColor>:</Text>\n <Text color={segmentColor(\"seconds\")} dimColor={isDisabled} inverse={segmentInverse(\"seconds\")}>\n {pad(current.seconds ?? 0)}\n </Text>\n </>\n )\n : undefined}\n {hour12\n ? (\n <Box marginLeft={1}>\n <Text color={segmentColor(\"ampm\")} dimColor={isDisabled} inverse={segmentInverse(\"ampm\")}>\n {meridiem}\n </Text>\n </Box>\n )\n : undefined}\n </Box>\n );\n}\n\nexport { TimePicker };\nexport type { Props as TimePickerProps };\n",
19
+ "path": "registry/ui/time-picker.tsx",
20
+ "target": "components/ui/time-picker.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": "timer",
4
+ "type": "registry:ui",
5
+ "title": "Timer",
6
+ "description": "Timer component for @visulima/tui.",
7
+ "categories": [
8
+ "ui"
9
+ ],
10
+ "dependencies": [
11
+ "@visulima/tui"
12
+ ],
13
+ "registryDependencies": [],
14
+ "files": [
15
+ {
16
+ "content": "import Text from \"@visulima/tui/components/text\";\nimport type { UseTimerResult } from \"@visulima/tui/hooks/use-timer\";\nimport useTimer from \"@visulima/tui/hooks/use-timer\";\nimport { formatDuration } from \"@visulima/tui/utils\";\nimport type { ReactElement, Ref } from \"react\";\nimport { useImperativeHandle } from \"react\";\n\nexport type TimerRef = Omit<UseTimerResult, \"isFinished\" | \"isRunning\" | \"remaining\">;\n\nexport type Props = {\n /**\n * Whether to start the timer immediately.\n * @default false\n */\n readonly autoStart?: boolean;\n\n /**\n * Make the rendered text bold.\n */\n readonly bold?: boolean;\n\n /**\n * Text color for the rendered time.\n */\n readonly color?: string;\n\n /**\n * Total countdown duration in milliseconds.\n */\n readonly duration: number;\n\n /**\n * Custom formatter for the remaining time.\n * Receives remaining milliseconds, returns a display string.\n * @default formatDuration (MM:SS or HH:MM:SS)\n */\n readonly format?: (remaining: number) => string;\n\n /**\n * Tick interval in milliseconds.\n * @default 1000\n */\n readonly interval?: number;\n\n /**\n * Called once when the timer reaches zero.\n */\n readonly onTimeout?: () => void;\n\n readonly ref?: Ref<TimerRef>;\n};\n\n/**\n * A countdown timer component.\n *\n * Exposes `start`, `stop`, `toggle`, and `reset` methods via ref.\n *\n * ```tsx\n * const ref = useRef&lt;TimerRef>(null);\n * &lt;Timer ref={ref} duration={60_000} autoStart />\n * ```\n */\nconst Timer = ({ autoStart, bold, color, duration, format = formatDuration, interval, onTimeout, ref }: Props): ReactElement => {\n const timer = useTimer({ autoStart, duration, interval, onTimeout });\n\n useImperativeHandle(ref, () => {\n return {\n reset: timer.reset,\n start: timer.start,\n stop: timer.stop,\n toggle: timer.toggle,\n };\n }, [timer.reset, timer.start, timer.stop, timer.toggle]);\n\n return (\n <Text bold={bold} color={color}>\n {format(timer.remaining)}\n </Text>\n );\n};\n\nexport default Timer;\n\nexport { Timer };\nexport type { Props as TimerProps };\n",
17
+ "path": "registry/ui/timer.tsx",
18
+ "target": "components/ui/timer.tsx",
19
+ "type": "registry:ui"
20
+ }
21
+ ]
22
+ }