@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,917 @@
1
+ /**
2
+ * Unicode Renderer - Renders UINode trees using Unicode box-drawing characters
3
+ *
4
+ * This renderer is part of the 6-tier Universal Terminal UI system.
5
+ * It uses Unicode characters for beautiful terminal output without ANSI colors.
6
+ *
7
+ * Unicode box drawing characters:
8
+ * - Single: ┌ ─ ┐ │ └ ┘ ├ ┤ ┬ ┴ ┼
9
+ * - Double: ╔ ═ ╗ ║ ╚ ╝
10
+ * - Rounded: ╭ ╮ ╰ ╯
11
+ *
12
+ * Bullets: • ◦ ▪ ▸ ▾ ✓ ✗
13
+ * Progress: ▓ ░ ▒
14
+ * Spinners: ⠋ ⠙ ⠹ ⠸ ⠼ ⠴ ⠦ ⠧ ⠇ ⠏
15
+ */
16
+
17
+ import type { UINode, RenderContext, ThemeTokens } from '../core/types'
18
+ import {
19
+ DEFAULT_THEME_TOKENS,
20
+ DEFAULT_RENDER_CONTEXT,
21
+ type BoxChars,
22
+ getUnicodeBoxChars,
23
+ UNICODE_SYMBOLS,
24
+ SPINNER_FRAMES,
25
+ } from './utils'
26
+
27
+ // ============================================================================
28
+ // Unicode Characters
29
+ // ============================================================================
30
+
31
+ const UNICODE = {
32
+ // Single box drawing
33
+ topLeft: '┌',
34
+ topRight: '┐',
35
+ bottomLeft: '└',
36
+ bottomRight: '┘',
37
+ horizontal: '─',
38
+ vertical: '│',
39
+
40
+ // T-junctions
41
+ teeLeft: '├',
42
+ teeRight: '┤',
43
+ teeTop: '┬',
44
+ teeBottom: '┴',
45
+ crossJunction: '┼',
46
+
47
+ // Double box drawing
48
+ doubleTopLeft: '╔',
49
+ doubleTopRight: '╗',
50
+ doubleBottomLeft: '╚',
51
+ doubleBottomRight: '╝',
52
+ doubleHorizontal: '═',
53
+ doubleVertical: '║',
54
+
55
+ // Rounded corners
56
+ roundedTopLeft: '╭',
57
+ roundedTopRight: '╮',
58
+ roundedBottomLeft: '╰',
59
+ roundedBottomRight: '╯',
60
+
61
+ // Bullets and list markers
62
+ bullet: '•',
63
+ hollowBullet: '◦',
64
+ squareBullet: '▪',
65
+ triangleRight: '▸',
66
+ triangleDown: '▾',
67
+ checkmark: '✓',
68
+ crossMark: '✗',
69
+ unchecked: '☐',
70
+ arrowRight: '→',
71
+ arrowDown: '↓',
72
+
73
+ // Progress bar characters
74
+ progressFull: '▓',
75
+ progressEmpty: '░',
76
+ progressHalf: '▒',
77
+
78
+ // Dividers
79
+ ellipsis: '…',
80
+ middleDot: '·',
81
+ } as const
82
+
83
+ // Note: SPINNER_FRAMES is now imported from utils
84
+
85
+ // ============================================================================
86
+ // Default Context
87
+ // ============================================================================
88
+
89
+ function createDefaultTheme(): ThemeTokens {
90
+ return { ...DEFAULT_THEME_TOKENS }
91
+ }
92
+
93
+ function createDefaultContext(): RenderContext {
94
+ return {
95
+ tier: 'unicode',
96
+ width: DEFAULT_RENDER_CONTEXT.width,
97
+ height: DEFAULT_RENDER_CONTEXT.height,
98
+ depth: DEFAULT_RENDER_CONTEXT.depth,
99
+ theme: createDefaultTheme(),
100
+ interactive: false,
101
+ }
102
+ }
103
+
104
+ // ============================================================================
105
+ // Box Rendering
106
+ // ============================================================================
107
+
108
+ // Note: BoxChars interface and getUnicodeBoxChars are now imported from utils
109
+
110
+ function renderBox(node: UINode, context: RenderContext): string {
111
+ const props = node.props || {}
112
+ const border = (props.border as string) || 'single'
113
+ const chars = getUnicodeBoxChars(border)
114
+
115
+ // Render children first to determine content
116
+ let content = ''
117
+ if (node.children && (typeof node.children === 'string' || node.children.length > 0)) {
118
+ if (typeof node.children === 'string') {
119
+ content = node.children
120
+ } else {
121
+ content = node.children
122
+ .map((child: UINode) => renderUnicode(child, { ...context, depth: context.depth + 1 }))
123
+ .join('\n')
124
+ }
125
+ }
126
+
127
+ const contentLines = content ? content.split('\n') : []
128
+ const contentWidth = Math.max(
129
+ ...contentLines.map((line) => line.length),
130
+ 0
131
+ )
132
+
133
+ // Determine box dimensions
134
+ let width = (props.width as number) || contentWidth + 2 || 6
135
+ let height = (props.height as number) || contentLines.length + 2 || 3
136
+
137
+ // Handle zero dimensions
138
+ if (width <= 0) width = 2
139
+ if (height <= 0) return ''
140
+
141
+ const innerWidth = Math.max(width - 2, 0)
142
+
143
+ // Build box
144
+ const lines: string[] = []
145
+
146
+ // Top border
147
+ lines.push(chars.topLeft + chars.horizontal.repeat(innerWidth) + chars.topRight)
148
+
149
+ // Content lines
150
+ const numContentLines = height - 2
151
+ for (let i = 0; i < numContentLines; i++) {
152
+ const lineContent = contentLines[i] || ''
153
+ const paddedContent = lineContent.padEnd(innerWidth, ' ')
154
+ lines.push(chars.vertical + paddedContent.slice(0, innerWidth) + chars.vertical)
155
+ }
156
+
157
+ // Bottom border
158
+ lines.push(chars.bottomLeft + chars.horizontal.repeat(innerWidth) + chars.bottomRight)
159
+
160
+ return lines.join('\n')
161
+ }
162
+
163
+ // ============================================================================
164
+ // Text Rendering
165
+ // ============================================================================
166
+
167
+ function renderText(node: UINode): string {
168
+ const content = (node.props?.content as string) || ''
169
+ // Unicode tier ignores style props (bold, color, etc.) - no ANSI codes
170
+ return content
171
+ }
172
+
173
+ // ============================================================================
174
+ // List Rendering
175
+ // ============================================================================
176
+
177
+ function renderList(node: UINode, context: RenderContext): string {
178
+ const style = (node.props?.style as string) || 'unordered'
179
+ const items = node.props?.items as Array<string | { text: string; checked?: boolean }> | undefined
180
+ const numbered = (node.props?.numbered as boolean) ?? false
181
+ const taskList = (node.props?.taskList as boolean) ?? false
182
+ const children = Array.isArray(node.children) ? node.children : []
183
+ const lines: string[] = []
184
+
185
+ // Handle items prop
186
+ if (items && items.length > 0) {
187
+ items.forEach((item, index) => {
188
+ let text: string
189
+ let marker: string
190
+
191
+ if (typeof item === 'string') {
192
+ text = item
193
+ if (style === 'ordered' || numbered) {
194
+ marker = `${index + 1}. `
195
+ } else {
196
+ marker = `${UNICODE.bullet} `
197
+ }
198
+ } else {
199
+ text = item.text ?? ''
200
+ if (taskList && 'checked' in item) {
201
+ marker = item.checked ? `${UNICODE.checkmark} ` : `${UNICODE.unchecked} `
202
+ } else if (style === 'ordered' || numbered) {
203
+ marker = `${index + 1}. `
204
+ } else {
205
+ marker = `${UNICODE.bullet} `
206
+ }
207
+ }
208
+
209
+ lines.push(marker + text)
210
+ })
211
+ }
212
+
213
+ // Handle list-item children
214
+ children.forEach((child: UINode, index: number) => {
215
+ if (child.type === 'list-item') {
216
+ const content = (child.props?.content as string) || ''
217
+ let marker: string
218
+
219
+ if (style === 'ordered' || numbered) {
220
+ marker = `${index + 1}. `
221
+ } else if (style === 'checklist' || taskList) {
222
+ const checked = child.props?.checked as boolean
223
+ marker = checked ? `${UNICODE.checkmark} ` : `${UNICODE.unchecked} `
224
+ } else {
225
+ // unordered
226
+ marker = `${UNICODE.bullet} `
227
+ }
228
+
229
+ lines.push(marker + content)
230
+
231
+ // Handle nested lists
232
+ if (child.children && Array.isArray(child.children) && child.children.length > 0) {
233
+ child.children.forEach((nestedChild: UINode) => {
234
+ if (nestedChild.type === 'list') {
235
+ const nestedLines = renderNestedList(nestedChild, context)
236
+ lines.push(...nestedLines.split('\n').map((line) => ' ' + line))
237
+ }
238
+ })
239
+ }
240
+ }
241
+ })
242
+
243
+ return lines.join('\n')
244
+ }
245
+
246
+ function renderNestedList(node: UINode, context: RenderContext, depth = 1): string {
247
+ const style = (node.props?.style as string) || 'unordered'
248
+ const children = Array.isArray(node.children) ? node.children : []
249
+ const lines: string[] = []
250
+
251
+ // Different bullet styles for different nesting levels
252
+ const bulletStyles = [UNICODE.bullet, UNICODE.hollowBullet, '▪', '▫', '▸']
253
+ const bullet = bulletStyles[Math.min(depth, bulletStyles.length - 1)]
254
+
255
+ children.forEach((child: UINode, index: number) => {
256
+ if (child.type === 'list-item') {
257
+ const content = (child.props?.content as string) || ''
258
+ let marker: string
259
+
260
+ if (style === 'ordered') {
261
+ marker = `${index + 1}. `
262
+ } else {
263
+ // Use different bullet for nested unordered lists
264
+ marker = `${bullet} `
265
+ }
266
+
267
+ lines.push(marker + content)
268
+
269
+ // Handle deeper nested lists
270
+ if (child.children && Array.isArray(child.children) && child.children.length > 0) {
271
+ child.children.forEach((nestedChild: UINode) => {
272
+ if (nestedChild.type === 'list') {
273
+ const nestedLines = renderNestedList(nestedChild, context, depth + 1)
274
+ lines.push(...nestedLines.split('\n').map((line) => ' ' + line))
275
+ }
276
+ })
277
+ }
278
+ }
279
+ })
280
+
281
+ return lines.join('\n')
282
+ }
283
+
284
+ // ============================================================================
285
+ // Progress Bar Rendering
286
+ // ============================================================================
287
+
288
+ function renderProgress(node: UINode): string {
289
+ const value = (node.props?.value as number) || 0
290
+ const max = (node.props?.max as number) || 100
291
+ const width = (node.props?.width as number) || 10
292
+
293
+ const percentage = Math.min(Math.max(value / max, 0), 1)
294
+ const filledBlocks = percentage * width
295
+
296
+ const fullBlocks = Math.floor(filledBlocks)
297
+ const hasHalf = filledBlocks - fullBlocks >= 0.5
298
+ const emptyBlocks = width - fullBlocks - (hasHalf ? 1 : 0)
299
+
300
+ let bar = UNICODE.progressFull.repeat(fullBlocks)
301
+ if (hasHalf) {
302
+ bar += UNICODE.progressHalf
303
+ }
304
+ bar += UNICODE.progressEmpty.repeat(Math.max(emptyBlocks, 0))
305
+
306
+ return bar
307
+ }
308
+
309
+ // ============================================================================
310
+ // Table Rendering
311
+ // ============================================================================
312
+
313
+ interface TableColumn {
314
+ key: string
315
+ header: string
316
+ width: number
317
+ }
318
+
319
+ function renderTable(node: UINode, context: RenderContext): string {
320
+ const props = node.props || {}
321
+ const columns = (props.columns as TableColumn[]) || []
322
+ // Support data from node.data (TDD tests) or props.data (legacy)
323
+ const nodeData = node.data as Record<string, unknown>[] | undefined
324
+ const propsData = props.data as Record<string, unknown>[] | undefined
325
+ const data = nodeData ?? propsData ?? []
326
+ const rowSeparators = props.rowSeparators as boolean
327
+
328
+ if (columns.length === 0) return ''
329
+
330
+ const lines: string[] = []
331
+
332
+ // Build widths array - calculate from content if not specified
333
+ const widths = columns.map((col) => {
334
+ if (col.width && col.width > 0) return col.width
335
+
336
+ // Calculate based on header and data
337
+ let maxWidth = col.header.length
338
+ for (const row of data) {
339
+ const val = row[col.key]
340
+ const str = val != null ? String(val) : ''
341
+ maxWidth = Math.max(maxWidth, str.length)
342
+ }
343
+ return maxWidth
344
+ })
345
+
346
+ // Top border
347
+ const topBorder =
348
+ UNICODE.topLeft +
349
+ widths.map((w) => UNICODE.horizontal.repeat(w)).join(UNICODE.teeTop) +
350
+ UNICODE.topRight
351
+ lines.push(topBorder)
352
+
353
+ // Header row
354
+ const headerRow =
355
+ UNICODE.vertical +
356
+ columns.map((col, i) => col.header.padEnd(widths[i]).slice(0, widths[i])).join(UNICODE.vertical) +
357
+ UNICODE.vertical
358
+ lines.push(headerRow)
359
+
360
+ // Header separator
361
+ const headerSep =
362
+ UNICODE.teeLeft +
363
+ widths.map((w) => UNICODE.horizontal.repeat(w)).join(UNICODE.crossJunction) +
364
+ UNICODE.teeRight
365
+ lines.push(headerSep)
366
+
367
+ // Data rows
368
+ data.forEach((row, rowIndex) => {
369
+ const dataRow =
370
+ UNICODE.vertical +
371
+ columns
372
+ .map((col, i) => {
373
+ const value = String(row[col.key] ?? '')
374
+ return value.padEnd(widths[i]).slice(0, widths[i])
375
+ })
376
+ .join(UNICODE.vertical) +
377
+ UNICODE.vertical
378
+ lines.push(dataRow)
379
+
380
+ // Row separator if enabled and not last row
381
+ if (rowSeparators && rowIndex < data.length - 1) {
382
+ const rowSep =
383
+ UNICODE.teeLeft +
384
+ widths.map((w) => UNICODE.horizontal.repeat(w)).join(UNICODE.crossJunction) +
385
+ UNICODE.teeRight
386
+ lines.push(rowSep)
387
+ }
388
+ })
389
+
390
+ // Bottom border
391
+ const bottomBorder =
392
+ UNICODE.bottomLeft +
393
+ widths.map((w) => UNICODE.horizontal.repeat(w)).join(UNICODE.teeBottom) +
394
+ UNICODE.bottomRight
395
+ lines.push(bottomBorder)
396
+
397
+ return lines.join('\n')
398
+ }
399
+
400
+ // ============================================================================
401
+ // Panel Rendering
402
+ // ============================================================================
403
+
404
+ function renderPanel(node: UINode, context: RenderContext): string {
405
+ const props = node.props || {}
406
+ const title = (props.title as string) || ''
407
+ const width = (props.width as number) || 40
408
+
409
+ // Render children
410
+ let content = ''
411
+ if (node.children && (typeof node.children === 'string' || node.children.length > 0)) {
412
+ if (typeof node.children === 'string') {
413
+ content = node.children
414
+ } else {
415
+ content = node.children
416
+ .map((child: UINode) => renderUnicode(child, { ...context, depth: context.depth + 1 }))
417
+ .join('\n')
418
+ }
419
+ }
420
+
421
+ const innerWidth = width - 2
422
+ const lines: string[] = []
423
+
424
+ // Top border
425
+ lines.push(UNICODE.topLeft + UNICODE.horizontal.repeat(innerWidth) + UNICODE.topRight)
426
+
427
+ // Title row
428
+ const paddedTitle = (' ' + title + ' ').padEnd(innerWidth).slice(0, innerWidth)
429
+ lines.push(UNICODE.vertical + paddedTitle + UNICODE.vertical)
430
+
431
+ // Title separator
432
+ lines.push(UNICODE.teeLeft + UNICODE.horizontal.repeat(innerWidth) + UNICODE.teeRight)
433
+
434
+ // Content rows
435
+ const contentLines = content ? content.split('\n') : []
436
+ contentLines.forEach((line) => {
437
+ const paddedLine = (' ' + line).padEnd(innerWidth).slice(0, innerWidth)
438
+ lines.push(UNICODE.vertical + paddedLine + UNICODE.vertical)
439
+ })
440
+
441
+ // Bottom border
442
+ lines.push(UNICODE.bottomLeft + UNICODE.horizontal.repeat(innerWidth) + UNICODE.bottomRight)
443
+
444
+ return lines.join('\n')
445
+ }
446
+
447
+ // ============================================================================
448
+ // Divider Rendering
449
+ // ============================================================================
450
+
451
+ function renderDivider(node: UINode, context: RenderContext): string {
452
+ const props = node.props || {}
453
+ const label = props.label as string | undefined
454
+ const width = (props.width as number) || context.width || 40
455
+
456
+ if (label) {
457
+ const labelWithSpaces = ` ${label} `
458
+ const remaining = width - labelWithSpaces.length
459
+ const leftSide = Math.floor(remaining / 2)
460
+ const rightSide = remaining - leftSide
461
+ return (
462
+ UNICODE.horizontal.repeat(Math.max(leftSide, 0)) +
463
+ labelWithSpaces +
464
+ UNICODE.horizontal.repeat(Math.max(rightSide, 0))
465
+ )
466
+ }
467
+
468
+ return UNICODE.horizontal.repeat(width)
469
+ }
470
+
471
+ // ============================================================================
472
+ // Spinner Rendering
473
+ // ============================================================================
474
+
475
+ function renderSpinner(node: UINode): string {
476
+ const props = node.props || {}
477
+ const frame = (props.frame as number) || 0
478
+ const label = props.label as string | undefined
479
+
480
+ const spinnerChar = SPINNER_FRAMES[frame % SPINNER_FRAMES.length]
481
+
482
+ if (label) {
483
+ return `${spinnerChar} ${label}`
484
+ }
485
+
486
+ return spinnerChar
487
+ }
488
+
489
+ // ============================================================================
490
+ // Badge Rendering
491
+ // ============================================================================
492
+
493
+ function renderBadge(node: UINode): string {
494
+ const props = node.props || {}
495
+ const content = (props.content as string) || ''
496
+ // Use unicode brackets
497
+ return `【${content}】`
498
+ }
499
+
500
+ // ============================================================================
501
+ // Tree Rendering
502
+ // ============================================================================
503
+
504
+ function renderTree(node: UINode, context: RenderContext): string {
505
+ const children = Array.isArray(node.children) ? node.children : []
506
+ const lines: string[] = []
507
+
508
+ children.forEach((child: UINode) => {
509
+ if (child.type === 'tree-item') {
510
+ const treeLines = renderTreeItem(child, '', true, context, true)
511
+ lines.push(...treeLines)
512
+ }
513
+ })
514
+
515
+ return lines.join('\n')
516
+ }
517
+
518
+ function renderTreeItem(
519
+ node: UINode,
520
+ prefix: string,
521
+ isLast: boolean,
522
+ context: RenderContext,
523
+ isRoot: boolean = false
524
+ ): string[] {
525
+ const label = (node.props?.label as string) || ''
526
+ const children = Array.isArray(node.children) ? node.children : []
527
+ const lines: string[] = []
528
+
529
+ // Root items have no connector prefix
530
+ if (isRoot) {
531
+ lines.push(label)
532
+ } else {
533
+ const connector = isLast ? UNICODE.bottomLeft : UNICODE.teeLeft
534
+ lines.push(prefix + connector + UNICODE.horizontal + UNICODE.horizontal + ' ' + label)
535
+ }
536
+
537
+ // Determine prefix for children
538
+ // For root items, children start with empty prefix (just the connector)
539
+ // For non-root items, extend the prefix with continuation lines
540
+ const childPrefix = isRoot ? '' : prefix + (isLast ? ' ' : UNICODE.vertical + ' ')
541
+
542
+ // Render children
543
+ children.forEach((child: UINode, index: number) => {
544
+ if (child.type === 'tree-item') {
545
+ const isChildLast = index === children.length - 1
546
+ const childLines = renderTreeItem(child, childPrefix, isChildLast, context, false)
547
+ lines.push(...childLines)
548
+ }
549
+ })
550
+
551
+ return lines
552
+ }
553
+
554
+ // ============================================================================
555
+ // Breadcrumb Rendering
556
+ // ============================================================================
557
+
558
+ function renderBreadcrumb(node: UINode): string {
559
+ const children = Array.isArray(node.children) ? node.children : []
560
+ const items: string[] = []
561
+
562
+ children.forEach((child: UINode) => {
563
+ if (child.type === 'breadcrumb-item') {
564
+ const label = (child.props?.label as string) || ''
565
+ items.push(label)
566
+ }
567
+ })
568
+
569
+ return items.join(` ${UNICODE.arrowRight} `)
570
+ }
571
+
572
+ // ============================================================================
573
+ // Tooltip Rendering
574
+ // ============================================================================
575
+
576
+ function renderTooltip(node: UINode): string {
577
+ const props = node.props || {}
578
+ const content = (props.content as string) || ''
579
+ const position = (props.position as string) || 'top'
580
+
581
+ let pointer = '▲' // default top
582
+ switch (position) {
583
+ case 'bottom':
584
+ pointer = '▼'
585
+ break
586
+ case 'left':
587
+ pointer = '◀'
588
+ break
589
+ case 'right':
590
+ pointer = '▶'
591
+ break
592
+ }
593
+
594
+ return `${pointer} ${content}`
595
+ }
596
+
597
+ // ============================================================================
598
+ // Input Rendering
599
+ // ============================================================================
600
+
601
+ function renderInput(node: UINode, context: RenderContext): string {
602
+ const props = node.props || {}
603
+ const label = (props.label as string) || ''
604
+ const value = (props.value as string) || ''
605
+
606
+ return `${label}: [${value}]`
607
+ }
608
+
609
+ // ============================================================================
610
+ // Button Rendering
611
+ // ============================================================================
612
+
613
+ function renderButton(node: UINode): string {
614
+ const props = node.props || {}
615
+ const label = (props.label as string) || ''
616
+
617
+ return `[ ${label} ]`
618
+ }
619
+
620
+ // ============================================================================
621
+ // Card Rendering
622
+ // ============================================================================
623
+
624
+ function renderCard(node: UINode, context: RenderContext): string {
625
+ const title = node.props?.title as string | undefined
626
+ const subtitle = node.props?.subtitle as string | undefined
627
+ const badge = node.props?.badge as { content: string; variant?: string } | undefined
628
+ const titleAction = node.props?.titleAction as { label: string; action?: string } | undefined
629
+ const pairs = node.props?.pairs as Array<{ key: string; value: unknown }> | undefined
630
+ const actions = node.props?.actions as Array<{ label: string; action?: string }> | undefined
631
+ const border = node.props?.border as string | undefined
632
+
633
+ const contentLines: string[] = []
634
+
635
+ // Title section
636
+ if (title) {
637
+ let titleLine = title
638
+ if (badge) {
639
+ titleLine += ` 【${badge.content}】`
640
+ }
641
+ if (titleAction) {
642
+ titleLine += ` ${UNICODE.arrowRight} ${titleAction.label}`
643
+ }
644
+ contentLines.push(titleLine)
645
+ }
646
+
647
+ if (subtitle) {
648
+ contentLines.push(subtitle)
649
+ }
650
+
651
+ // Key-value pairs
652
+ if (pairs && pairs.length > 0) {
653
+ if (contentLines.length > 0) contentLines.push('')
654
+ for (const pair of pairs) {
655
+ const val = pair.value != null ? String(pair.value) : ''
656
+ contentLines.push(`${pair.key}: ${val}`)
657
+ }
658
+ }
659
+
660
+ // Children content
661
+ if (node.children && (typeof node.children === 'string' || node.children.length > 0)) {
662
+ if (contentLines.length > 0) contentLines.push('')
663
+ let childContent: string
664
+ if (typeof node.children === 'string') {
665
+ childContent = node.children
666
+ } else {
667
+ childContent = node.children
668
+ .map((child: UINode) => renderUnicode(child, { ...context, depth: context.depth + 1 }))
669
+ .join('\n')
670
+ }
671
+ if (childContent) {
672
+ contentLines.push(childContent)
673
+ }
674
+ }
675
+
676
+ // Actions
677
+ if (actions && actions.length > 0) {
678
+ if (contentLines.length > 0) contentLines.push('')
679
+ const actionLabels = actions.map((a) => `[ ${a.label} ]`).join(' ')
680
+ contentLines.push(actionLabels)
681
+ }
682
+
683
+ // If border is specified, wrap content in a box
684
+ if (border) {
685
+ const chars = getUnicodeBoxChars(border)
686
+ // Calculate the width based on content
687
+ const contentWidth = Math.max(
688
+ ...contentLines.map((line) => line.length),
689
+ 0
690
+ )
691
+ const boxWidth = contentWidth + 4 // 2 for border chars, 2 for padding
692
+
693
+ const lines: string[] = []
694
+ const innerWidth = boxWidth - 2
695
+
696
+ // Top border
697
+ lines.push(chars.topLeft + chars.horizontal.repeat(innerWidth) + chars.topRight)
698
+
699
+ // Content lines with padding
700
+ for (const line of contentLines) {
701
+ const paddedLine = ' ' + line.padEnd(innerWidth - 2) + ' '
702
+ lines.push(chars.vertical + paddedLine.slice(0, innerWidth) + chars.vertical)
703
+ }
704
+
705
+ // If no content, add an empty line
706
+ if (contentLines.length === 0) {
707
+ lines.push(chars.vertical + ' '.repeat(innerWidth) + chars.vertical)
708
+ }
709
+
710
+ // Bottom border
711
+ lines.push(chars.bottomLeft + chars.horizontal.repeat(innerWidth) + chars.bottomRight)
712
+
713
+ return lines.join('\n')
714
+ }
715
+
716
+ return contentLines.join('\n')
717
+ }
718
+
719
+ // ============================================================================
720
+ // Metrics Rendering
721
+ // ============================================================================
722
+
723
+ // Sparkline characters (8 levels of height)
724
+ const SPARKLINE_CHARS = ['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█']
725
+
726
+ // Trend arrow characters
727
+ const TREND_ARROWS = {
728
+ up: '↑',
729
+ down: '↓',
730
+ neutral: '→',
731
+ }
732
+
733
+ function renderSparkline(data: number[]): string {
734
+ if (!data || data.length === 0) return ''
735
+ const min = Math.min(...data)
736
+ const max = Math.max(...data)
737
+ const range = max - min || 1 // Avoid division by zero
738
+ return data
739
+ .map((v) => {
740
+ const normalized = (v - min) / range
741
+ const charIndex = Math.min(Math.floor(normalized * 8), 7)
742
+ return SPARKLINE_CHARS[charIndex]
743
+ })
744
+ .join('')
745
+ }
746
+
747
+ function renderMetrics(node: UINode): string {
748
+ const metrics = node.props?.metrics as Array<{
749
+ label: string
750
+ value: unknown
751
+ format?: string
752
+ unit?: string
753
+ trend?: 'up' | 'down' | 'neutral'
754
+ trendValue?: number
755
+ sparkline?: number[]
756
+ }> | undefined
757
+
758
+ if (!metrics || metrics.length === 0) return ''
759
+
760
+ const lines: string[] = []
761
+ for (const m of metrics) {
762
+ const val = m.value != null ? String(m.value) : ''
763
+ let formatted = val
764
+ if (m.format === 'percentage' && !val.includes('%')) {
765
+ formatted = `${val}%`
766
+ }
767
+ if (m.unit) {
768
+ formatted = `${formatted} ${m.unit}`
769
+ }
770
+
771
+ // Add trend arrow if specified
772
+ if (m.trend && TREND_ARROWS[m.trend]) {
773
+ formatted = `${formatted} ${TREND_ARROWS[m.trend]}`
774
+ if (m.trendValue !== undefined) {
775
+ formatted = `${formatted} ${m.trendValue}%`
776
+ }
777
+ }
778
+
779
+ // Add sparkline if specified
780
+ if (m.sparkline && m.sparkline.length > 0) {
781
+ const sparkline = renderSparkline(m.sparkline)
782
+ formatted = `${formatted} ${sparkline}`
783
+ }
784
+
785
+ lines.push(`${m.label}: ${formatted}`)
786
+ }
787
+
788
+ return lines.join('\n')
789
+ }
790
+
791
+ function renderSingleMetric(node: UINode): string {
792
+ const label = node.props?.label as string | undefined
793
+ const value = node.props?.value
794
+ const format = node.props?.format as string | undefined
795
+ const unit = node.props?.unit as string | undefined
796
+
797
+ if (!label) return ''
798
+
799
+ const val = value != null ? String(value) : ''
800
+ let formatted = val
801
+ if (format === 'percentage' && !val.includes('%')) {
802
+ formatted = `${val}%`
803
+ }
804
+ if (unit) {
805
+ formatted = `${formatted} ${unit}`
806
+ }
807
+
808
+ return `${label}: ${formatted}`
809
+ }
810
+
811
+ // ============================================================================
812
+ // Main Render Function
813
+ // ============================================================================
814
+
815
+ /**
816
+ * Renders a UINode tree to Unicode box-drawing output.
817
+ *
818
+ * This renderer is part of the 6-tier Universal Terminal UI system.
819
+ * It outputs Unicode characters for beautiful terminal borders and
820
+ * symbols without using ANSI escape sequences for colors.
821
+ *
822
+ * @param node - The UINode tree to render
823
+ * @param context - Optional render context with width, height, theme
824
+ * @returns Unicode string ready for terminal output
825
+ *
826
+ * @example
827
+ * ```ts
828
+ * const node: UINode = {
829
+ * type: 'box',
830
+ * props: { border: 'single' },
831
+ * children: [{ type: 'text', props: { content: 'Hello' } }]
832
+ * }
833
+ *
834
+ * const output = renderUnicode(node)
835
+ * // ┌───────┐
836
+ * // │ Hello │
837
+ * // └───────┘
838
+ * ```
839
+ */
840
+ export function renderUnicode(node: UINode, context?: RenderContext): string {
841
+ const ctx = context || createDefaultContext()
842
+
843
+ switch (node.type) {
844
+ case 'text':
845
+ return renderText(node)
846
+
847
+ case 'box':
848
+ return renderBox(node, ctx)
849
+
850
+ case 'list':
851
+ return renderList(node, ctx)
852
+
853
+ case 'list-item':
854
+ // List items are handled by list
855
+ return (node.props?.content as string) || ''
856
+
857
+ case 'progress':
858
+ return renderProgress(node)
859
+
860
+ case 'table':
861
+ return renderTable(node, ctx)
862
+
863
+ case 'panel':
864
+ return renderPanel(node, ctx)
865
+
866
+ case 'divider':
867
+ return renderDivider(node, ctx)
868
+
869
+ case 'spinner':
870
+ return renderSpinner(node)
871
+
872
+ case 'badge':
873
+ return renderBadge(node)
874
+
875
+ case 'tree':
876
+ return renderTree(node, ctx)
877
+
878
+ case 'tree-item':
879
+ return (node.props?.label as string) || ''
880
+
881
+ case 'breadcrumb':
882
+ return renderBreadcrumb(node)
883
+
884
+ case 'breadcrumb-item':
885
+ return (node.props?.label as string) || ''
886
+
887
+ case 'tooltip':
888
+ return renderTooltip(node)
889
+
890
+ case 'input':
891
+ return renderInput(node, ctx)
892
+
893
+ case 'button':
894
+ return renderButton(node)
895
+
896
+ case 'card':
897
+ return renderCard(node, ctx)
898
+
899
+ case 'metrics':
900
+ return renderMetrics(node)
901
+
902
+ case 'metric':
903
+ return renderSingleMetric(node)
904
+
905
+ default:
906
+ // Unknown node type - return empty or render children
907
+ if (node.children && (typeof node.children === 'string' || node.children.length > 0)) {
908
+ if (typeof node.children === 'string') {
909
+ return node.children
910
+ }
911
+ return node.children
912
+ .map((child: UINode) => renderUnicode(child, { ...ctx, depth: ctx.depth + 1 }))
913
+ .join('\n')
914
+ }
915
+ return ''
916
+ }
917
+ }