@elabs-ai/components-ui 4.0.0 → 4.2.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 (339) hide show
  1. package/README.md +10 -8
  2. package/dist/chunk-FCH4ZN6G.js +57 -0
  3. package/dist/chunk-FCH4ZN6G.js.map +1 -0
  4. package/dist/form.d.ts +28 -0
  5. package/dist/form.js +123 -0
  6. package/dist/form.js.map +1 -0
  7. package/dist/index.d.ts +2597 -188
  8. package/dist/index.js +12855 -3578
  9. package/dist/index.js.map +1 -1
  10. package/dist/lib/cn.js +10 -1
  11. package/dist/lib/cn.js.map +1 -1
  12. package/package.json +21 -7
  13. package/src/__contract__/attribution-panel.contract.test.tsx +49 -0
  14. package/src/__contract__/badge.contract.test.tsx +49 -0
  15. package/src/__contract__/bounded-number.contract.test.tsx +49 -0
  16. package/src/__contract__/button.contract.test.tsx +49 -0
  17. package/src/__contract__/color-picker.contract.test.tsx +49 -0
  18. package/src/__contract__/combobox.contract.test.tsx +49 -0
  19. package/src/__contract__/command-trigger.contract.test.tsx +49 -0
  20. package/src/__contract__/copyable-value.contract.test.tsx +49 -0
  21. package/src/__contract__/date-picker.contract.test.tsx +49 -0
  22. package/src/__contract__/date-range-picker.contract.test.tsx +49 -0
  23. package/src/__contract__/empty-state.contract.test.tsx +49 -0
  24. package/src/__contract__/error-state.contract.test.tsx +49 -0
  25. package/src/__contract__/icon-button.contract.test.tsx +49 -0
  26. package/src/__contract__/input.contract.test.tsx +49 -0
  27. package/src/__contract__/key-value-editor.contract.test.tsx +49 -0
  28. package/src/__contract__/list-editor.contract.test.tsx +49 -0
  29. package/src/__contract__/loading-state.contract.test.tsx +49 -0
  30. package/src/__contract__/metric-card.contract.test.tsx +49 -0
  31. package/src/__contract__/model-picker.contract.test.tsx +49 -0
  32. package/src/__contract__/nav-notifications.contract.test.tsx +49 -0
  33. package/src/__contract__/number-input.contract.test.tsx +49 -0
  34. package/src/__contract__/rating.contract.test.tsx +49 -0
  35. package/src/__contract__/schema-form.contract.test.tsx +49 -0
  36. package/src/__contract__/section-header.contract.test.tsx +49 -0
  37. package/src/__contract__/segmented-field.contract.test.tsx +49 -0
  38. package/src/__contract__/slider-number.contract.test.tsx +49 -0
  39. package/src/__contract__/status-badge.contract.test.tsx +49 -0
  40. package/src/__contract__/tag-input.contract.test.tsx +49 -0
  41. package/src/__contract__/text.contract.test.tsx +49 -0
  42. package/src/__contract__/textarea.contract.test.tsx +49 -0
  43. package/src/__contract__/timeline.contract.test.tsx +49 -0
  44. package/src/__contract__/toggle.contract.test.tsx +49 -0
  45. package/src/__contract__/transfer.contract.test.tsx +49 -0
  46. package/src/__contract__/tree-select.contract.test.tsx +49 -0
  47. package/src/__contract__/tree.contract.test.tsx +49 -0
  48. package/src/__contract__/virtual-select.contract.test.tsx +49 -0
  49. package/src/__contract__/workspace-picker.contract.test.tsx +49 -0
  50. package/src/_peer-deps.test.ts +59 -0
  51. package/src/blocks-comparison-table.stories.tsx +2 -2
  52. package/src/components/accordion/accordion.tsx +13 -4
  53. package/src/components/advanced-group/advanced-group.tsx +1 -1
  54. package/src/components/alert/alert.tsx +15 -4
  55. package/src/components/alert-dialog/alert-dialog.test.tsx +32 -0
  56. package/src/components/alert-dialog/alert-dialog.tsx +28 -6
  57. package/src/components/app-shell/app-shell.stories.tsx +44 -2
  58. package/src/components/app-shell/app-shell.test.tsx +99 -0
  59. package/src/components/app-shell/app-shell.tsx +56 -4
  60. package/src/components/app-sidebar/app-sidebar.stories.tsx +9 -1
  61. package/src/components/attribution-panel/attribution-panel.tsx +3 -1
  62. package/src/components/attribution-panel/attributions.generated.ts +83 -79
  63. package/src/components/avatar/avatar.tsx +4 -1
  64. package/src/components/badge/badge.stories.tsx +8 -0
  65. package/src/components/badge/badge.test.tsx +8 -0
  66. package/src/components/badge/badge.tsx +15 -5
  67. package/src/components/bento-grid/bento-grid.test.tsx +24 -3
  68. package/src/components/bento-grid/bento-grid.tsx +11 -1
  69. package/src/components/bounded-number/bounded-number.test.tsx +1 -1
  70. package/src/components/bounded-number/bounded-number.tsx +78 -75
  71. package/src/components/breadcrumb/breadcrumb.stories.tsx +38 -0
  72. package/src/components/breadcrumb/breadcrumb.test.tsx +24 -1
  73. package/src/components/breadcrumb/breadcrumb.tsx +10 -4
  74. package/src/components/button/button.stories.tsx +116 -8
  75. package/src/components/button/button.tsx +18 -12
  76. package/src/components/button-group/button-group.tsx +1 -1
  77. package/src/components/calendar/calendar.tsx +3 -2
  78. package/src/components/card/card.stories.tsx +11 -6
  79. package/src/components/card/card.test.tsx +24 -2
  80. package/src/components/card/card.tsx +48 -7
  81. package/src/components/carousel/carousel.test.tsx +169 -0
  82. package/src/components/carousel/carousel.tsx +93 -32
  83. package/src/components/change-review/change-review.stories.tsx +145 -1
  84. package/src/components/change-review/change-review.test.tsx +143 -0
  85. package/src/components/change-review/change-review.tsx +214 -24
  86. package/src/components/checkbox/checkbox.tsx +2 -1
  87. package/src/components/collapsible-panel/use-collapsible-panel.ts +0 -3
  88. package/src/components/color-picker/color-picker.tsx +22 -15
  89. package/src/components/combobox/combobox.stories.tsx +10 -0
  90. package/src/components/combobox/combobox.test.tsx +54 -0
  91. package/src/components/combobox/combobox.tsx +25 -11
  92. package/src/components/command/command.stories.tsx +16 -6
  93. package/src/components/command/command.test.tsx +88 -0
  94. package/src/components/command/command.tsx +38 -13
  95. package/src/components/command/index.ts +1 -0
  96. package/src/components/command-trigger/command-trigger.stories.tsx +39 -0
  97. package/src/components/command-trigger/command-trigger.test.tsx +18 -0
  98. package/src/components/command-trigger/command-trigger.tsx +70 -0
  99. package/src/components/command-trigger/index.ts +1 -0
  100. package/src/components/context-menu/context-menu.tsx +16 -7
  101. package/src/components/context-rail/context-rail.stories.tsx +285 -0
  102. package/src/components/context-rail/context-rail.test.tsx +263 -0
  103. package/src/components/context-rail/context-rail.tsx +678 -0
  104. package/src/components/context-rail/index.ts +1 -0
  105. package/src/components/copyable-value/copyable-value.stories.tsx +10 -0
  106. package/src/components/copyable-value/copyable-value.test.tsx +28 -1
  107. package/src/components/copyable-value/copyable-value.tsx +13 -2
  108. package/src/components/date-picker/date-picker.stories.tsx +9 -0
  109. package/src/components/date-picker/date-picker.test.tsx +52 -0
  110. package/src/components/date-picker/date-picker.tsx +13 -15
  111. package/src/components/date-range-picker/date-range-picker.stories.tsx +9 -0
  112. package/src/components/date-range-picker/date-range-picker.test.tsx +76 -1
  113. package/src/components/date-range-picker/date-range-picker.tsx +39 -28
  114. package/src/components/descriptions/descriptions.tsx +1 -1
  115. package/src/components/dialog/dialog.test.tsx +8 -3
  116. package/src/components/dialog/dialog.tsx +14 -6
  117. package/src/components/drawer/drawer.tsx +23 -6
  118. package/src/components/dropdown-menu/dropdown-menu.stories.tsx +1 -1
  119. package/src/components/dropdown-menu/dropdown-menu.tsx +4 -4
  120. package/src/components/empty-state/empty-state.test.tsx +26 -0
  121. package/src/components/empty-state/empty-state.tsx +6 -1
  122. package/src/components/expand-dialog/expand-dialog.tsx +1 -2
  123. package/src/components/field/field-context.ts +59 -0
  124. package/src/components/field/field.stories.tsx +142 -0
  125. package/src/components/field/field.test.tsx +415 -0
  126. package/src/components/field/field.tsx +342 -0
  127. package/src/components/field/index.ts +9 -0
  128. package/src/components/field-row/field-row.stories.tsx +85 -1
  129. package/src/components/field-row/field-row.test.tsx +145 -0
  130. package/src/components/field-row/field-row.tsx +50 -6
  131. package/src/components/file-upload/file-upload.stories.tsx +16 -0
  132. package/src/components/file-upload/file-upload.test.tsx +90 -12
  133. package/src/components/file-upload/file-upload.tsx +164 -31
  134. package/src/components/form/form.tsx +8 -3
  135. package/src/components/hover-card/hover-card.tsx +1 -0
  136. package/src/components/icon-button/icon-button.test.tsx +1 -1
  137. package/src/components/icon-button/icon-button.tsx +3 -3
  138. package/src/components/input/input.stories.tsx +121 -0
  139. package/src/components/input/input.test.tsx +13 -0
  140. package/src/components/input/input.tsx +8 -4
  141. package/src/components/input-group/input-group.stories.tsx +74 -0
  142. package/src/components/input-group/input-group.tsx +18 -9
  143. package/src/components/input-otp/input-otp.tsx +8 -2
  144. package/src/components/keyboard-shortcuts/index.ts +6 -0
  145. package/src/components/keyboard-shortcuts/keyboard-shortcuts.stories.tsx +177 -0
  146. package/src/components/keyboard-shortcuts/keyboard-shortcuts.test.tsx +73 -0
  147. package/src/components/keyboard-shortcuts/keyboard-shortcuts.tsx +203 -0
  148. package/src/components/label/label.tsx +1 -1
  149. package/src/components/link-preview/link-preview.tsx +1 -1
  150. package/src/components/list-editor/list-editor.test.tsx +24 -0
  151. package/src/components/list-editor/list-editor.tsx +126 -52
  152. package/src/components/locale-provider/index.ts +6 -1
  153. package/src/components/locale-provider/locale-keys.test.ts +134 -0
  154. package/src/components/locale-provider/locale-provider.stories.tsx +89 -1
  155. package/src/components/locale-provider/locale-provider.test.tsx +257 -1
  156. package/src/components/locale-provider/locale-provider.tsx +63 -8
  157. package/src/components/locale-provider/messages.ts +822 -2
  158. package/src/components/match-highlight/match-highlight.stories.tsx +1 -1
  159. package/src/components/mention-input/mention-input-mirror.stories.tsx +9 -1
  160. package/src/components/mention-input/mention-input.stories.tsx +3 -3
  161. package/src/components/mention-input/mention-input.test.tsx +51 -1
  162. package/src/components/mention-input/mention-input.tsx +66 -46
  163. package/src/components/mention-input/mention-value.ts +8 -2
  164. package/src/components/menubar/menubar.tsx +16 -6
  165. package/src/components/metric-card/metric-card.stories.tsx +5 -3
  166. package/src/components/metric-card/metric-card.tsx +21 -2
  167. package/src/components/model-picker/model-picker.stories.tsx +63 -3
  168. package/src/components/model-picker/model-picker.test.tsx +63 -0
  169. package/src/components/model-picker/model-picker.tsx +62 -36
  170. package/src/components/nav-main/nav-main.tsx +8 -4
  171. package/src/components/nav-notifications/nav-notifications.stories.tsx +110 -0
  172. package/src/components/nav-notifications/nav-notifications.test.tsx +11 -0
  173. package/src/components/nav-notifications/nav-notifications.tsx +67 -9
  174. package/src/components/nav-user/nav-user.tsx +5 -5
  175. package/src/components/navigation-menu/navigation-menu.stories.tsx +22 -0
  176. package/src/components/navigation-menu/navigation-menu.test.tsx +174 -0
  177. package/src/components/navigation-menu/navigation-menu.tsx +204 -15
  178. package/src/components/number-input/number-input.stories.tsx +7 -3
  179. package/src/components/number-input/number-input.test.tsx +47 -0
  180. package/src/components/number-input/number-input.tsx +65 -24
  181. package/src/components/page-shell/page-shell.stories.tsx +163 -0
  182. package/src/components/page-shell/page-shell.test.tsx +229 -0
  183. package/src/components/page-shell/page-shell.tsx +108 -25
  184. package/src/components/pagination/pagination.test.tsx +109 -0
  185. package/src/components/pagination/pagination.tsx +27 -19
  186. package/src/components/popover/popover.tsx +1 -0
  187. package/src/components/progress/progress.tsx +2 -0
  188. package/src/components/radio-group/radio-group.tsx +1 -1
  189. package/src/components/rating/rating.tsx +3 -1
  190. package/src/components/resizable/resizable.stories.tsx +114 -2
  191. package/src/components/resizable/resizable.test.tsx +76 -0
  192. package/src/components/resizable/resizable.tsx +32 -6
  193. package/src/components/revision-timeline/revision-timeline.stories.tsx +8 -1
  194. package/src/components/revision-timeline/revision-timeline.test.tsx +26 -0
  195. package/src/components/revision-timeline/revision-timeline.tsx +53 -5
  196. package/src/components/schema-form/from-json-schema.test.ts +390 -0
  197. package/src/components/schema-form/from-json-schema.ts +375 -0
  198. package/src/components/schema-form/index.ts +81 -0
  199. package/src/components/schema-form/schema-form-spec.ts +698 -0
  200. package/src/components/schema-form/schema-form-store.ts +506 -0
  201. package/src/components/schema-form/schema-form.stories.tsx +566 -0
  202. package/src/components/schema-form/schema-form.test.tsx +1021 -0
  203. package/src/components/schema-form/schema-form.tsx +1381 -0
  204. package/src/components/section-header/section-header.stories.tsx +37 -0
  205. package/src/components/section-header/section-header.tsx +14 -3
  206. package/src/components/select/select.tsx +13 -7
  207. package/src/components/separator/separator.tsx +1 -0
  208. package/src/components/sheet/sheet.test.tsx +39 -0
  209. package/src/components/sheet/sheet.tsx +40 -9
  210. package/src/components/side-dock/index.ts +1 -0
  211. package/src/components/side-dock/side-dock.stories.tsx +137 -0
  212. package/src/components/side-dock/side-dock.test.tsx +299 -0
  213. package/src/components/side-dock/side-dock.tsx +515 -0
  214. package/src/components/sidebar/index.ts +1 -0
  215. package/src/components/sidebar/sidebar-frame.test.tsx +162 -0
  216. package/src/components/sidebar/sidebar.stories.tsx +421 -1
  217. package/src/components/sidebar/sidebar.test.tsx +71 -2
  218. package/src/components/sidebar/sidebar.tsx +264 -38
  219. package/src/components/skeleton/skeleton.tsx +1 -0
  220. package/src/components/skip-link/index.ts +1 -0
  221. package/src/components/skip-link/skip-link.stories.tsx +106 -0
  222. package/src/components/skip-link/skip-link.test.tsx +20 -0
  223. package/src/components/skip-link/skip-link.tsx +34 -0
  224. package/src/components/slider/slider.tsx +1 -1
  225. package/src/components/slider-number/slider-number.stories.tsx +4 -1
  226. package/src/components/slider-number/slider-number.test.tsx +1 -1
  227. package/src/components/sonner/sonner.tsx +2 -0
  228. package/src/components/spinner/spinner.test.tsx +18 -0
  229. package/src/components/spinner/spinner.tsx +14 -4
  230. package/src/components/split-panel/index.ts +6 -1
  231. package/src/components/split-panel/split-panel.stories.tsx +18 -2
  232. package/src/components/split-panel/split-panel.test.tsx +20 -0
  233. package/src/components/split-panel/split-panel.tsx +28 -8
  234. package/src/components/state-panel/state-panel.stories.tsx +52 -0
  235. package/src/components/state-panel/state-panel.test.tsx +125 -0
  236. package/src/components/state-panel/state-panel.tsx +110 -24
  237. package/src/components/status-badge/status-badge.stories.tsx +8 -0
  238. package/src/components/switch/switch.tsx +3 -1
  239. package/src/components/table/table.stories.tsx +64 -0
  240. package/src/components/table/table.test.tsx +149 -1
  241. package/src/components/table/table.tsx +71 -2
  242. package/src/components/tabs/index.ts +10 -1
  243. package/src/components/tabs/tabs.stories.tsx +48 -0
  244. package/src/components/tabs/tabs.test.tsx +67 -0
  245. package/src/components/tabs/tabs.tsx +176 -72
  246. package/src/components/tag-input/tag-input.tsx +6 -3
  247. package/src/components/team-switcher/team-switcher.tsx +35 -6
  248. package/src/components/textarea/textarea.tsx +2 -2
  249. package/src/components/theme-switcher/theme-switcher.stories.tsx +76 -0
  250. package/src/components/theme-switcher/theme-switcher.test.tsx +182 -0
  251. package/src/components/theme-switcher/theme-switcher.tsx +140 -12
  252. package/src/components/theme-switcher/use-theme-transition.ts +2 -2
  253. package/src/components/timeline/timeline.stories.tsx +27 -0
  254. package/src/components/timeline/timeline.tsx +10 -3
  255. package/src/components/toggle/toggle.tsx +7 -2
  256. package/src/components/toggle-group/toggle-group.tsx +2 -0
  257. package/src/components/toolbar/toolbar.stories.tsx +10 -2
  258. package/src/components/tooltip/tooltip.tsx +2 -1
  259. package/src/components/top-nav/top-nav.tsx +1 -1
  260. package/src/components/transfer/transfer.tsx +35 -30
  261. package/src/components/tree/tree.stories.tsx +2 -2
  262. package/src/components/tree/tree.test.tsx +41 -0
  263. package/src/components/tree/tree.tsx +91 -31
  264. package/src/components/tree/use-tree-keyboard.ts +37 -14
  265. package/src/components/tree-select/tree-select.stories.tsx +9 -0
  266. package/src/components/tree-select/tree-select.test.tsx +5 -0
  267. package/src/components/tree-select/tree-select.tsx +11 -14
  268. package/src/components/typography/prose.test.tsx +3 -2
  269. package/src/components/typography/prose.tsx +3 -3
  270. package/src/components/typography/typography.stories.tsx +228 -0
  271. package/src/components/view-toolbar/view-toolbar.stories.tsx +41 -1
  272. package/src/components/view-toolbar/view-toolbar.test.tsx +33 -0
  273. package/src/components/view-toolbar/view-toolbar.tsx +37 -4
  274. package/src/components/virtual-select/virtual-select.stories.tsx +9 -0
  275. package/src/components/virtual-select/virtual-select.test.tsx +29 -3
  276. package/src/components/virtual-select/virtual-select.tsx +56 -18
  277. package/src/components/wizard/wizard.test.tsx +9 -0
  278. package/src/components/wizard/wizard.tsx +13 -5
  279. package/src/components/workspace-picker/index.ts +3 -0
  280. package/src/components/workspace-picker/workspace-picker-state.ts +50 -0
  281. package/src/components/workspace-picker/workspace-picker.stories.tsx +126 -0
  282. package/src/components/workspace-picker/workspace-picker.test.tsx +207 -0
  283. package/src/components/workspace-picker/workspace-picker.tsx +172 -0
  284. package/src/illustrations/empty-list-illustration.tsx +26 -0
  285. package/src/illustrations/error-illustration.tsx +60 -0
  286. package/src/illustrations/first-run-illustration.tsx +27 -0
  287. package/src/illustrations/illustration-base.tsx +108 -0
  288. package/src/illustrations/illustrations.stories.tsx +94 -0
  289. package/src/illustrations/illustrations.test.tsx +98 -0
  290. package/src/illustrations/index.ts +16 -0
  291. package/src/illustrations/no-access-illustration.tsx +45 -0
  292. package/src/illustrations/no-results-illustration.tsx +46 -0
  293. package/src/illustrations/offline-illustration.tsx +37 -0
  294. package/src/illustrations/success-illustration.tsx +40 -0
  295. package/src/index.ts +80 -2
  296. package/src/lib/agent-event-model.test.ts +12 -0
  297. package/src/lib/agent-event-model.ts +42 -0
  298. package/src/lib/approval-option.test.ts +17 -0
  299. package/src/lib/approval-option.ts +53 -0
  300. package/src/lib/check-result.ts +33 -0
  301. package/src/lib/cn.ts +9 -0
  302. package/src/lib/csv.ts +48 -0
  303. package/src/lib/diff-line.test.ts +36 -0
  304. package/src/lib/diff-line.ts +63 -0
  305. package/src/lib/diff-rows.test.ts +76 -0
  306. package/src/lib/diff-rows.ts +94 -0
  307. package/src/lib/format-duration.test.ts +22 -0
  308. package/src/lib/format-duration.ts +30 -0
  309. package/src/lib/has-renderable-content.test.ts +49 -0
  310. package/src/lib/has-renderable-content.ts +32 -0
  311. package/src/lib/merge-refs.ts +2 -0
  312. package/src/lib/operating-mode.test.ts +36 -0
  313. package/src/lib/operating-mode.ts +81 -0
  314. package/src/lib/optional-peer.ts +59 -0
  315. package/src/lib/session-launch.ts +37 -0
  316. package/src/lib/slash-command.test.ts +44 -0
  317. package/src/lib/slash-command.ts +45 -0
  318. package/src/lib/trigger-query.test.ts +94 -0
  319. package/src/lib/trigger-query.ts +88 -0
  320. package/src/lib/use-controllable-state.ts +47 -0
  321. package/src/lib/use-mobile.test.tsx +47 -0
  322. package/src/lib/use-mobile.ts +34 -12
  323. package/src/motion.stories.tsx +6 -0
  324. package/src/templates-object-detail-hub.stories.tsx +2 -2
  325. package/src/templates-screen-states.stories.tsx +5 -5
  326. package/src/templates-settings.stories.tsx +1 -1
  327. package/src/blocks/sidebar-02/app-sidebar.tsx +0 -160
  328. package/src/blocks/sidebar-02/logo.tsx +0 -18
  329. package/src/blocks/sidebar-02/nav-main.tsx +0 -13
  330. package/src/blocks/sidebar-02/nav-notifications.tsx +0 -7
  331. package/src/blocks/sidebar-02/sidebar-02.stories.tsx +0 -23
  332. package/src/blocks/sidebar-02/team-switcher.tsx +0 -7
  333. package/src/blocks/sidebar-04/app-sidebar.tsx +0 -236
  334. package/src/blocks/sidebar-04/mail-context.tsx +0 -29
  335. package/src/blocks/sidebar-04/nav-user.tsx +0 -6
  336. package/src/blocks/sidebar-04/sidebar-04.stories.tsx +0 -150
  337. package/src/blocks/sidebar-05/app-sidebar.tsx +0 -375
  338. package/src/blocks/sidebar-05/sidebar-05.stories.tsx +0 -18
  339. package/src/blocks/sidebar-05/team-switcher.tsx +0 -6
