@silvery/term 0.3.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 (92) hide show
  1. package/package.json +54 -0
  2. package/src/adapters/canvas-adapter.ts +356 -0
  3. package/src/adapters/dom-adapter.ts +452 -0
  4. package/src/adapters/flexily-zero-adapter.ts +368 -0
  5. package/src/adapters/terminal-adapter.ts +305 -0
  6. package/src/adapters/yoga-adapter.ts +370 -0
  7. package/src/ansi/ansi.ts +251 -0
  8. package/src/ansi/constants.ts +76 -0
  9. package/src/ansi/detection.ts +441 -0
  10. package/src/ansi/hyperlink.ts +38 -0
  11. package/src/ansi/index.ts +201 -0
  12. package/src/ansi/patch-console.ts +159 -0
  13. package/src/ansi/sgr-codes.ts +34 -0
  14. package/src/ansi/storybook.ts +209 -0
  15. package/src/ansi/term.ts +724 -0
  16. package/src/ansi/types.ts +202 -0
  17. package/src/ansi/underline.ts +156 -0
  18. package/src/ansi/utils.ts +65 -0
  19. package/src/ansi-sanitize.ts +509 -0
  20. package/src/app.ts +571 -0
  21. package/src/bound-term.ts +94 -0
  22. package/src/bracketed-paste.ts +75 -0
  23. package/src/browser-renderer.ts +174 -0
  24. package/src/buffer.ts +1984 -0
  25. package/src/clipboard.ts +74 -0
  26. package/src/cursor-query.ts +85 -0
  27. package/src/device-attrs.ts +228 -0
  28. package/src/devtools.ts +123 -0
  29. package/src/dom/index.ts +194 -0
  30. package/src/errors.ts +39 -0
  31. package/src/focus-reporting.ts +48 -0
  32. package/src/hit-registry-core.ts +228 -0
  33. package/src/hit-registry.ts +176 -0
  34. package/src/index.ts +458 -0
  35. package/src/input.ts +119 -0
  36. package/src/inspector.ts +155 -0
  37. package/src/kitty-detect.ts +95 -0
  38. package/src/kitty-manager.ts +160 -0
  39. package/src/layout-engine.ts +296 -0
  40. package/src/layout.ts +26 -0
  41. package/src/measurer.ts +74 -0
  42. package/src/mode-query.ts +106 -0
  43. package/src/mouse-events.ts +419 -0
  44. package/src/mouse.ts +83 -0
  45. package/src/non-tty.ts +223 -0
  46. package/src/osc-markers.ts +32 -0
  47. package/src/osc-palette.ts +169 -0
  48. package/src/output.ts +406 -0
  49. package/src/pane-manager.ts +248 -0
  50. package/src/pipeline/CLAUDE.md +587 -0
  51. package/src/pipeline/content-phase-adapter.ts +976 -0
  52. package/src/pipeline/content-phase.ts +1765 -0
  53. package/src/pipeline/helpers.ts +42 -0
  54. package/src/pipeline/index.ts +416 -0
  55. package/src/pipeline/layout-phase.ts +686 -0
  56. package/src/pipeline/measure-phase.ts +198 -0
  57. package/src/pipeline/measure-stats.ts +21 -0
  58. package/src/pipeline/output-phase.ts +2593 -0
  59. package/src/pipeline/render-box.ts +343 -0
  60. package/src/pipeline/render-helpers.ts +243 -0
  61. package/src/pipeline/render-text.ts +1255 -0
  62. package/src/pipeline/types.ts +161 -0
  63. package/src/pipeline.ts +29 -0
  64. package/src/pixel-size.ts +119 -0
  65. package/src/render-adapter.ts +179 -0
  66. package/src/renderer.ts +1330 -0
  67. package/src/runtime/create-app.tsx +1845 -0
  68. package/src/runtime/create-buffer.ts +18 -0
  69. package/src/runtime/create-runtime.ts +325 -0
  70. package/src/runtime/diff.ts +56 -0
  71. package/src/runtime/event-handlers.ts +254 -0
  72. package/src/runtime/index.ts +119 -0
  73. package/src/runtime/keys.ts +8 -0
  74. package/src/runtime/layout.ts +164 -0
  75. package/src/runtime/run.tsx +318 -0
  76. package/src/runtime/term-provider.ts +399 -0
  77. package/src/runtime/terminal-lifecycle.ts +246 -0
  78. package/src/runtime/tick.ts +219 -0
  79. package/src/runtime/types.ts +210 -0
  80. package/src/scheduler.ts +723 -0
  81. package/src/screenshot.ts +57 -0
  82. package/src/scroll-region.ts +69 -0
  83. package/src/scroll-utils.ts +97 -0
  84. package/src/term-def.ts +267 -0
  85. package/src/terminal-caps.ts +5 -0
  86. package/src/terminal-colors.ts +216 -0
  87. package/src/termtest.ts +224 -0
  88. package/src/text-sizing.ts +109 -0
  89. package/src/toolbelt/index.ts +72 -0
  90. package/src/unicode.ts +1763 -0
  91. package/src/xterm/index.ts +491 -0
  92. package/src/xterm/xterm-provider.ts +204 -0
