@mdxui/terminal 2.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 (191) hide show
  1. package/README.md +571 -0
  2. package/dist/ansi-css-Sk5mWtdK.d.ts +119 -0
  3. package/dist/ansi-css-V6JIHGsM.d.ts +119 -0
  4. package/dist/ansi-css-_3eSEU9d.d.ts +119 -0
  5. package/dist/chunk-3EFDH7PK.js +5235 -0
  6. package/dist/chunk-3RG5ZIWI.js +10 -0
  7. package/dist/chunk-3X5IR6WE.js +884 -0
  8. package/dist/chunk-4FV5ZDCE.js +5236 -0
  9. package/dist/chunk-4OVMSF2J.js +243 -0
  10. package/dist/chunk-63FEETIS.js +4048 -0
  11. package/dist/chunk-B43KP7XJ.js +884 -0
  12. package/dist/chunk-BMTJXWUV.js +655 -0
  13. package/dist/chunk-C3SVH4N7.js +882 -0
  14. package/dist/chunk-EVWR7Y47.js +874 -0
  15. package/dist/chunk-F6A5VWUC.js +1285 -0
  16. package/dist/chunk-FD7KW7GE.js +882 -0
  17. package/dist/chunk-GBQ6UD6I.js +655 -0
  18. package/dist/chunk-GMDD3M6U.js +5227 -0
  19. package/dist/chunk-JBHRXOXM.js +1058 -0
  20. package/dist/chunk-JFOO3EYO.js +1182 -0
  21. package/dist/chunk-JQ5H3WXL.js +1291 -0
  22. package/dist/chunk-JQD5NASE.js +234 -0
  23. package/dist/chunk-KRHJP5R7.js +592 -0
  24. package/dist/chunk-KWF6WVJE.js +962 -0
  25. package/dist/chunk-LHYQVN3H.js +1038 -0
  26. package/dist/chunk-M3TLQLGC.js +1032 -0
  27. package/dist/chunk-MVW4Q5OP.js +240 -0
  28. package/dist/chunk-NXCZSWLU.js +1294 -0
  29. package/dist/chunk-O25TNRO6.js +607 -0
  30. package/dist/chunk-PNECDA2I.js +884 -0
  31. package/dist/chunk-QIHWRLJR.js +962 -0
  32. package/dist/chunk-QW5YMQ7K.js +882 -0
  33. package/dist/chunk-R5U7XKVJ.js +16 -0
  34. package/dist/chunk-RP2MVQLR.js +962 -0
  35. package/dist/chunk-TP6RXGXA.js +1087 -0
  36. package/dist/chunk-TQQSTITZ.js +655 -0
  37. package/dist/chunk-X24GWXQV.js +1281 -0
  38. package/dist/components/index.d.ts +802 -0
  39. package/dist/components/index.js +149 -0
  40. package/dist/data/index.d.ts +2554 -0
  41. package/dist/data/index.js +51 -0
  42. package/dist/forms/index.d.ts +1596 -0
  43. package/dist/forms/index.js +464 -0
  44. package/dist/index-CQRFZntR.d.ts +867 -0
  45. package/dist/index.d.ts +579 -0
  46. package/dist/index.js +786 -0
  47. package/dist/interactive-D0JkWosD.d.ts +217 -0
  48. package/dist/keyboard/index.d.ts +2 -0
  49. package/dist/keyboard/index.js +43 -0
  50. package/dist/renderers/index.d.ts +546 -0
  51. package/dist/renderers/index.js +2157 -0
  52. package/dist/storybook/index.d.ts +396 -0
  53. package/dist/storybook/index.js +641 -0
  54. package/dist/theme/index.d.ts +1339 -0
  55. package/dist/theme/index.js +123 -0
  56. package/dist/types-Bxu5PAgA.d.ts +710 -0
  57. package/dist/types-CIlop5Ji.d.ts +701 -0
  58. package/dist/types-Ca8p_p5X.d.ts +710 -0
  59. package/package.json +90 -0
  60. package/src/__tests__/components/data/card.test.ts +458 -0
  61. package/src/__tests__/components/data/list.test.ts +473 -0
  62. package/src/__tests__/components/data/metrics.test.ts +541 -0
  63. package/src/__tests__/components/data/table.test.ts +448 -0
  64. package/src/__tests__/components/input/field.test.ts +555 -0
  65. package/src/__tests__/components/input/form.test.ts +870 -0
  66. package/src/__tests__/components/input/search.test.ts +1238 -0
  67. package/src/__tests__/components/input/select.test.ts +658 -0
  68. package/src/__tests__/components/navigation/breadcrumb.test.ts +923 -0
  69. package/src/__tests__/components/navigation/command-palette.test.ts +1095 -0
  70. package/src/__tests__/components/navigation/sidebar.test.ts +1018 -0
  71. package/src/__tests__/components/navigation/tabs.test.ts +995 -0
  72. package/src/__tests__/components.test.tsx +1197 -0
  73. package/src/__tests__/core/compiler.test.ts +986 -0
  74. package/src/__tests__/core/parser.test.ts +785 -0
  75. package/src/__tests__/core/tier-switcher.test.ts +1103 -0
  76. package/src/__tests__/core/types.test.ts +1398 -0
  77. package/src/__tests__/data/collections.test.ts +1337 -0
  78. package/src/__tests__/data/db.test.ts +1265 -0
  79. package/src/__tests__/data/reactive.test.ts +1010 -0
  80. package/src/__tests__/data/sync.test.ts +1614 -0
  81. package/src/__tests__/errors.test.ts +660 -0
  82. package/src/__tests__/forms/integration.test.ts +444 -0
  83. package/src/__tests__/integration.test.ts +905 -0
  84. package/src/__tests__/keyboard.test.ts +1791 -0
  85. package/src/__tests__/renderer.test.ts +489 -0
  86. package/src/__tests__/renderers/ansi-css.test.ts +948 -0
  87. package/src/__tests__/renderers/ansi.test.ts +1366 -0
  88. package/src/__tests__/renderers/ascii.test.ts +1360 -0
  89. package/src/__tests__/renderers/interactive.test.ts +2353 -0
  90. package/src/__tests__/renderers/markdown.test.ts +1483 -0
  91. package/src/__tests__/renderers/text.test.ts +1369 -0
  92. package/src/__tests__/renderers/unicode.test.ts +1307 -0
  93. package/src/__tests__/theme.test.ts +639 -0
  94. package/src/__tests__/utils/assertions.ts +685 -0
  95. package/src/__tests__/utils/index.ts +115 -0
  96. package/src/__tests__/utils/test-renderer.ts +381 -0
  97. package/src/__tests__/utils/utils.test.ts +560 -0
  98. package/src/components/containers/card.ts +56 -0
  99. package/src/components/containers/dialog.ts +53 -0
  100. package/src/components/containers/index.ts +9 -0
  101. package/src/components/containers/panel.ts +59 -0
  102. package/src/components/feedback/badge.ts +40 -0
  103. package/src/components/feedback/index.ts +8 -0
  104. package/src/components/feedback/spinner.ts +23 -0
  105. package/src/components/helpers.ts +81 -0
  106. package/src/components/index.ts +153 -0
  107. package/src/components/layout/breadcrumb.ts +31 -0
  108. package/src/components/layout/index.ts +10 -0
  109. package/src/components/layout/list.ts +29 -0
  110. package/src/components/layout/sidebar.ts +79 -0
  111. package/src/components/layout/table.ts +62 -0
  112. package/src/components/primitives/box.ts +95 -0
  113. package/src/components/primitives/button.ts +54 -0
  114. package/src/components/primitives/index.ts +11 -0
  115. package/src/components/primitives/input.ts +88 -0
  116. package/src/components/primitives/select.ts +97 -0
  117. package/src/components/primitives/text.ts +60 -0
  118. package/src/components/render.ts +155 -0
  119. package/src/components/templates/app.ts +43 -0
  120. package/src/components/templates/index.ts +8 -0
  121. package/src/components/templates/site.ts +54 -0
  122. package/src/components/types.ts +777 -0
  123. package/src/core/compiler.ts +718 -0
  124. package/src/core/parser.ts +127 -0
  125. package/src/core/tier-switcher.ts +607 -0
  126. package/src/core/types.ts +672 -0
  127. package/src/data/collection.ts +316 -0
  128. package/src/data/collections.ts +50 -0
  129. package/src/data/context.tsx +174 -0
  130. package/src/data/db.ts +127 -0
  131. package/src/data/hooks.ts +532 -0
  132. package/src/data/index.ts +138 -0
  133. package/src/data/reactive.ts +1225 -0
  134. package/src/data/saas-collections.ts +375 -0
  135. package/src/data/sync.ts +1213 -0
  136. package/src/data/types.ts +660 -0
  137. package/src/forms/converters.ts +512 -0
  138. package/src/forms/index.ts +133 -0
  139. package/src/forms/schemas.ts +403 -0
  140. package/src/forms/types.ts +476 -0
  141. package/src/index.ts +542 -0
  142. package/src/keyboard/focus.ts +748 -0
  143. package/src/keyboard/index.ts +96 -0
  144. package/src/keyboard/integration.ts +371 -0
  145. package/src/keyboard/manager.ts +377 -0
  146. package/src/keyboard/presets.ts +90 -0
  147. package/src/renderers/ansi-css.ts +576 -0
  148. package/src/renderers/ansi.ts +802 -0
  149. package/src/renderers/ascii.ts +680 -0
  150. package/src/renderers/breadcrumb.ts +480 -0
  151. package/src/renderers/command-palette.ts +802 -0
  152. package/src/renderers/components/field.ts +210 -0
  153. package/src/renderers/components/form.ts +327 -0
  154. package/src/renderers/components/index.ts +21 -0
  155. package/src/renderers/components/search.ts +449 -0
  156. package/src/renderers/components/select.ts +222 -0
  157. package/src/renderers/index.ts +101 -0
  158. package/src/renderers/interactive/component-handlers.ts +622 -0
  159. package/src/renderers/interactive/cursor-manager.ts +147 -0
  160. package/src/renderers/interactive/focus-manager.ts +279 -0
  161. package/src/renderers/interactive/index.ts +661 -0
  162. package/src/renderers/interactive/input-handler.ts +164 -0
  163. package/src/renderers/interactive/keyboard-handler.ts +212 -0
  164. package/src/renderers/interactive/mouse-handler.ts +167 -0
  165. package/src/renderers/interactive/state-manager.ts +109 -0
  166. package/src/renderers/interactive/types.ts +338 -0
  167. package/src/renderers/interactive-string.ts +299 -0
  168. package/src/renderers/interactive.ts +59 -0
  169. package/src/renderers/markdown.ts +950 -0
  170. package/src/renderers/sidebar.ts +549 -0
  171. package/src/renderers/tabs.ts +682 -0
  172. package/src/renderers/text.ts +791 -0
  173. package/src/renderers/unicode.ts +917 -0
  174. package/src/renderers/utils.ts +942 -0
  175. package/src/router/adapters.ts +383 -0
  176. package/src/router/types.ts +140 -0
  177. package/src/router/utils.ts +452 -0
  178. package/src/schemas.ts +205 -0
  179. package/src/storybook/index.ts +91 -0
  180. package/src/storybook/interactive-decorator.tsx +659 -0
  181. package/src/storybook/keyboard-simulator.ts +501 -0
  182. package/src/theme/ansi-codes.ts +80 -0
  183. package/src/theme/box-drawing.ts +132 -0
  184. package/src/theme/color-convert.ts +254 -0
  185. package/src/theme/color-support.ts +321 -0
  186. package/src/theme/index.ts +134 -0
  187. package/src/theme/strip-ansi.ts +50 -0
  188. package/src/theme/tailwind-map.ts +469 -0
  189. package/src/theme/text-styles.ts +206 -0
  190. package/src/theme/theme-system.ts +568 -0
  191. package/src/types.ts +103 -0