@@ -0,0 +1,76 @@
1
+ import { act, renderHook } from "@testing-library/react";
2
+ import { describe, expect, it } from "vitest";
3
+ import type { DiffLine } from "./diff-line";
4
+ import { collapseDiffRows, useDiffRows } from "./diff-rows";
5
+
6
+ function contextLine(index: number): DiffLine {
7
+ return { type: "context", oldNumber: index, newNumber: index, text: `line ${index}` };
8
+ }
9
+
10
+ describe("collapseDiffRows", () => {
11
+ it("renders every line when contextLines is undefined", () => {
12
+ const lines = Array.from({ length: 5 }, (_, i) => contextLine(i));
13
+ const rows = collapseDiffRows(lines, undefined);
14
+ expect(rows).toHaveLength(5);
15
+ expect(rows.every((row) => row.kind === "line")).toBe(true);
16
+ });
17
+
18
+ it("does not collapse a run at or below the threshold", () => {
19
+ const lines = Array.from({ length: 4 }, (_, i) => contextLine(i));
20
+ const rows = collapseDiffRows(lines, 4);
21
+ expect(rows).toHaveLength(4);
22
+ expect(rows.every((row) => row.kind === "line")).toBe(true);
23
+ });
24
+
25
+ it("collapses a run longer than contextLines, keeping top and bottom context", () => {
26
+ const lines = Array.from({ length: 10 }, (_, i) => contextLine(i));
27
+ const rows = collapseDiffRows(lines, 4);
28
+ // top=2, bottom=2, collapsed hiddenCount = 10 - 2 - 2 = 6. `runStart` is the
29
+ // run's OWN start index (0 — the whole 10-line run is one context block),
30
+ // not the index of the first hidden line — it is the key `expand()` takes.
31
+ expect(rows.map((row) => row.kind)).toEqual(["line", "line", "collapsed", "line", "line"]);
32
+ const collapsed = rows.find((row) => row.kind === "collapsed");
33
+ expect(collapsed).toMatchObject({ kind: "collapsed", runStart: 0, hiddenCount: 6 });
34
+ });
35
+
36
+ it("renders a run in full when its start index is in `expanded`", () => {
37
+ const lines = Array.from({ length: 10 }, (_, i) => contextLine(i));
38
+ const rows = collapseDiffRows(lines, 4, new Set([0]));
39
+ expect(rows).toHaveLength(10);
40
+ expect(rows.every((row) => row.kind === "line")).toBe(true);
41
+ });
42
+
43
+ it("does not collapse non-context lines flanking a run", () => {
44
+ const lines: DiffLine[] = [
45
+ { type: "add", newNumber: 1, text: "added" },
46
+ ...Array.from({ length: 8 }, (_, i) => contextLine(i)),
47
+ { type: "del", oldNumber: 99, text: "removed" },
48
+ ];
49
+ const rows = collapseDiffRows(lines, 4);
50
+ expect(rows[0]).toMatchObject({ kind: "line", index: 0 });
51
+ expect(rows.at(-1)).toMatchObject({ kind: "line", index: 9 });
52
+ expect(rows.some((row) => row.kind === "collapsed")).toBe(true);
53
+ });
54
+ });
55
+
56
+ describe("useDiffRows", () => {
57
+ it("starts with no run expanded", () => {
58
+ const lines = Array.from({ length: 10 }, (_, i) => contextLine(i));
59
+ const { result } = renderHook(() => useDiffRows(lines, 4));
60
+ expect(result.current.rows.some((row) => row.kind === "collapsed")).toBe(true);
61
+ });
62
+
63
+ it("expand() reveals the collapsed run's lines", () => {
64
+ const lines = Array.from({ length: 10 }, (_, i) => contextLine(i));
65
+ const { result } = renderHook(() => useDiffRows(lines, 4));
66
+
67
+ const collapsed = result.current.rows.find((row) => row.kind === "collapsed");
68
+ expect(collapsed).toBeDefined();
69
+ const runStart = (collapsed as { runStart: number }).runStart;
70
+
71
+ act(() => result.current.expand(runStart));
72
+
73
+ expect(result.current.rows.every((row) => row.kind === "line")).toBe(true);
74
+ expect(result.current.rows).toHaveLength(10);
75
+ });
76
+ });
@@ -0,0 +1,94 @@
1
+ "use client";
2
+
3
+ /**
4
+ * The context-run-collapsing hunk-windowing algorithm behind `DiffView`
5
+ * (`@elabs-ai/components-ai`, #102, where it shipped as the private
6
+ * `useVisibleRows`) and the terminal CLI look-alike family's own diff hunk
7
+ * (issue #117).
8
+ *
9
+ * Promoted here (not duplicated) because `@elabs-ai/components-ai` and
10
+ * `@elabs-ai/components-terminal` are layer-2 DAG SIBLINGS and may not import
11
+ * each other — `ui` is upstream of both
12
+ * (docs/decisions/2026-09-01-brainless-adoption-architecture.md § 4). The
13
+ * windowing algorithm is shiki-free and DOM-free; `ui` already hosts hooks
14
+ * (`use-mobile`, `use-copy-to-clipboard`), so a hook alongside a pure
15
+ * function is an ordinary shape here, not a new category.
16
+ */
17
+ import { useCallback, useMemo, useState } from "react";
18
+ import type { DiffLine } from "./diff-line";
19
+
20
+ /**
21
+ * One rendered row: either a real diff line, or a collapsed run of
22
+ * consecutive `context` lines longer than the caller's `contextLines`.
23
+ */
24
+ export type DiffRow =
25
+ | { kind: "line"; line: DiffLine; index: number }
26
+ | { kind: "collapsed"; runStart: number; hiddenCount: number };
27
+
28
+ /**
29
+ * Collapse long runs of consecutive `context` lines behind a single
30
+ * `"collapsed"` row, keeping `Math.ceil(contextLines / 2)` lines of context
31
+ * at the top of the run and `Math.floor(contextLines / 2)` at the bottom —
32
+ * so a reader always sees context on both sides of a hidden gap. A run
33
+ * already in `expanded` (by its start index) renders in full instead.
34
+ *
35
+ * Pure: no state, no effect. `useDiffRows` below is the stateful wrapper
36
+ * that owns the `expanded` set for a component.
37
+ */
38
+ export function collapseDiffRows(
39
+ lines: DiffLine[],
40
+ contextLines: number | undefined,
41
+ expanded: ReadonlySet<number> = new Set(),
42
+ ): DiffRow[] {
43
+ if (!contextLines || contextLines <= 0) {
44
+ return lines.map((line, index) => ({ kind: "line", line, index }));
45
+ }
46
+ const out: DiffRow[] = [];
47
+ let i = 0;
48
+ while (i < lines.length) {
49
+ if (lines[i]?.type !== "context") {
50
+ out.push({ kind: "line", line: lines[i]!, index: i });
51
+ i++;
52
+ continue;
53
+ }
54
+ const runStart = i;
55
+ let j = i;
56
+ while (j < lines.length && lines[j]?.type === "context") j++;
57
+ const runLength = j - runStart;
58
+ if (runLength <= contextLines || expanded.has(runStart)) {
59
+ for (let k = runStart; k < j; k++) out.push({ kind: "line", line: lines[k]!, index: k });
60
+ } else {
61
+ const top = Math.ceil(contextLines / 2);
62
+ const bottom = Math.floor(contextLines / 2);
63
+ for (let k = runStart; k < runStart + top; k++)
64
+ out.push({ kind: "line", line: lines[k]!, index: k });
65
+ out.push({ kind: "collapsed", runStart, hiddenCount: runLength - top - bottom });
66
+ for (let k = j - bottom; k < j; k++) out.push({ kind: "line", line: lines[k]!, index: k });
67
+ }
68
+ i = j;
69
+ }
70
+ return out;
71
+ }
72
+
73
+ /**
74
+ * Stateful wrapper around `collapseDiffRows`: owns the set of run-start
75
+ * indices a caller has expanded via the "show more" control, and returns the
76
+ * current row list alongside an `expand` action.
77
+ */
78
+ export function useDiffRows(
79
+ lines: DiffLine[],
80
+ contextLines: number | undefined,
81
+ ): { rows: DiffRow[]; expand: (runStart: number) => void } {
82
+ const [expanded, setExpanded] = useState<ReadonlySet<number>>(new Set());
83
+
84
+ const rows = useMemo(
85
+ () => collapseDiffRows(lines, contextLines, expanded),
86
+ [lines, contextLines, expanded],
87
+ );
88
+
89
+ const expand = useCallback((runStart: number) => {
90
+ setExpanded((prev) => new Set(prev).add(runStart));
91
+ }, []);
92
+
93
+ return { rows, expand };
94
+ }
@@ -0,0 +1,22 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { formatElapsed } from "./format-duration";
3
+
4
+ describe("formatElapsed", () => {
5
+ it.each([
6
+ // [elapsedMs, expected]
7
+ [0, "0ms"],
8
+ [420, "420ms"],
9
+ [999, "999ms"],
10
+ [1000, "1.0s"],
11
+ [8000, "8.0s"],
12
+ [9949, "9.9s"],
13
+ [10000, "10s"],
14
+ [42000, "42s"],
15
+ [59000, "59s"],
16
+ [60000, "1m00s"],
17
+ [77000, "1m17s"],
18
+ [605000, "10m05s"],
19
+ ] as const)("formats %dms as %s", (elapsedMs, expected) => {
20
+ expect(formatElapsed(elapsedMs)).toBe(expected);
21
+ });
22
+ });
@@ -0,0 +1,30 @@
1
+ const MS_PER_SECOND = 1000;
2
+ const SECONDS_PER_MINUTE = 60;
3
+ const TEN_SECONDS = 10;
4
+
5
+ /**
6
+ * Human-readable elapsed time — one formatter for every "how long did this take"
7
+ * surface in the system: `TurnStatus`'s running turn (#105), `ChangeReview`'s check
8
+ * rows (#112), `AgentEvent`'s hook durations (#109).
9
+ *
10
+ * Exported so a consumer's own elapsed-time surfaces agree with ours.
11
+ * Adapted from `brainless`'s turn-status elapsed formatter (MIT, see `ATTRIBUTION.md`).
12
+ *
13
+ * Rungs: sub-second → "420ms" · under ten seconds → "8.0s" · under a minute → "45s" ·
14
+ * beyond → "2m05s".
15
+ */
16
+ export function formatElapsed(elapsedMs: number): string {
17
+ if (elapsedMs < MS_PER_SECOND) {
18
+ return `${Math.round(elapsedMs)}ms`;
19
+ }
20
+
21
+ const totalSeconds = elapsedMs / MS_PER_SECOND;
22
+
23
+ if (totalSeconds < SECONDS_PER_MINUTE) {
24
+ return `${totalSeconds.toFixed(totalSeconds < TEN_SECONDS ? 1 : 0)}s`;
25
+ }
26
+
27
+ const minutes = Math.floor(totalSeconds / SECONDS_PER_MINUTE);
28
+ const seconds = Math.round(totalSeconds % SECONDS_PER_MINUTE);
29
+ return `${minutes}m${String(seconds).padStart(2, "0")}s`;
30
+ }
@@ -0,0 +1,49 @@
1
+ import { createElement } from "react";
2
+ import { describe, expect, it } from "vitest";
3
+ import { hasRenderableContent } from "./has-renderable-content";
4
+
5
+ describe("hasRenderableContent", () => {
6
+ it("is false for every scalar falsy value", () => {
7
+ expect(hasRenderableContent(undefined)).toBe(false);
8
+ expect(hasRenderableContent(null)).toBe(false);
9
+ expect(hasRenderableContent(false)).toBe(false);
10
+ expect(hasRenderableContent("")).toBe(false);
11
+ expect(hasRenderableContent(0)).toBe(false);
12
+ });
13
+
14
+ it("is true for a non-empty string or a truthy number", () => {
15
+ expect(hasRenderableContent("Required.")).toBe(true);
16
+ expect(hasRenderableContent(1)).toBe(true);
17
+ });
18
+
19
+ it("is false for an empty array — arrays are always truthy, which is exactly the bug this helper exists to avoid", () => {
20
+ expect(hasRenderableContent([])).toBe(false);
21
+ });
22
+
23
+ it("is false for an array of only falsy children", () => {
24
+ expect(hasRenderableContent([false, null, undefined])).toBe(false);
25
+ });
26
+
27
+ it("is false for an array of only empty strings — Children.toArray alone keeps '' and would still report content", () => {
28
+ expect(hasRenderableContent(["", ""])).toBe(false);
29
+ });
30
+
31
+ it("is true for an array containing at least one renderable child", () => {
32
+ expect(hasRenderableContent([false, "Required."])).toBe(true);
33
+ });
34
+
35
+ it("is true for a single React element", () => {
36
+ expect(hasRenderableContent(createElement("span", null, "hi"))).toBe(true);
37
+ });
38
+
39
+ // [KNOWN LIMIT] — an element naming a component that will render `null` is
40
+ // still an inert, truthy descriptor at this point; whether it renders
41
+ // anything is not decidable before React actually renders it. Documented
42
+ // limit, not a bug — see the JSDoc above `hasRenderableContent`.
43
+ it("[KNOWN LIMIT] is true for an element whose component will render null — not knowable before render", () => {
44
+ function RendersNull() {
45
+ return null;
46
+ }
47
+ expect(hasRenderableContent(createElement(RendersNull))).toBe(true);
48
+ });
49
+ });
@@ -0,0 +1,32 @@
1
+ import { Children, type ReactNode } from "react";
2
+
3
+ /**
4
+ * Whether `node` has any renderable content. `{condition && "message"}` is
5
+ * the ordinary React idiom for optional content, and `condition === false`
6
+ * must render nothing — a `false`/`0`/`""`/`null`/`undefined` value is "no
7
+ * content", not an empty paragraph/alert. `Boolean(node)` alone is not
8
+ * enough: an array is always truthy, so `{errors.map(...)}` on an empty list
9
+ * (or `[a && "x", b && "y"]` with both false) would still count as content.
10
+ * `Children.toArray` drops `null`/`undefined`/booleans from an array but
11
+ * KEEPS `""` (and `0`), so an array like `["", ""]` would still slip through
12
+ * as "content" without the trailing `.filter(Boolean)` — filtering after
13
+ * `toArray` is what makes this match the scalar check for every array shape,
14
+ * not just the ones `toArray` already prunes.
15
+ *
16
+ * Shared by `FieldDescription`/`FieldError` (`../components/field/field.tsx`)
17
+ * and `FieldRow` (`../components/field-row/field-row.tsx`) — extracted so a
18
+ * fix to the predicate reaches every caller instead of drifting between
19
+ * hand-duplicated copies, which is exactly how `FieldRow` ended up with the
20
+ * weaker, bare-truthiness version of this check for two rounds of `Field*`
21
+ * fixes (#93).
22
+ *
23
+ * **Known limit:** a `node` that is itself a component returning `null` (or
24
+ * an empty fragment) is not knowable from the element before render — a
25
+ * React element is always truthy, whatever the component it names will
26
+ * return, so this function reports `true` for it. That limit is documented
27
+ * on `FieldDescription`/`FieldError`/`FieldRow`'s own JSDoc; it is not, and
28
+ * cannot be, fixed here.
29
+ */
30
+ export function hasRenderableContent(node: ReactNode): boolean {
31
+ return Boolean(node) && Children.toArray(node).filter(Boolean).length > 0;
32
+ }
@@ -1,3 +1,5 @@
1
+ "use client";
2
+
1
3
  import type { ForwardedRef } from "react";