@@ -0,0 +1,343 @@
1
+ /**
2
+ * Box Rendering - Functions for rendering box elements to the buffer.
3
+ *
4
+ * Contains:
5
+ * - Box rendering (renderBox)
6
+ * - Border rendering (renderBorder)
7
+ * - Scroll indicators (renderScrollIndicators)
8
+ */
9
+
10
+ import type { Color, Style, TerminalBuffer } from "../buffer"
11
+ import type { BoxProps, TeaNode, Rect } from "@silvery/tea/types"
12
+ import { getPadding } from "./helpers"
13
+ import { getBorderChars, getBorderSize, parseColor } from "./render-helpers"
14
+ import { renderTextLine } from "./render-text"
15
+ import type { NodeRenderState, PipelineContext } from "./types"
16
+
17
+ // ============================================================================
18
+ // Box Rendering
19
+ // ============================================================================
20
+
21
+ /**
22
+ * Render a Box node.
23
+ */
24
+ export function renderBox(
25
+ _node: TeaNode,
26
+ buffer: TerminalBuffer,
27
+ layout: Rect,
28
+ props: BoxProps,
29
+ nodeState: NodeRenderState,
30
+ skipBgFill = false,
31
+ inheritedBg?: Color | null,
32
+ ): void {
33
+ const { scrollOffset, clipBounds } = nodeState
34
+ const { x, width, height } = layout
35
+ // Apply scroll offset to y position
36
+ const y = layout.y - scrollOffset
37
+
38
+ // Skip if completely outside clip bounds
39
+ if (clipBounds) {
40
+ if (y + height <= clipBounds.top || y >= clipBounds.bottom) return
41
+ if (clipBounds.left !== undefined && clipBounds.right !== undefined) {
42
+ if (x + width <= clipBounds.left || x >= clipBounds.right) return
43
+ }
44
+ }
45
+
46
+ // Fill background if set.
47
+ // In incremental mode, skipBgFill=true when the box itself hasn't changed
48
+ // (only subtreeDirty). The cloned buffer already has the correct bg fill,
49
+ // and re-filling would destroy child pixels that won't be repainted.
50
+ if (props.backgroundColor && !skipBgFill) {
51
+ const bg = parseColor(props.backgroundColor)
52
+ // Clip background fill to bounds
53
+ if (clipBounds) {
54
+ const clippedY = Math.max(y, clipBounds.top)
55
+ const clippedHeight = Math.min(y + height, clipBounds.bottom) - clippedY
56
+ let clippedX = x
57
+ let clippedWidth = width
58
+ if (clipBounds.left !== undefined && clipBounds.right !== undefined) {
59
+ clippedX = Math.max(x, clipBounds.left)
60
+ clippedWidth = Math.min(x + width, clipBounds.right) - clippedX
61
+ }
62
+ if (clippedHeight > 0 && clippedWidth > 0) {
63
+ buffer.fill(clippedX, clippedY, clippedWidth, clippedHeight, { bg })
64
+ }
65
+ } else {
66
+ buffer.fill(x, y, width, height, { bg })
67
+ }
68
+ }
69
+
70
+ // Render border if set
71
+ if (props.borderStyle) {
72
+ renderBorder(buffer, x, y, width, height, props, clipBounds, inheritedBg)
73
+ }
74
+ }
75
+
76
+ // ============================================================================
77
+ // Border Rendering
78
+ // ============================================================================
79
+
80
+ /**
81
+ * Render a border around a box.
82
+ */
83
+ export function renderBorder(
84
+ buffer: TerminalBuffer,
85
+ x: number,
86
+ y: number,
87
+ width: number,
88
+ height: number,
89
+ props: BoxProps,
90
+ clipBounds?: { top: number; bottom: number; left?: number; right?: number },
91
+ inheritedBg?: Color | null,
92
+ ): void {
93
+ const chars = getBorderChars(props.borderStyle ?? "single")
94
+ const color = props.borderColor ? parseColor(props.borderColor) : null
95
+ // Preserve the box's background color on border cells. Falls back to
96
+ // inherited bg from the nearest ancestor with backgroundColor, ensuring
97
+ // border cells don't punch transparent holes through parent backgrounds.
98
+ const bg = props.backgroundColor ? parseColor(props.backgroundColor) : (inheritedBg ?? null)
99
+
100
+ const showTop = props.borderTop !== false
101
+ const showBottom = props.borderBottom !== false
102
+ const showLeft = props.borderLeft !== false
103
+ const showRight = props.borderRight !== false
104
+
105
+ // Helper to check if a row is visible within clip bounds
106
+ const isRowVisible = (row: number): boolean => {
107
+ if (!clipBounds) return row >= 0 && row < buffer.height
108
+ return row >= clipBounds.top && row < clipBounds.bottom && row < buffer.height
109
+ }
110
+
111
+ // Helper to check if a column is visible within clip bounds
112
+ const isColVisible = (col: number): boolean => {
113
+ if (clipBounds?.left === undefined || clipBounds.right === undefined) return col >= 0 && col < buffer.width
114
+ return col >= clipBounds.left && col < clipBounds.right && col < buffer.width
115
+ }
116
+
117
+ // Top border
118
+ if (showTop && isRowVisible(y)) {
119
+ if (showLeft && isColVisible(x)) buffer.setCell(x, y, { char: chars.topLeft, fg: color, bg })
120
+ const hStart = showLeft ? x + 1 : x
121
+ const hEnd = showRight ? x + width - 1 : x + width
122
+ for (let col = hStart; col < hEnd && col < buffer.width; col++) {
123
+ if (isColVisible(col)) buffer.setCell(col, y, { char: chars.horizontal, fg: color, bg })
124
+ }
125
+ if (showRight && x + width - 1 < buffer.width && isColVisible(x + width - 1)) {
126
+ buffer.setCell(x + width - 1, y, { char: chars.topRight, fg: color, bg })
127
+ }
128
+ }
129
+
130
+ // Side borders — extend range when top/bottom borders are hidden
131
+ const rightVertical = chars.rightVertical ?? chars.vertical
132
+ const sideStart = showTop ? y + 1 : y
133
+ const sideEnd = showBottom ? y + height - 1 : y + height
134
+ for (let row = sideStart; row < sideEnd; row++) {
135
+ if (!isRowVisible(row)) continue
136
+ if (showLeft && isColVisible(x)) buffer.setCell(x, row, { char: chars.vertical, fg: color, bg })
137
+ if (showRight && x + width - 1 < buffer.width && isColVisible(x + width - 1)) {
138
+ buffer.setCell(x + width - 1, row, { char: rightVertical, fg: color, bg })
139
+ }
140
+ }
141
+
142
+ // Bottom border
143
+ const bottomHorizontal = chars.bottomHorizontal ?? chars.horizontal
144
+ const bottomY = y + height - 1
145
+ if (showBottom && isRowVisible(bottomY)) {
146
+ if (showLeft && isColVisible(x)) {
147
+ buffer.setCell(x, bottomY, { char: chars.bottomLeft, fg: color, bg })
148
+ }
149
+ const bStart = showLeft ? x + 1 : x
150
+ const bEnd = showRight ? x + width - 1 : x + width
151
+ for (let col = bStart; col < bEnd && col < buffer.width; col++) {
152
+ if (isColVisible(col)) buffer.setCell(col, bottomY, { char: bottomHorizontal, fg: color, bg })
153
+ }
154
+ if (showRight && x + width - 1 < buffer.width && isColVisible(x + width - 1)) {
155
+ buffer.setCell(x + width - 1, bottomY, {
156
+ char: chars.bottomRight,
157
+ fg: color,
158
+ bg,
159
+ })
160
+ }
161
+ }
162
+ }
163
+
164
+ // ============================================================================
165
+ // Outline Rendering
166
+ // ============================================================================
167
+
168
+ /**
169
+ * Render an outline around a box.
170
+ *
171
+ * Unlike borders, outlines do NOT affect layout dimensions. They draw border
172
+ * characters that OVERLAP the content area at the node's screen rect edges.
173
+ * This is the CSS `outline` equivalent for terminal UI.
174
+ */
175
+ export function renderOutline(
176
+ buffer: TerminalBuffer,
177
+ x: number,
178
+ y: number,
179
+ width: number,
180
+ height: number,
181
+ props: BoxProps,
182
+ clipBounds?: { top: number; bottom: number; left?: number; right?: number },
183
+ inheritedBg?: Color | null,
184
+ ): void {
185
+ const chars = getBorderChars(props.outlineStyle ?? "single")
186
+ const color = props.outlineColor ? parseColor(props.outlineColor) : null
187
+ const bg = props.backgroundColor ? parseColor(props.backgroundColor) : (inheritedBg ?? null)
188
+ const attrs = props.outlineDimColor ? { dim: true } : {}
189
+
190
+ // Helper to check if a row is visible within clip bounds
191
+ const isRowVisible = (row: number): boolean => {
192
+ if (!clipBounds) return row >= 0 && row < buffer.height
193
+ return row >= clipBounds.top && row < clipBounds.bottom && row < buffer.height
194
+ }
195
+
196
+ // Helper to check if a column is visible within clip bounds
197
+ const isColVisible = (col: number): boolean => {
198
+ if (clipBounds?.left === undefined || clipBounds.right === undefined) return col >= 0 && col < buffer.width
199
+ return col >= clipBounds.left && col < clipBounds.right && col < buffer.width
200
+ }
201
+
202
+ const showTop = props.outlineTop !== false
203
+ const showBottom = props.outlineBottom !== false
204
+ const showLeft = props.outlineLeft !== false
205
+ const showRight = props.outlineRight !== false
206
+
207
+ // Top border
208
+ if (showTop && isRowVisible(y)) {
209
+ if (showLeft && isColVisible(x)) buffer.setCell(x, y, { char: chars.topLeft, fg: color, bg, attrs })
210
+ for (let col = x + 1; col < x + width - 1 && col < buffer.width; col++) {
211
+ if (isColVisible(col)) buffer.setCell(col, y, { char: chars.horizontal, fg: color, bg, attrs })
212
+ }
213
+ if (showRight && x + width - 1 < buffer.width && isColVisible(x + width - 1)) {
214
+ buffer.setCell(x + width - 1, y, { char: chars.topRight, fg: color, bg, attrs })
215
+ }
216
+ }
217
+
218
+ // Side borders — extend range when top/bottom are hidden
219
+ const outlineRightVertical = chars.rightVertical ?? chars.vertical
220
+ const sideStart = showTop ? y + 1 : y
221
+ const sideEnd = showBottom ? y + height - 1 : y + height
222
+ for (let row = sideStart; row < sideEnd; row++) {
223
+ if (!isRowVisible(row)) continue
224
+ if (showLeft && isColVisible(x)) buffer.setCell(x, row, { char: chars.vertical, fg: color, bg, attrs })
225
+ if (showRight && x + width - 1 < buffer.width && isColVisible(x + width - 1)) {
226
+ buffer.setCell(x + width - 1, row, { char: outlineRightVertical, fg: color, bg, attrs })
227
+ }
228
+ }
229
+
230
+ // Bottom border
231
+ const outlineBottomHorizontal = chars.bottomHorizontal ?? chars.horizontal
232
+ const bottomY = y + height - 1
233
+ if (showBottom && isRowVisible(bottomY)) {
234
+ if (showLeft && isColVisible(x)) {
235
+ buffer.setCell(x, bottomY, { char: chars.bottomLeft, fg: color, bg, attrs })
236
+ }
237
+ for (let col = x + 1; col < x + width - 1 && col < buffer.width; col++) {
238
+ if (isColVisible(col)) buffer.setCell(col, bottomY, { char: outlineBottomHorizontal, fg: color, bg, attrs })
239
+ }
240
+ if (showRight && x + width - 1 < buffer.width && isColVisible(x + width - 1)) {
241
+ buffer.setCell(x + width - 1, bottomY, {
242
+ char: chars.bottomRight,
243
+ fg: color,
244
+ bg,
245
+ attrs,
246
+ })
247
+ }
248
+ }
249
+ }
250
+
251
+ // ============================================================================
252
+ // Scroll Indicators
253
+ // ============================================================================
254
+
255
+ /**
256
+ * Render scroll indicators showing hidden items above/below viewport.
257
+ *
258
+ * Two rendering modes:
259
+ * 1. Bordered containers: Indicators appear on the border (e.g., "───▲42───")
260
+ * 2. Borderless containers with overflowIndicator: Indicators appear directly
261
+ * after the last visible child (not at the viewport bottom)
262
+ *
263
+ * Uses ▲N for items hidden above, ▼N for items hidden below.
264
+ */
265
+ export function renderScrollIndicators(
266
+ _node: TeaNode,
267
+ buffer: TerminalBuffer,
268
+ layout: Rect,
269
+ props: BoxProps,
270
+ ss: NonNullable<TeaNode["scrollState"]>,
271
+ ctx?: PipelineContext,
272
+ ): void {
273
+ const border = props.borderStyle ? getBorderSize(props) : { top: 0, bottom: 0, left: 0, right: 0 }
274
+
275
+ // Inverse bar style: white text on dark background
276
+ const indicatorStyle: Style = {
277
+ fg: 15, // Bright white
278
+ bg: 8, // Dark gray
279
+ attrs: {},
280
+ }
281
+
282
+ // Determine if we should show indicators for borderless containers
283
+ const showBorderless = props.overflowIndicator === true
284
+
285
+ // Top indicator
286
+ if (ss.hiddenAbove > 0) {
287
+ const indicator = `\u25b2${ss.hiddenAbove}`
288
+
289
+ if (border.top > 0) {
290
+ // Bordered: render centered inverse bar on top border line
291
+ const contentWidth = layout.width - border.left - border.right
292
+ const bar = padCenter(indicator, contentWidth)
293
+ const x = layout.x + border.left
294
+ const y = layout.y
295
+ const maxCol = x + contentWidth
296
+ renderTextLine(buffer, x, y, bar, indicatorStyle, maxCol, undefined, ctx)
297
+ } else if (showBorderless) {
298
+ // Borderless: render centered inverse bar on first content row
299
+ const padding = getPadding(props)
300
+ const contentWidth = layout.width - padding.left - padding.right
301
+ const bar = padCenter(indicator, contentWidth)
302
+ const x = layout.x + padding.left
303
+ const y = layout.y + padding.top
304
+ const maxCol = x + contentWidth
305
+ renderTextLine(buffer, x, y, bar, indicatorStyle, maxCol, undefined, ctx)
306
+ }
307
+ }
308
+
309
+ // Bottom indicator
310
+ if (ss.hiddenBelow > 0) {
311
+ const indicator = `\u25bc${ss.hiddenBelow}`
312
+
313
+ if (border.bottom > 0) {
314
+ // Bordered: render centered inverse bar on bottom border line
315
+ const contentWidth = layout.width - border.left - border.right
316
+ const bar = padCenter(indicator, contentWidth)
317
+ const x = layout.x + border.left
318
+ const y = layout.y + layout.height - 1
319
+ const maxCol = x + contentWidth
320
+ renderTextLine(buffer, x, y, bar, indicatorStyle, maxCol, undefined, ctx)
321
+ } else if (showBorderless) {
322
+ // Borderless: render indicator flush to viewport bottom
323
+ const padding = getPadding(props)
324
+ const contentWidth = layout.width - padding.left - padding.right
325
+ const bar = padCenter(indicator, contentWidth)
326
+ const x = layout.x + padding.left
327
+ const y = layout.y + layout.height - padding.bottom - 1
328
+ const maxCol = x + contentWidth
329
+ renderTextLine(buffer, x, y, bar, indicatorStyle, maxCol, undefined, ctx)
330
+ }
331
+ }
332
+ }
333
+
334
+ /** Center text within a fixed width, padding with spaces on both sides.
335
+ * Truncates from the right if text exceeds available width. */
336
+ function padCenter(text: string, width: number): string {
337
+ if (width <= 0) return ""
338
+ if (text.length > width) return text.slice(0, width)
339
+ if (text.length === width) return text
340
+ const leftPad = Math.floor((width - text.length) / 2)
341
+ const rightPad = width - text.length - leftPad
342
+ return " ".repeat(leftPad) + text + " ".repeat(rightPad)
343
+ }
@@ -0,0 +1,243 @@
1
+ /**
2
+ * Render Helpers - Pure utility functions for content rendering.
3
+ *
4
+ * Contains:
5
+ * - Color parsing (parseColor)
6
+ * - Border character definitions (getBorderChars)
7
+ * - Style extraction (getTextStyle)
8
+ * - Text width utilities (getTextWidth)
9
+ *
10
+ * Re-exports layout helpers from helpers.ts:
11
+ * - getPadding, getBorderSize
12
+ */
13
+
14
+ import { DEFAULT_BG, type Color, type Style, type UnderlineStyle } from "../buffer"
15
+ import { getActiveTheme } from "@silvery/theme/state"
16
+ import { resolveThemeColor } from "@silvery/theme/resolve"
17
+ import type { BoxProps, TextProps } from "@silvery/tea/types"
18
+ import { displayWidthAnsi } from "../unicode"
19
+ import type { BorderChars, PipelineContext } from "./types"
20
+
21
+ // Re-export shared layout helpers
22
+ export { getBorderSize, getPadding } from "./helpers"
23
+
24
+ // ============================================================================
25
+ // Color Parsing
26
+ // ============================================================================
27
+
28
+ // Named colors map to 256-color indices (hoisted to module scope to avoid per-call allocation)
29
+ const namedColors: Record<string, number> = {
30
+ black: 0,
31
+ red: 1,
32
+ green: 2,
33
+ yellow: 3,
34
+ blue: 4,
35
+ magenta: 5,
36
+ cyan: 6,
37
+ white: 7,
38
+ gray: 8,
39
+ grey: 8,
40
+ blackBright: 8,
41
+ redBright: 9,
42
+ greenBright: 10,
43
+ yellowBright: 11,
44
+ blueBright: 12,
45
+ magentaBright: 13,
46
+ cyanBright: 14,
47
+ whiteBright: 15,
48
+ }
49
+
50
+ /**
51
+ * Parse color string to Color type.
52
+ * Supports: $token (theme), named colors, hex (#rgb, #rrggbb), rgb(r,g,b)
53
+ */
54
+ export function parseColor(color: string): Color {
55
+ // Special token: terminal's default background (SGR 49)
56
+ if (color === "$default") return DEFAULT_BG
57
+
58
+ // Resolve $token colors against the active theme
59
+ if (color.startsWith("$")) {
60
+ const resolved = resolveThemeColor(color, getActiveTheme())
61
+ if (resolved && resolved !== color) return parseColor(resolved)
62
+ return null
63
+ }
64
+
65
+ if (color in namedColors) {
66
+ return namedColors[color as keyof typeof namedColors]!
67
+ }
68
+
69
+ // Hex color
70
+ if (color.startsWith("#")) {
71
+ const hex = color.slice(1)
72
+ if (hex.length === 3) {
73
+ const r = Number.parseInt(hex[0]! + hex[0]!, 16)
74
+ const g = Number.parseInt(hex[1]! + hex[1]!, 16)
75
+ const b = Number.parseInt(hex[2]! + hex[2]!, 16)
76
+ return { r, g, b }
77
+ }
78
+ if (hex.length === 6) {
79
+ const r = Number.parseInt(hex.slice(0, 2), 16)
80
+ const g = Number.parseInt(hex.slice(2, 4), 16)
81
+ const b = Number.parseInt(hex.slice(4, 6), 16)
82
+ return { r, g, b }
83
+ }
84
+ }
85
+
86
+ // rgb(r,g,b)
87
+ const rgbMatch = color.match(/^rgb\s*\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*\)$/i)
88
+ if (rgbMatch) {
89
+ return {
90
+ r: Number.parseInt(rgbMatch[1]!, 10),
91
+ g: Number.parseInt(rgbMatch[2]!, 10),
92
+ b: Number.parseInt(rgbMatch[3]!, 10),
93
+ }
94
+ }
95
+
96
+ // ansi256(N) — 256-color palette index (0-255)
97
+ const ansi256Match = color.match(/^ansi256\s*\(\s*(\d+)\s*\)$/i)
98
+ if (ansi256Match) {
99
+ return Number.parseInt(ansi256Match[1]!, 10)
100
+ }
101
+
102
+ return null
103
+ }
104
+
105
+ // ============================================================================
106
+ // Border Characters
107
+ // ============================================================================
108
+
109
+ /**
110
+ * Border character sets by style. Hoisted to module scope to avoid
111
+ * re-allocating 7 objects on every call.
112
+ */
113
+ const borders: Record<NonNullable<BoxProps["borderStyle"]>, BorderChars> = {
114
+ single: {
115
+ topLeft: "\u250c",
116
+ topRight: "\u2510",
117
+ bottomLeft: "\u2514",
118
+ bottomRight: "\u2518",
119
+ horizontal: "\u2500",
120
+ vertical: "\u2502",
121
+ },
122
+ double: {
123
+ topLeft: "\u2554",
124
+ topRight: "\u2557",
125
+ bottomLeft: "\u255a",
126
+ bottomRight: "\u255d",
127
+ horizontal: "\u2550",
128
+ vertical: "\u2551",
129
+ },
130
+ round: {
131
+ topLeft: "\u256d",
132
+ topRight: "\u256e",
133
+ bottomLeft: "\u2570",
134
+ bottomRight: "\u256f",
135
+ horizontal: "\u2500",
136
+ vertical: "\u2502",
137
+ },
138
+ bold: {
139
+ topLeft: "\u250f",
140
+ topRight: "\u2513",
141
+ bottomLeft: "\u2517",
142
+ bottomRight: "\u251b",
143
+ horizontal: "\u2501",
144
+ vertical: "\u2503",
145
+ },
146
+ singleDouble: {
147
+ topLeft: "\u2553",
148
+ topRight: "\u2556",
149
+ bottomLeft: "\u2559",
150
+ bottomRight: "\u255c",
151
+ horizontal: "\u2500",
152
+ vertical: "\u2551",
153
+ },
154
+ doubleSingle: {
155
+ topLeft: "\u2552",
156
+ topRight: "\u2555",
157
+ bottomLeft: "\u2558",
158
+ bottomRight: "\u255b",
159
+ horizontal: "\u2550",
160
+ vertical: "\u2502",
161
+ },
162
+ classic: {
163
+ topLeft: "+",
164
+ topRight: "+",
165
+ bottomLeft: "+",
166
+ bottomRight: "+",
167
+ horizontal: "-",
168
+ vertical: "|",
169
+ },
170
+ }
171
+
172
+ /**
173
+ * Get border characters for a style.
174
+ */
175
+ export function getBorderChars(style: BoxProps["borderStyle"]): BorderChars {
176
+ if (style && typeof style === "object") {
177
+ // Custom border object (Ink compat): map Ink's top/bottom/left/right to
178
+ // silvery's horizontal/vertical format. Supports distinct chars per side.
179
+ const obj = style as Record<string, string>
180
+ const topHorizontal = obj.top ?? obj.horizontal ?? "-"
181
+ const leftVertical = obj.left ?? obj.vertical ?? "|"
182
+ return {
183
+ topLeft: obj.topLeft ?? "+",
184
+ topRight: obj.topRight ?? "+",
185
+ bottomLeft: obj.bottomLeft ?? "+",
186
+ bottomRight: obj.bottomRight ?? "+",
187
+ horizontal: topHorizontal,
188
+ vertical: leftVertical,
189
+ bottomHorizontal: obj.bottom && obj.bottom !== topHorizontal ? obj.bottom : undefined,
190
+ rightVertical: obj.right && obj.right !== leftVertical ? obj.right : undefined,
191
+ }
192
+ }
193
+ return borders[style ?? "single"]
194
+ }
195
+
196
+ // ============================================================================
197
+ // Style Extraction
198
+ // ============================================================================
199
+
200
+ /**
201
+ * Get text style from props.
202
+ */
203
+ export function getTextStyle(props: TextProps): Style {
204
+ // Determine underline style: underlineStyle takes precedence over underline boolean
205
+ let underlineStyle: UnderlineStyle | undefined
206
+ if (props.underlineStyle !== undefined) {
207
+ underlineStyle = props.underlineStyle
208
+ } else if (props.underline) {
209
+ underlineStyle = "single"
210
+ }
211
+
212
+ return {
213
+ fg: props.color ? parseColor(props.color) : null,
214
+ bg: props.backgroundColor ? parseColor(props.backgroundColor) : null,
215
+ underlineColor: props.underlineColor ? parseColor(props.underlineColor) : null,
216
+ attrs: {
217
+ bold: props.bold,
218
+ dim: props.dim || props.dimColor, // dimColor is Ink compatibility alias
219
+ italic: props.italic,
220
+ underline: props.underline || !!underlineStyle,
221
+ underlineStyle,
222
+ strikethrough: props.strikethrough,
223
+ inverse: props.inverse,
224
+ },
225
+ }
226
+ }
227
+
228
+ // ============================================================================
229
+ // Text Width Utilities
230
+ // ============================================================================
231
+
232
+ /**
233
+ * Get text display width (accounting for wide characters and ANSI codes).
234
+ * Uses ANSI-aware width calculation to handle styled text.
235
+ *
236
+ * When a PipelineContext is provided, uses the context's measurer for
237
+ * terminal-capability-aware width calculation. Falls back to the module-level
238
+ * displayWidthAnsi (which reads the scoped measurer or default).
239
+ */
240
+ export function getTextWidth(text: string, ctx?: PipelineContext): number {
241
+ if (ctx) return ctx.measurer.displayWidthAnsi(text)
242
+ return displayWidthAnsi(text)
243
+ }