@@ -0,0 +1,682 @@
1
+ /**
2
+ * @mdxui/terminal Tabs Renderer
3
+ *
4
+ * Multi-tier renderer for tabs navigation component.
5
+ * Supports all 6 tiers: text, markdown, ascii, unicode, ansi, interactive.
6
+ */
7
+
8
+ import type { UINode, RenderContext, RenderTier } from '../core/types'
9
+ import {
10
+ matchPath,
11
+ findActiveItemByPath,
12
+ type RouteMatchMode,
13
+ type RouterAdapter,
14
+ } from './utils'
15
+
16
+ // ANSI constants
17
+ const RESET = '\x1b[0m'
18
+ const BOLD = '\x1b[1m'
19
+ const DIM = '\x1b[2m'
20
+ const UNDERLINE = '\x1b[4m'
21
+ const INVERSE = '\x1b[7m'
22
+
23
+ // ============================================================================
24
+ // Types
25
+ // ============================================================================
26
+
27
+ interface Tab {
28
+ id: string
29
+ label: string
30
+ icon?: string
31
+ badge?: string | number
32
+ disabled?: boolean
33
+ content?: string | UINode
34
+ /** Path associated with this tab for route-based switching */
35
+ path?: string
36
+ }
37
+
38
+ interface TabsProps {
39
+ tabs: Tab[]
40
+ activeId: string
41
+ variant?: 'default' | 'pills' | 'underline'
42
+ orientation?: 'horizontal' | 'vertical'
43
+ onTabChange?: (tabId: string) => void
44
+ /** Current route path for automatic active tab detection */
45
+ currentPath?: string
46
+ /** Route matching mode: 'exact', 'prefix', or 'pattern' */
47
+ routeMatchMode?: RouteMatchMode
48
+ /** Router adapter for framework integration */
49
+ router?: RouterAdapter
50
+ }
51
+
52
+ interface TabsState {
53
+ tabs: Tab[]
54
+ activeId: string
55
+ focusedId: string
56
+ orientation?: 'horizontal' | 'vertical'
57
+ wrapNavigation?: boolean
58
+ onTabChange?: (tabId: string) => void
59
+ activationMode?: 'automatic' | 'manual'
60
+ /** Current route path for automatic active detection */
61
+ currentPath?: string
62
+ /** Route matching mode */
63
+ routeMatchMode?: RouteMatchMode
64
+ /** Router adapter for framework integration */
65
+ router?: RouterAdapter
66
+ }
67
+
68
+ // ============================================================================
69
+ // Main Render Function
70
+ // ============================================================================
71
+
72
+ export function renderTabs(node: UINode, ctx: RenderContext): string {
73
+ const props = node.props as unknown as TabsProps
74
+ const {
75
+ activeId: providedActiveId,
76
+ variant = 'default',
77
+ orientation = 'horizontal',
78
+ currentPath,
79
+ routeMatchMode = 'exact',
80
+ router,
81
+ } = props
82
+
83
+ // Extract tabs from node structure
84
+ const tabs = extractTabs(node)
85
+
86
+ if (!tabs || tabs.length === 0) {
87
+ return ''
88
+ }
89
+
90
+ // Determine active tab ID - prefer explicit activeId, then route-based detection
91
+ let activeId: string = providedActiveId || ''
92
+ if (!activeId && currentPath) {
93
+ activeId = findActiveTabByPath(tabs, currentPath, routeMatchMode) || ''
94
+ }
95
+ if (!activeId && router) {
96
+ const routerPath = router.getCurrentPath()
97
+ activeId = findActiveTabByPath(tabs, routerPath, routeMatchMode) || ''
98
+ }
99
+ // Fallback to first tab if no active found
100
+ if (!activeId && tabs.length > 0) {
101
+ activeId = tabs[0].id
102
+ }
103
+
104
+ const tier = ctx.tier
105
+ const maxWidth = ctx.width || 80
106
+ const lines: string[] = []
107
+
108
+ // Render tab list
109
+ const tabList = renderTabList(tabs, activeId, variant, orientation, tier, ctx, maxWidth)
110
+ lines.push(...tabList)
111
+
112
+ // Render active tab content
113
+ const activeTab = tabs.find((t) => t.id === activeId)
114
+ if (activeTab && activeTab.content) {
115
+ lines.push('') // Separator
116
+ const contentStr = renderTabContent(activeTab.content, tier, ctx)
117
+ if (contentStr) {
118
+ lines.push(contentStr)
119
+ }
120
+ }
121
+
122
+ return lines.join('\n')
123
+ }
124
+
125
+ // ============================================================================
126
+ // Tab Extraction
127
+ // ============================================================================
128
+
129
+ function extractTabs(node: UINode): Tab[] {
130
+ const props = node.props as unknown as TabsProps
131
+ if (props.tabs) {
132
+ return props.tabs
133
+ }
134
+
135
+ // Extract from children if structured as tab-list
136
+ const tabs: Tab[] = []
137
+ const rawChildren = node.children
138
+ const children: UINode[] = Array.isArray(rawChildren) ? rawChildren : []
139
+
140
+ for (const child of children) {
141
+ if (child.type === 'tab-list') {
142
+ const rawTabItems = child.children
143
+ const tabItems: UINode[] = Array.isArray(rawTabItems) ? rawTabItems : []
144
+ for (const tabItem of tabItems) {
145
+ if (tabItem.type === 'tab') {
146
+ const tabItemProps = tabItem.props || {}
147
+ tabs.push({
148
+ id: tabItemProps.id as string,
149
+ label: tabItemProps.label as string,
150
+ icon: tabItemProps.icon as string | undefined,
151
+ badge: tabItemProps.badge as string | number | undefined,
152
+ disabled: tabItemProps.disabled as boolean | undefined,
153
+ })
154
+ }
155
+ }
156
+ } else if (child.type === 'tab-panel') {
157
+ // Find corresponding tab and attach content
158
+ const childProps = child.props || {}
159
+ const tabId = childProps.tabId as string
160
+ const tab = tabs.find((t) => t.id === tabId)
161
+ if (tab) {
162
+ const rawPanelChildren = child.children
163
+ const panelChildren: UINode[] = Array.isArray(rawPanelChildren) ? rawPanelChildren : []
164
+ if (panelChildren.length > 0) {
165
+ const textChild = panelChildren[0]
166
+ if (textChild.type === 'text') {
167
+ const textChildProps = textChild.props || {}
168
+ tab.content = textChildProps.content as string
169
+ } else {
170
+ tab.content = textChild
171
+ }
172
+ }
173
+ }
174
+ }
175
+ }
176
+
177
+ return tabs
178
+ }
179
+
180
+ // ============================================================================
181
+ // Tab List Rendering
182
+ // ============================================================================
183
+
184
+ function renderTabList(
185
+ tabs: Tab[],
186
+ activeId: string,
187
+ variant: 'default' | 'pills' | 'underline',
188
+ orientation: 'horizontal' | 'vertical',
189
+ tier: RenderTier,
190
+ ctx: RenderContext,
191
+ maxWidth: number
192
+ ): string[] {
193
+ const lines: string[] = []
194
+
195
+ if (orientation === 'vertical') {
196
+ // Vertical: each tab on its own line
197
+ for (const tab of tabs) {
198
+ const isActive = tab.id === activeId
199
+ const tabStr = renderSingleTab(tab, isActive, variant, tier, ctx)
200
+ lines.push(tabStr)
201
+ }
202
+ return lines
203
+ }
204
+
205
+ // Horizontal: all tabs on one line
206
+ const tabParts: string[] = []
207
+ const underlineParts: string[] = []
208
+
209
+ for (const tab of tabs) {
210
+ const isActive = tab.id === activeId
211
+ const tabStr = renderSingleTab(tab, isActive, variant, tier, ctx)
212
+ tabParts.push(tabStr)
213
+
214
+ // For underline variant, track underline parts
215
+ if (variant === 'underline' && tier !== 'text' && tier !== 'markdown') {
216
+ const labelLen = stripAnsi(tabStr).length
217
+ if (isActive) {
218
+ const underlineChar = tier === 'ascii' ? '-' : '\u2500'
219
+ underlineParts.push(underlineChar.repeat(labelLen))
220
+ } else {
221
+ underlineParts.push(' '.repeat(labelLen))
222
+ }
223
+ }
224
+ }
225
+
226
+ // Join tabs with separator
227
+ const separator = tier === 'text' || tier === 'markdown' || tier === 'ascii' ? ' | ' : ' \u2502 '
228
+ let tabLine = tabParts.join(separator)
229
+ let finalParts = tabParts
230
+
231
+ // Check if we need to truncate individual tab labels (when total is too wide)
232
+ let currentWidth = stripAnsi(tabLine).length
233
+ if (currentWidth > maxWidth) {
234
+ // Truncate individual tab labels
235
+ const truncatedParts: string[] = []
236
+ const separatorWidth = separator.length
237
+ const availableForTabs = maxWidth - (separatorWidth * (tabs.length - 1))
238
+ const maxLabelWidth = Math.max(5, Math.floor(availableForTabs / tabs.length) - 4) // Reserve space for styling
239
+
240
+ for (const tab of tabs) {
241
+ const isActive = tab.id === activeId
242
+ let label = tab.label
243
+
244
+ // Truncate label if too long
245
+ if (label.length > maxLabelWidth) {
246
+ const ellipsis = tier === 'unicode' || tier === 'ansi' || tier === 'interactive' ? '\u2026' : '...'
247
+ label = label.slice(0, maxLabelWidth - 1) + ellipsis
248
+ }
249
+
250
+ const truncatedTab: Tab = { ...tab, label }
251
+ const tabStr = renderSingleTab(truncatedTab, isActive, variant, tier, ctx)
252
+ truncatedParts.push(tabStr)
253
+ }
254
+
255
+ tabLine = truncatedParts.join(separator)
256
+ finalParts = truncatedParts
257
+ }
258
+
259
+ // Stretch tabs to fill available width (only add modest padding)
260
+ currentWidth = stripAnsi(tabLine).length
261
+ if (currentWidth < maxWidth && tabs.length > 1) {
262
+ const extraSpace = maxWidth - currentWidth
263
+ const paddingPerSide = Math.min(4, Math.floor(extraSpace / (2 * (tabs.length - 1))))
264
+ if (paddingPerSide > 0) {
265
+ const paddedParts = finalParts.map((part, idx) => {
266
+ if (idx === 0) return part + ' '.repeat(paddingPerSide)
267
+ if (idx === finalParts.length - 1) return ' '.repeat(paddingPerSide) + part
268
+ return ' '.repeat(paddingPerSide) + part + ' '.repeat(paddingPerSide)
269
+ })
270
+ tabLine = paddedParts.join(separator)
271
+ }
272
+ }
273
+
274
+ lines.push(tabLine)
275
+
276
+ // Add underline row for underline variant
277
+ if (variant === 'underline' && underlineParts.length > 0) {
278
+ const underlineSep = ' '.repeat(separator.length)
279
+ lines.push(underlineParts.join(underlineSep))
280
+ }
281
+
282
+ return lines
283
+ }
284
+
285
+ function renderSingleTab(
286
+ tab: Tab,
287
+ isActive: boolean,
288
+ variant: 'default' | 'pills' | 'underline',
289
+ tier: RenderTier,
290
+ ctx: RenderContext
291
+ ): string {
292
+ let label = tab.label
293
+
294
+ // Add icon for unicode/ansi/interactive
295
+ if (tab.icon && (tier === 'unicode' || tier === 'ansi' || tier === 'interactive')) {
296
+ label = tab.icon + ' ' + label
297
+ }
298
+
299
+ // Add badge
300
+ if (tab.badge !== undefined) {
301
+ label = label + ` (${tab.badge})`
302
+ }
303
+
304
+ // Text tier
305
+ if (tier === 'text') {
306
+ if (tab.disabled) {
307
+ return `${label} (disabled)`
308
+ }
309
+ return isActive ? `[${label}]` : label
310
+ }
311
+
312
+ // Markdown tier
313
+ if (tier === 'markdown') {
314
+ if (tab.disabled) {
315
+ return `~${label}~`
316
+ }
317
+ return isActive ? `**${label}**` : label
318
+ }
319
+
320
+ // ASCII tier
321
+ if (tier === 'ascii') {
322
+ if (tab.disabled) {
323
+ return `${label} (disabled)`
324
+ }
325
+ if (variant === 'pills' && isActive) {
326
+ return `( ${label} )`
327
+ }
328
+ return isActive ? `[${label}]` : label
329
+ }
330
+
331
+ // Unicode tier
332
+ if (tier === 'unicode') {
333
+ if (tab.disabled) {
334
+ return label
335
+ }
336
+ if (variant === 'pills' && isActive) {
337
+ return `\u256D ${label} \u256E`
338
+ }
339
+ return label
340
+ }
341
+
342
+ // ANSI tier
343
+ if (tier === 'ansi') {
344
+ if (tab.disabled) {
345
+ return ctx.theme.muted + label + RESET
346
+ }
347
+ if (isActive) {
348
+ return ctx.theme.primary + BOLD + label + RESET
349
+ }
350
+ return label
351
+ }
352
+
353
+ // Interactive tier
354
+ if (tier === 'interactive') {
355
+ if (tab.disabled) {
356
+ return ctx.theme.muted + label + RESET
357
+ }
358
+ if (isActive) {
359
+ return INVERSE + label + RESET
360
+ }
361
+ return label
362
+ }
363
+
364
+ return label
365
+ }
366
+
367
+ // ============================================================================
368
+ // Tab Content Rendering
369
+ // ============================================================================
370
+
371
+ function renderTabContent(content: string | UINode, tier: RenderTier, ctx: RenderContext): string {
372
+ if (typeof content === 'string') {
373
+ return content
374
+ }
375
+
376
+ const contentProps = content.props || {}
377
+
378
+ // For UINode content, render recursively
379
+ if (content.type === 'text') {
380
+ return contentProps.content as string
381
+ }
382
+
383
+ // For complex nodes, render their text content
384
+ if (content.type === 'box') {
385
+ const rawChildren = content.children
386
+ const children: UINode[] = Array.isArray(rawChildren) ? rawChildren : []
387
+ const parts: string[] = []
388
+ for (const child of children) {
389
+ if (child.type === 'text') {
390
+ const childProps = child.props || {}
391
+ parts.push(childProps.content as string)
392
+ }
393
+ }
394
+ return parts.join('\n')
395
+ }
396
+
397
+ return ''
398
+ }
399
+
400
+ // ============================================================================
401
+ // Keyboard Bindings
402
+ // ============================================================================
403
+
404
+ export function getTabsKeyBindings(): Record<string, string> {
405
+ return {
406
+ left: 'focus-prev',
407
+ right: 'focus-next',
408
+ h: 'focus-prev',
409
+ l: 'focus-next',
410
+ j: 'focus-next', // For vertical orientation
411
+ k: 'focus-prev', // For vertical orientation
412
+ enter: 'activate',
413
+ space: 'activate',
414
+ home: 'focus-first',
415
+ end: 'focus-last',
416
+ }
417
+ }
418
+
419
+ // ============================================================================
420
+ // State Management
421
+ // ============================================================================
422
+
423
+ export function createTabsState(config: {
424
+ tabs: Tab[]
425
+ activeId: string
426
+ focusedId?: string
427
+ orientation?: 'horizontal' | 'vertical'
428
+ wrapNavigation?: boolean
429
+ onTabChange?: (tabId: string) => void
430
+ activationMode?: 'automatic' | 'manual'
431
+ /** Current route path for automatic active detection */
432
+ currentPath?: string
433
+ /** Route matching mode */
434
+ routeMatchMode?: RouteMatchMode
435
+ /** Router adapter for framework integration */
436
+ router?: RouterAdapter
437
+ }): TabsState {
438
+ const {
439
+ tabs,
440
+ activeId,
441
+ focusedId,
442
+ orientation,
443
+ wrapNavigation,
444
+ onTabChange,
445
+ activationMode,
446
+ currentPath,
447
+ routeMatchMode,
448
+ router,
449
+ } = config
450
+
451
+ return {
452
+ tabs,
453
+ activeId,
454
+ focusedId: focusedId || activeId,
455
+ orientation,
456
+ wrapNavigation,
457
+ onTabChange,
458
+ activationMode: activationMode || 'manual',
459
+ currentPath,
460
+ routeMatchMode,
461
+ router,
462
+ }
463
+ }
464
+
465
+ export function handleTabsKey(state: TabsState, key: string): TabsState {
466
+ const { tabs, focusedId, orientation, wrapNavigation, onTabChange, activationMode, router } = state
467
+ const enabledTabs = tabs.filter((t) => !t.disabled)
468
+
469
+ // Helper to handle tab activation (with router support)
470
+ const activateTab = (tabId: string): void => {
471
+ const tab = tabs.find((t) => t.id === tabId)
472
+ // Use router adapter if available and tab has a path, otherwise use callback
473
+ if (tab?.path && router) {
474
+ router.navigate(tab.path)
475
+ } else if (onTabChange) {
476
+ onTabChange(tabId)
477
+ }
478
+ }
479
+ const focusedIndex = enabledTabs.findIndex((t) => t.id === focusedId)
480
+
481
+ // Determine movement keys based on orientation
482
+ const isVertical = orientation === 'vertical'
483
+ const nextKeys = isVertical ? ['j', 'down'] : ['l', 'right']
484
+ const prevKeys = isVertical ? ['k', 'up'] : ['h', 'left']
485
+
486
+ if (nextKeys.includes(key)) {
487
+ let newIndex = focusedIndex + 1
488
+ if (newIndex >= enabledTabs.length) {
489
+ newIndex = wrapNavigation ? 0 : enabledTabs.length - 1
490
+ }
491
+ const newFocusedId = enabledTabs[newIndex]?.id || focusedId
492
+
493
+ // Automatic activation
494
+ if (activationMode === 'automatic' && newFocusedId !== focusedId) {
495
+ activateTab(newFocusedId)
496
+ return { ...state, focusedId: newFocusedId, activeId: newFocusedId }
497
+ }
498
+
499
+ return { ...state, focusedId: newFocusedId }
500
+ }
501
+
502
+ if (prevKeys.includes(key)) {
503
+ let newIndex = focusedIndex - 1
504
+ if (newIndex < 0) {
505
+ newIndex = wrapNavigation ? enabledTabs.length - 1 : 0
506
+ }
507
+ const newFocusedId = enabledTabs[newIndex]?.id || focusedId
508
+
509
+ // Automatic activation
510
+ if (activationMode === 'automatic' && newFocusedId !== focusedId) {
511
+ activateTab(newFocusedId)
512
+ return { ...state, focusedId: newFocusedId, activeId: newFocusedId }
513
+ }
514
+
515
+ return { ...state, focusedId: newFocusedId }
516
+ }
517
+
518
+ switch (key) {
519
+ case 'home': {
520
+ const firstTab = enabledTabs[0]
521
+ if (firstTab) {
522
+ return { ...state, focusedId: firstTab.id }
523
+ }
524
+ return state
525
+ }
526
+
527
+ case 'end': {
528
+ const lastTab = enabledTabs[enabledTabs.length - 1]
529
+ if (lastTab) {
530
+ return { ...state, focusedId: lastTab.id }
531
+ }
532
+ return state
533
+ }
534
+
535
+ case 'enter':
536
+ case 'space': {
537
+ activateTab(focusedId)
538
+ return { ...state, activeId: focusedId }
539
+ }
540
+
541
+ default:
542
+ return state
543
+ }
544
+ }
545
+
546
+ // ============================================================================
547
+ // Utility Functions
548
+ // ============================================================================
549
+
550
+ function stripAnsi(str: string): string {
551
+ return str.replace(/\x1b\[[\d;]*m/g, '')
552
+ }
553
+
554
+ function truncateToWidth(line: string, maxWidth: number, tier: RenderTier): string {
555
+ const stripped = stripAnsi(line)
556
+ if (stripped.length <= maxWidth) return line
557
+
558
+ const ellipsis = tier === 'unicode' || tier === 'ansi' || tier === 'interactive' ? '\u2026' : '...'
559
+ const ellipsisLen = tier === 'unicode' || tier === 'ansi' || tier === 'interactive' ? 1 : 3
560
+
561
+ let visibleLen = 0
562
+ let result = ''
563
+ let inEscape = false
564
+
565
+ for (let i = 0; i < line.length; i++) {
566
+ const char = line[i]
567
+
568
+ if (char === '\x1b') {
569
+ inEscape = true
570
+ result += char
571
+ continue
572
+ }
573
+
574
+ if (inEscape) {
575
+ result += char
576
+ if (char === 'm') {
577
+ inEscape = false
578
+ }
579
+ continue
580
+ }
581
+
582
+ if (visibleLen >= maxWidth - ellipsisLen) {
583
+ result += ellipsis + RESET
584
+ break
585
+ }
586
+
587
+ result += char
588
+ visibleLen++
589
+ }
590
+
591
+ return result
592
+ }
593
+
594
+ // ============================================================================
595
+ // Router Integration Functions
596
+ // ============================================================================
597
+
598
+ /**
599
+ * Finds the active tab ID based on the current route path.
600
+ * Tabs with `path` property will be matched against the current path.
601
+ *
602
+ * @param tabs - Array of tabs with optional path property
603
+ * @param currentPath - Current route path
604
+ * @param mode - Route matching mode ('exact', 'prefix', or 'pattern')
605
+ * @returns The ID of the matching tab, or undefined if no match
606
+ */
607
+ export function findActiveTabByPath(
608
+ tabs: Tab[],
609
+ currentPath: string,
610
+ mode: RouteMatchMode = 'exact'
611
+ ): string | undefined {
612
+ // Filter to tabs that have paths
613
+ const tabsWithPaths = tabs.filter((t) => t.path)
614
+ return findActiveItemByPath(tabsWithPaths, currentPath, mode)
615
+ }
616
+
617
+ /**
618
+ * Checks if a specific tab is active based on the current route.
619
+ *
620
+ * @param tab - Tab to check
621
+ * @param currentPath - Current route path
622
+ * @param mode - Route matching mode
623
+ * @returns true if the tab is active
624
+ */
625
+ export function isTabActiveByRoute(
626
+ tab: Tab,
627
+ currentPath: string,
628
+ mode: RouteMatchMode = 'exact'
629
+ ): boolean {
630
+ if (!tab.path) return false
631
+ return matchPath(currentPath, tab.path, mode)
632
+ }
633
+
634
+ /**
635
+ * Creates a tab change handler that integrates with a router adapter.
636
+ * Returns a function compatible with the onTabChange callback.
637
+ *
638
+ * @param tabs - Array of tabs (needed to look up paths by ID)
639
+ * @param router - Router adapter instance
640
+ * @param fallback - Optional fallback handler if router navigation fails
641
+ * @returns Tab change handler function
642
+ */
643
+ export function createRouterTabChangeHandler(
644
+ tabs: Tab[],
645
+ router: RouterAdapter,
646
+ fallback?: (tabId: string) => void
647
+ ): (tabId: string) => void {
648
+ return (tabId: string) => {
649
+ const tab = tabs.find((t) => t.id === tabId)
650
+ if (tab?.path) {
651
+ router.navigate(tab.path)
652
+ } else if (fallback) {
653
+ fallback(tabId)
654
+ }
655
+ }
656
+ }
657
+
658
+ /**
659
+ * Creates tabs from route definitions.
660
+ * Useful for generating tabs from a list of routes.
661
+ *
662
+ * @param routes - Array of route definitions
663
+ * @param labelGenerator - Optional function to generate labels from paths
664
+ * @returns Array of Tab objects
665
+ */
666
+ export function tabsFromRoutes(
667
+ routes: Array<{
668
+ path: string
669
+ label?: string
670
+ icon?: string
671
+ disabled?: boolean
672
+ }>,
673
+ labelGenerator?: (path: string) => string
674
+ ): Tab[] {
675
+ return routes.map((route, index) => ({
676
+ id: route.path || `tab-${index}`,
677
+ label: route.label || (labelGenerator ? labelGenerator(route.path) : route.path),
678
+ path: route.path,
679
+ icon: route.icon,
680
+ disabled: route.disabled,
681
+ }))
682
+ }