2
4
 
3
5
  /**
@@ -0,0 +1,36 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { effortRungForIndex } from "./operating-mode";
3
+
4
+ describe("effortRungForIndex", () => {
5
+ it("returns the smallest rung for a single-level scale", () => {
6
+ expect(effortRungForIndex(0, 1)).toBe("size-2.5");
7
+ });
8
+
9
+ it("returns the smallest rung for the first of several levels", () => {
10
+ expect(effortRungForIndex(0, 4)).toBe("size-2.5");
11
+ });
12
+
13
+ it("returns the largest rung for the last of several levels", () => {
14
+ expect(effortRungForIndex(3, 4)).toBe("size-6");
15
+ });
16
+
17
+ it("grows monotonically across the scale", () => {
18
+ const RUNGS = [
19
+ "size-2.5",
20
+ "size-3",
21
+ "size-3.5",
22
+ "size-4",
23
+ "size-4.5",
24
+ "size-5",
25
+ "size-5.5",
26
+ "size-6",
27
+ ];
28
+ const count = 5;
29
+ const indices = Array.from({ length: count }, (_, i) =>
30
+ RUNGS.indexOf(effortRungForIndex(i, count)),
31
+ );
32
+ for (let i = 1; i < indices.length; i++) {
33
+ expect(indices[i]).toBeGreaterThanOrEqual(indices[i - 1]!);
34
+ }
35
+ });
36
+ });
@@ -0,0 +1,81 @@
1
+ /**
2
+ * The operating-mode and reasoning-effort vocabulary behind
3
+ * `PromptInputMode`/`PromptInputEffort` (`@elabs-ai/components-ai`, #104/#107)
4
+ * and the terminal CLI look-alike family's own composer chips (issue #117).
5
+ *
6
+ * Promoted here (not duplicated) because `@elabs-ai/components-ai` and
7
+ * `@elabs-ai/components-terminal` are layer-2 DAG SIBLINGS and may not import
8
+ * each other — `ui` is upstream of both
9
+ * (docs/decisions/2026-09-01-brainless-adoption-architecture.md § 4). Both
10
+ * types are caller-supplied vocabulary objects — never a vendor union — which
11
+ * #117 requires: no vendor mode/effort union may appear in a public type.
12
+ */
13
+ import type { ReactNode } from "react";
14
+
15
+ /**
16
+ * One app-defined operating mode — how autonomously the agent may act while
17
+ * this mode is active. `brand-ui` ships no vocabulary: `id`/`label` are
18
+ * entirely the consumer's (e.g. `"auto"`/`"Auto"`, `"plan"`/`"Plan first"`,
19
+ * or a completely different product's own terms).
20
+ */
21
+ export interface OperatingMode {
22
+ /** Stable identifier — round-trips through `value`/`onValueChange`. */
23
+ id: string;
24
+ /** Visible + accessible name. */
25
+ label: string;
26
+ /** One line of guidance shown under the label in the menu. */
27
+ description?: string;
28
+ /** A shortcut hint rendered as a `Kbd` chip next to the label (e.g. `"⇧ Tab"`). */
29
+ keyHint?: string;
30
+ /** Leading glyph. Decorative — the mode is identified by `label`, not the icon. */
31
+ icon?: ReactNode;
32
+ }
33
+
34
+ /**
35
+ * One rung of an app-defined reasoning-effort scale. `brand-ui` ships no
36
+ * vocabulary: `id`/`label` are entirely the consumer's (e.g.
37
+ * `"low"`/`"Low"` … `"max"`/`"Max"`, or a completely different product's own
38
+ * terms and however many rungs it wants).
39
+ */
40
+ export interface EffortLevel {
41
+ /** Stable identifier — round-trips through `value`/`onValueChange`. */
42
+ id: string;
43
+ /** Visible + accessible name for this rung. */
44
+ label: string;
45
+ }
46
+
47
+ /**
48
+ * Growing-square size rungs (Tailwind's own spacing scale — `size-2.5` through
49
+ * `size-6`), indexed by ordinal position so a caller never invents a raw
50
+ * pixel value. All in ONE `tailwind-merge` class group (`size`), so
51
+ * overriding a base size class is a guaranteed, order-independent swap
52
+ * rather than a `w-*`/`h-*` vs `size-*` conflict tailwind-merge can't see.
53
+ */
54
+ const EFFORT_SIZE_RUNGS = [
55
+ "size-2.5",
56
+ "size-3",
57
+ "size-3.5",
58
+ "size-4",
59
+ "size-4.5",
60
+ "size-5",
61
+ "size-5.5",
62
+ "size-6",
63
+ ] as const;
64
+
65
+ /**
66
+ * The size class for rung `index` of `count` evenly-spaced rungs — the
67
+ * growing-square ramp `PromptInputEffort` (and the terminal composer's own
68
+ * effort chips) render, low to high.
69
+ */
70
+ export function effortRungForIndex(
71
+ index: number,
72
+ count: number,
73
+ ): (typeof EFFORT_SIZE_RUNGS)[number] {
74
+ if (count <= 1) return EFFORT_SIZE_RUNGS[0];
75
+ const position = index / (count - 1);
76
+ const rungIndex = Math.min(
77
+ EFFORT_SIZE_RUNGS.length - 1,
78
+ Math.max(0, Math.round(position * (EFFORT_SIZE_RUNGS.length - 1))),
79
+ );
80
+ return EFFORT_SIZE_RUNGS[rungIndex] ?? EFFORT_SIZE_RUNGS[0];
81
+ }
@@ -0,0 +1,59 @@
1
+ /**
2
+ * Detect a dynamic `import()` of an OPTIONAL peer that is not installed
3
+ * (issue #33 — mermaid / xterm / Rive / media-chrome are optional peers of
4
+ * `@elabs-ai/components-ai`, reached only through its lazy boundaries:
5
+ * `_lazy-mermaid.ts`, `_interactive-terminal-xterm.ts`, `_persona-rive.tsx` and
6
+ * `_audio-player-media-chrome.tsx`).
7
+ *
8
+ * Bundlers and runtimes report this differently (`ERR_MODULE_NOT_FOUND`,
9
+ * `Cannot find module`, `Failed to resolve module specifier`, Vite/browser's
10
+ * `Failed to fetch dynamically imported module`), so match on the SHAPES
11
+ * rather than one runtime's wording. A false positive here is cheap — the
12
+ * developer is told to install a package instead of seeing a raw
13
+ * module-resolution stack trace — and a false negative is the failure mode
14
+ * this exists to avoid.
15
+ *
16
+ * **Canonical home (promoted here per
17
+ * `docs/decisions/2026-09-01-brainless-adoption-architecture.md` § 2).** `ui` is
18
+ * upstream of every layer-2 package (`ai`, `terminal`, …) in the one-way
19
+ * package graph (`tokens` → `ui`/`icons` →
20
+ * `data`/`ai`/`flow`/`maps`/`charts`/`marketing`/`editor`/`viewer`), so this is
21
+ * an ordinary downward import for all of them — unlike the sibling-to-sibling
22
+ * case (e.g. `ai` ↔ `viewer`), which the graph forbids and which is why a
23
+ * safety-critical predicate like this one must never be copy-pasted sideways
24
+ * again. `packages/ai/src/_optional-peer.ts` still exists as a private,
25
+ * underived-prefixed module for one merge window (issue #116 deletes it and
26
+ * rewires its three importers onto this one); nothing imports this copy yet.
27
+ *
28
+ * It does NOT mirror `@elabs-ai/components-viewer`'s PRIMARY mechanism: the
29
+ * viewer's adapters throw a typed `ViewerError` carrying `code`/`packages`
30
+ * fields, and only fall back to string-matching for an error that didn't
31
+ * originate as one. This module has no typed-error primary path — every lazy
32
+ * boundary that uses it classifies purely by matching the caught error's
33
+ * message shape. That difference is why a shape mismatch (a resolved module
34
+ * with the wrong exports, rather than a rejected import) can slip past this
35
+ * detector silently instead of being caught by construction — see
36
+ * `_lazy-mermaid.ts`'s `loadEngine()` guard, added for exactly that gap
37
+ * (issue #33 review).
38
+ */
39
+ const MODULE_NOT_FOUND_PATTERN =
40
+ /cannot find module|failed to resolve|dynamically imported module|module not found/i;
41
+
42
+ /** True when a raw error MESSAGE names a module the runtime could not load. */
43
+ export function isModuleNotFoundMessage(message: string): boolean {
44
+ return MODULE_NOT_FOUND_PATTERN.test(message);
45
+ }
46
+
47
+ /**
48
+ * True when `value` is (or looks like) a failed dynamic `import()` of a
49
+ * missing optional peer. Accepts anything a `.catch()` or an error boundary
50
+ * might see: an `Error` instance (checked by `code` first, then message), or
51
+ * `undefined`/other for anything that clearly is not one.
52
+ */
53
+ export function isOptionalPeerMissing(value: unknown): boolean {
54
+ if (!(value instanceof Error)) return false;
55
+ if ("code" in value && (value as { code?: unknown }).code === "ERR_MODULE_NOT_FOUND") {
56
+ return true;
57
+ }
58
+ return isModuleNotFoundMessage(value.message);
59
+ }
@@ -0,0 +1,37 @@
1
+ /**
2
+ * The session-launch vocabulary behind `SessionHeader`
3
+ * (`@elabs-ai/components-ai`, #110) and the terminal CLI look-alike family's
4
+ * own launch banner (issue #117, T7 — the "Empty" and "First-run" states).
5
+ *
6
+ * Promoted here (not duplicated) because `@elabs-ai/components-ai` and
7
+ * `@elabs-ai/components-terminal` are layer-2 DAG SIBLINGS and may not import
8
+ * each other — `ui` is upstream of both
9
+ * (docs/decisions/2026-09-01-brainless-adoption-architecture.md § 4). Pure
10
+ * item types, no behaviour.
11
+ */
12
+ import type { ReactNode } from "react";
13
+
14
+ export interface SessionCapability {
15
+ /** Short capability name, e.g. "Web search". */
16
+ label: string;
17
+ /** One-line detail rendered under the label. */
18
+ description?: string;
19
+ /** Decorative leading glyph — a Lucide icon element. */
20
+ icon?: ReactNode;
21
+ }
22
+
23
+ export interface SessionWhatsNewItem {
24
+ /** What changed, e.g. "Faster file search". */
25
+ label: string;
26
+ /** Optional link to release notes / a changelog entry. */
27
+ href?: string;
28
+ }
29
+
30
+ export interface SessionQuickAction {
31
+ /** Visible label, e.g. "New chat". */
32
+ label: string;
33
+ /** Shortcut hint, rendered with the `Kbd` primitive (e.g. "⌘N"). */
34
+ keyHint?: string;
35
+ /** Invoked when the action is chosen. */
36
+ onSelect?: () => void;
37
+ }
@@ -0,0 +1,44 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { defaultSlashCommandFilter, stepIndex, type SlashCommand } from "./slash-command";
3
+
4
+ describe("defaultSlashCommandFilter", () => {
5
+ const command: SlashCommand = { name: "help", keywords: ["support", "docs"] };
6
+
7
+ it("matches everything on an empty query", () => {
8
+ expect(defaultSlashCommandFilter(command, "")).toBe(true);
9
+ });
10
+
11
+ it("matches a case-insensitive prefix of the name", () => {
12
+ expect(defaultSlashCommandFilter(command, "HEL")).toBe(true);
13
+ });
14
+
15
+ it("does not match a substring that is not a prefix", () => {
16
+ expect(defaultSlashCommandFilter(command, "elp")).toBe(false);
17
+ });
18
+
19
+ it("falls back to a prefix match on a keyword", () => {
20
+ expect(defaultSlashCommandFilter(command, "doc")).toBe(true);
21
+ });
22
+
23
+ it("rejects a query matching neither the name nor a keyword", () => {
24
+ expect(defaultSlashCommandFilter(command, "deploy")).toBe(false);
25
+ });
26
+ });
27
+
28
+ describe("stepIndex", () => {
29
+ it("returns -1 for an empty list", () => {
30
+ expect(stepIndex(0, 0, 1)).toBe(-1);
31
+ });
32
+
33
+ it("moves forward by one", () => {
34
+ expect(stepIndex(3, 0, 1)).toBe(1);
35
+ });
36
+
37
+ it("wraps forward past the end", () => {
38
+ expect(stepIndex(3, 2, 1)).toBe(0);
39
+ });
40
+
41
+ it("wraps backward past the start", () => {
42
+ expect(stepIndex(3, 0, -1)).toBe(2);
43
+ });
44
+ });
@@ -0,0 +1,45 @@
1
+ /**
2
+ * The command model behind a `/`-triggered slash-command palette
3
+ * (`PromptInputSlash` in `@elabs-ai/components-ai`, and the terminal CLI
4
+ * look-alike family's own palette — issue #117).
5
+ *
6
+ * Promoted here (not duplicated) because `@elabs-ai/components-ai` and
7
+ * `@elabs-ai/components-terminal` are layer-2 DAG SIBLINGS and may not import
8
+ * each other — `ui` is upstream of both, so this is the one legal shared home
9
+ * (docs/decisions/2026-09-01-brainless-adoption-architecture.md § 4, the
10
+ * promotion rule: "a helper shared by two layer-2 siblings moves UP to `ui`,
11
+ * it is never duplicated sideways"). No DOM, no keyboard wiring, no rendering
12
+ * — the popover, the caret tracking and the row markup stay in each
13
+ * package's own presentational component.
14
+ */
15
+ import type { ReactNode } from "react";
16
+
17
+ /** One entry a slash palette offers. Ships no vocabulary — entirely prop-driven. */
18
+ export interface SlashCommand {
19
+ /** Stable name, WITHOUT the leading "/" (e.g. `"help"`, not `"/help"`). */
20
+ name: string;
21
+ /** One line shown next to the name. */
22
+ description?: string;
23
+ /** Extra prefix-match aids beyond `name` (matched case-insensitively). */
24
+ keywords?: string[];
25
+ /** Leading glyph. Decorative — the row is identified by `name`. */
26
+ icon?: ReactNode;
27
+ }
28
+
29
+ /**
30
+ * Case-insensitive PREFIX match on `name`, falling back to a prefix match on
31
+ * any `keyword`. Prefix (not substring) is deliberate: `/hel` should surface
32
+ * `/help`, not e.g. a hypothetical `/channel`.
33
+ */
34
+ export function defaultSlashCommandFilter(command: SlashCommand, query: string): boolean {
35
+ const q = query.toLowerCase();
36
+ if (q.length === 0) return true;
37
+ if (command.name.toLowerCase().startsWith(q)) return true;
38
+ return (command.keywords ?? []).some((keyword) => keyword.toLowerCase().startsWith(q));
39
+ }
40
+
41
+ /** Next index in `[0, count)`, wrapping at both ends. `-1` when `count` is 0. */
42
+ export function stepIndex(count: number, from: number, direction: 1 | -1): number {
43
+ if (count === 0) return -1;
44
+ return (((from + direction) % count) + count) % count;
45
+ }