@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,1255 @@
1
+ /**
2
+ * Text Rendering - Functions for rendering text content to the buffer.
3
+ *
4
+ * Contains:
5
+ * - ANSI text line rendering (renderAnsiTextLine)
6
+ * - Plain text line rendering (renderTextLine)
7
+ * - Text formatting (formatTextLines)
8
+ * - Text truncation (truncateText)
9
+ * - Text content collection (collectTextContent)
10
+ */
11
+
12
+ import {
13
+ type CellAttrs,
14
+ type Color,
15
+ type Style,
16
+ type TerminalBuffer,
17
+ type UnderlineStyle,
18
+ createMutableCell,
19
+ } from "../buffer"
20
+ import type { TeaNode, TextProps } from "@silvery/tea/types"
21
+ import {
22
+ type StyledSegment,
23
+ ensureEmojiPresentation,
24
+ graphemeWidth,
25
+ hasAnsi,
26
+ parseAnsiText,
27
+ sliceByWidth,
28
+ sliceByWidthFromEnd,
29
+ splitGraphemes,
30
+ wrapText,
31
+ } from "../unicode"
32
+ import { getTextStyle, getTextWidth, parseColor } from "./render-helpers"
33
+ import type { BgConflictMode, NodeRenderState, PipelineContext } from "./types"
34
+
35
+ // ============================================================================
36
+ // Background Conflict Detection
37
+ // ============================================================================
38
+
39
+ /** Cached bg conflict mode. Read from env once at module load. */
40
+ let bgConflictMode: BgConflictMode = (() => {
41
+ const env = process.env.SILVERY_BG_CONFLICT?.toLowerCase()
42
+ if (env === "ignore" || env === "warn" || env === "throw") return env
43
+ return "throw" // default - fail fast on programming errors
44
+ })()
45
+
46
+ /**
47
+ * Get the current background conflict detection mode.
48
+ */
49
+ function getBgConflictMode(): BgConflictMode {
50
+ return bgConflictMode
51
+ }
52
+
53
+ /**
54
+ * Set the background conflict detection mode. For tests.
55
+ */
56
+ export function setBgConflictMode(mode: BgConflictMode): void {
57
+ bgConflictMode = mode
58
+ }
59
+
60
+ // Track warned conflicts to avoid spam (only used in 'warn' mode)
61
+ const warnedBgConflicts = new Set<string>()
62
+
63
+ /**
64
+ * Clear the background conflict warning cache.
65
+ * Call this at the start of each render cycle to:
66
+ * - Prevent memory leaks in long-running apps
67
+ * - Allow warnings to repeat after user fixes issues
68
+ */
69
+ export function clearBgConflictWarnings(): void {
70
+ warnedBgConflicts.clear()
71
+ }
72
+
73
+ // ============================================================================
74
+ // Text Content Collection
75
+ // ============================================================================
76
+
77
+ /**
78
+ * Style context for nested Text elements.
79
+ * Tracks cumulative styles through the tree to enable proper push/pop behavior.
80
+ */
81
+ interface StyleContext {
82
+ color?: string
83
+ backgroundColor?: string
84
+ bold?: boolean
85
+ dim?: boolean
86
+ italic?: boolean
87
+ underline?: boolean
88
+ inverse?: boolean
89
+ strikethrough?: boolean
90
+ }
91
+
92
+ /**
93
+ * Build ANSI escape sequence for a style context.
94
+ *
95
+ * Note: backgroundColor is intentionally NOT embedded as ANSI codes.
96
+ * Background color is handled at the buffer level (via BgSegment tracking)
97
+ * to prevent bg bleed across wrapped text lines. See km-silvery.bg-bleed.
98
+ */
99
+ function styleToAnsi(style: StyleContext): string {
100
+ const codes: number[] = []
101
+
102
+ // Foreground color - use parseColor directly instead of roundtripping through getTextStyle
103
+ if (style.color) {
104
+ const color = parseColor(style.color)
105
+ if (color !== null) {
106
+ if (typeof color === "number") {
107
+ codes.push(38, 5, color)
108
+ } else {
109
+ codes.push(38, 2, color.r, color.g, color.b)
110
+ }
111
+ }
112
+ }
113
+
114
+ // backgroundColor is NOT embedded here - it is tracked separately via
115
+ // BgSegment and applied at the buffer level in renderText(). This prevents
116
+ // bg color from bleeding across wrapped lines. See collectTextWithBg().
117
+
118
+ // Attributes
119
+ if (style.bold) codes.push(1)
120
+ if (style.dim) codes.push(2)
121
+ if (style.italic) codes.push(3)
122
+ if (style.underline) codes.push(4)
123
+ if (style.inverse) codes.push(7)
124
+ if (style.strikethrough) codes.push(9)
125
+
126
+ if (codes.length === 0) {
127
+ return ""
128
+ }
129
+
130
+ return `\x1b[${codes.join(";")}m`
131
+ }
132
+
133
+ /**
134
+ * Merge child props into parent context.
135
+ * Child values override parent values when specified.
136
+ */
137
+ function mergeStyleContext(parent: StyleContext, childProps: TextProps): StyleContext {
138
+ return {
139
+ color: childProps.color ?? parent.color,
140
+ backgroundColor: childProps.backgroundColor ?? parent.backgroundColor,
141
+ bold: childProps.bold ?? parent.bold,
142
+ dim: childProps.dim ?? childProps.dimColor ?? parent.dim,
143
+ italic: childProps.italic ?? parent.italic,
144
+ underline: childProps.underline ?? parent.underline,
145
+ inverse: childProps.inverse ?? parent.inverse,
146
+ strikethrough: childProps.strikethrough ?? parent.strikethrough,
147
+ }
148
+ }
149
+
150
+ /**
151
+ * Apply text styles as ANSI escape codes with proper push/pop behavior.
152
+ * After the child text, restores the parent context's styles.
153
+ *
154
+ * @param text - The text content to wrap
155
+ * @param childStyle - The merged style for this child (child overrides parent)
156
+ * @param parentStyle - The parent's style context to restore after
157
+ */
158
+ function applyTextStyleAnsi(text: string, childStyle: StyleContext, parentStyle: StyleContext): string {
159
+ if (!text) {
160
+ return text
161
+ }
162
+
163
+ const childAnsi = styleToAnsi(childStyle)
164
+ const parentAnsi = styleToAnsi(parentStyle)
165
+
166
+ // If child has no style changes, just return text
167
+ if (!childAnsi) {
168
+ return text
169
+ }
170
+
171
+ // Apply child style, then reset and re-apply parent style
172
+ // We use \x1b[0m to reset, then re-apply parent styles
173
+ return `${childAnsi}${text}\x1b[0m${parentAnsi}`
174
+ }
175
+
176
+ /**
177
+ * Recursively collect text content from a node and its children.
178
+ * Handles both raw text nodes (textContent set directly) and
179
+ * Text component wrappers (text in children).
180
+ *
181
+ * For nested Text nodes with style props (color, bold, etc.),
182
+ * applies ANSI codes so the styles are preserved when rendered.
183
+ * Uses a style stack to properly restore parent styles after nested elements.
184
+ *
185
+ * @param node - The node to collect text from
186
+ * @param parentContext - The inherited style context from parent (used for restoration)
187
+ */
188
+ export function collectTextContent(node: TeaNode, parentContext: StyleContext = {}): string {
189
+ // If this node has direct text content, return it
190
+ if (node.textContent !== undefined) {
191
+ return node.textContent
192
+ }
193
+
194
+ // Otherwise, collect from children
195
+ // Matching Ink's squashTextNodes: apply internal_transform to the full text
196
+ // of each child node (not per-line), using the child index as the index argument.
197
+ let result = ""
198
+ for (let i = 0; i < node.children.length; i++) {
199
+ const child = node.children[i]!
200
+ // If child is a Text node (virtual/nested) with style props, apply ANSI codes
201
+ if (child.type === "silvery-text" && child.props && !child.layoutNode) {
202
+ const childProps = child.props as TextProps
203
+ // Merge child props with parent context to get effective child style
204
+ const childContext = mergeStyleContext(parentContext, childProps)
205
+ // Recursively collect with child's context
206
+ let childContent = collectTextContent(child, childContext)
207
+ // Apply internal_transform from virtual text nodes (nested Transform components).
208
+ // Matches Ink's squashTextNodes: transform is applied to the full concatenated
209
+ // text of the child, with index = child position in parent's children array.
210
+ const childTransform = (childProps as any).internal_transform
211
+ if (childTransform && childContent.length > 0) {
212
+ childContent = childTransform(childContent, i)
213
+ }
214
+ // Apply styles with proper push/pop (child style, then restore parent)
215
+ result += applyTextStyleAnsi(childContent, childContext, parentContext)
216
+ } else {
217
+ // Not a styled Text node, just collect recursively
218
+ result += collectTextContent(child, parentContext)
219
+ }
220
+ }
221
+ return result
222
+ }
223
+
224
+ // ============================================================================
225
+ // Background Segment Tracking
226
+ // ============================================================================
227
+
228
+ /**
229
+ * A background color segment in collected text.
230
+ * Tracks which character range has which background color,
231
+ * independent of ANSI codes. Used to apply bg at the buffer level
232
+ * after text wrapping, preventing bg bleed across wrapped lines.
233
+ */
234
+ interface BgSegment {
235
+ /** Start character offset in the collected text (inclusive) */
236
+ start: number
237
+ /** End character offset in the collected text (exclusive) */
238
+ end: number
239
+ /** Background color to apply */
240
+ bg: Color
241
+ }
242
+
243
+ /**
244
+ * Result of collecting text with background segments.
245
+ */
246
+ interface TextWithBg {
247
+ /** The collected text string (with ANSI codes for fg/attrs, but NOT bg) */
248
+ text: string
249
+ /** Background color segments from nested Text elements */
250
+ bgSegments: BgSegment[]
251
+ /** Plain text character count (excluding ANSI codes). Used for DOM-level budget tracking. */
252
+ plainLen: number
253
+ }
254
+
255
+ /**
256
+ * Collect plain text content from a node tree (no ANSI codes).
257
+ * Used to compute DOM-level truncation budget before ANSI serialization.
258
+ * Applies internal_transform on child nodes to match Ink's squashTextNodes.
259
+ */
260
+ function collectPlainText(node: TeaNode): string {
261
+ if (node.textContent !== undefined) return node.textContent
262
+ let result = ""
263
+ for (let i = 0; i < node.children.length; i++) {
264
+ const child = node.children[i]!
265
+ let childText = collectPlainText(child)
266
+ if (childText.length > 0 && (child.props as any).internal_transform) {
267
+ childText = (child.props as any).internal_transform(childText, i)
268
+ }
269
+ result += childText
270
+ }
271
+ return result
272
+ }
273
+
274
+ /**
275
+ * Collect text content and background color segments from a node tree.
276
+ *
277
+ * Like collectTextContent, but also tracks backgroundColor from nested Text
278
+ * elements as separate BgSegment entries. Background is NOT embedded as ANSI
279
+ * codes, preventing bg bleed when text wraps across lines.
280
+ *
281
+ * @param node - The node to collect text from
282
+ * @param parentContext - The inherited style context from parent
283
+ * @param offset - Current character offset in the collected text (for bg tracking)
284
+ * @param maxDisplayWidth - Maximum display width (columns) to collect. When set,
285
+ * stops collecting once this many display columns of content have been gathered.
286
+ * This truncates at the DOM level BEFORE ANSI serialization, so escape sequences
287
+ * (OSC 8, etc.) are never generated for content that won't be displayed.
288
+ * Uses getTextWidth (ANSI-aware) so pre-styled leaf text is handled correctly.
289
+ */
290
+ function collectTextWithBg(
291
+ node: TeaNode,
292
+ parentContext: StyleContext = {},
293
+ offset = 0,
294
+ maxDisplayWidth?: number,
295
+ ctx?: PipelineContext,
296
+ ): TextWithBg {
297
+ // If this node has direct text content, return it with no bg segments
298
+ if (node.textContent !== undefined) {
299
+ let text = node.textContent
300
+ // DOM-level truncation: trim leaf text to display width budget
301
+ if (maxDisplayWidth !== undefined) {
302
+ const textW = getTextWidth(text, ctx)
303
+ if (textW > maxDisplayWidth) {
304
+ const sliceFn = ctx ? ctx.measurer.sliceByWidth : sliceByWidth
305
+ text = sliceFn(text, maxDisplayWidth)
306
+ }
307
+ }
308
+ // plainLen tracks display width for budget and BgSegment offset tracking.
309
+ // Both use display-width coordinates consistently: collectTextWithBg uses
310
+ // getTextWidth for offsets, mapLinesToCharOffsets returns display-width,
311
+ // and applyBgSegmentsToLine compares via display-width (col - x).
312
+ const plainLen = getTextWidth(text, ctx)
313
+ return { text, bgSegments: [], plainLen }
314
+ }
315
+
316
+ let result = ""
317
+ const bgSegments: BgSegment[] = []
318
+ let currentOffset = offset
319
+ let displayWidthCollected = 0
320
+
321
+ for (let i = 0; i < node.children.length; i++) {
322
+ const child = node.children[i]!
323
+ // Stop collecting if budget exhausted
324
+ if (maxDisplayWidth !== undefined && displayWidthCollected >= maxDisplayWidth) break
325
+
326
+ // Compute remaining budget for this child
327
+ const childBudget = maxDisplayWidth !== undefined ? maxDisplayWidth - displayWidthCollected : undefined
328
+
329
+ if (child.type === "silvery-text" && child.props && !child.layoutNode) {
330
+ const childProps = child.props as TextProps
331
+ const childContext = mergeStyleContext(parentContext, childProps)
332
+
333
+ // Recursively collect with child's context and budget
334
+ const childResult = collectTextWithBg(child, childContext, currentOffset, childBudget, ctx)
335
+
336
+ // Apply internal_transform from virtual text nodes (nested Transform components).
337
+ // Matches Ink's squashTextNodes: transform is applied to the full concatenated
338
+ // text of the child, with index = child position in parent's children array.
339
+ const childTransform = (childProps as any).internal_transform
340
+ if (childTransform && childResult.text.length > 0) {
341
+ childResult.text = childTransform(childResult.text, i)
342
+ }
343
+
344
+ // Apply ANSI styles for fg/attrs (but NOT bg) with push/pop
345
+ const styledText = applyTextStyleAnsi(childResult.text, childContext, parentContext)
346
+ result += styledText
347
+
348
+ // Track bg segment if this child (or its ancestors) has backgroundColor.
349
+ // When backgroundColor is "" (empty string), create a null-bg segment to
350
+ // explicitly clear inherited background (e.g., from a parent Box).
351
+ if (childContext.backgroundColor) {
352
+ const bg = parseColor(childContext.backgroundColor)
353
+ if (bg !== null) {
354
+ if (childResult.plainLen > 0) {
355
+ bgSegments.push({
356
+ start: currentOffset,
357
+ end: currentOffset + childResult.plainLen,
358
+ bg,
359
+ })
360
+ }
361
+ }
362
+ } else if (childProps.backgroundColor === "" && childResult.plainLen > 0) {
363
+ // Explicit backgroundColor="" clears inherited bg (from parent Text
364
+ // or ancestor Box's inheritedBg). Push a null-bg segment so
365
+ // applyBgSegmentsToLine overrides inheritedBg to null for this range.
366
+ bgSegments.push({
367
+ start: currentOffset,
368
+ end: currentOffset + childResult.plainLen,
369
+ bg: null,
370
+ })
371
+ }
372
+
373
+ // Include child's nested bg segments
374
+ bgSegments.push(...childResult.bgSegments)
375
+
376
+ // Track using plainLen (display width) — not text.length which includes ANSI codes
377
+ currentOffset += childResult.plainLen
378
+ displayWidthCollected += childResult.plainLen
379
+ } else {
380
+ // Not a styled Text node, just collect recursively
381
+ const childResult = collectTextWithBg(child, parentContext, currentOffset, childBudget, ctx)
382
+ result += childResult.text
383
+ bgSegments.push(...childResult.bgSegments)
384
+ currentOffset += childResult.plainLen
385
+ displayWidthCollected += childResult.plainLen
386
+ }
387
+ }
388
+
389
+ return { text: result, bgSegments, plainLen: displayWidthCollected }
390
+ }
391
+
392
+ /**
393
+ * Apply background segments to buffer cells for a single rendered line.
394
+ *
395
+ * Maps character offsets from the original collected text to screen positions,
396
+ * accounting for text wrapping. Each bg segment fills only the cells that
397
+ * correspond to actual text characters, not trailing whitespace.
398
+ *
399
+ * @param buffer - The terminal buffer to write to
400
+ * @param x - Screen x position of the line start
401
+ * @param y - Screen y position of the line
402
+ * @param lineText - The rendered line text (may contain ANSI codes)
403
+ * @param lineCharStart - Character offset in original text where this line starts
404
+ * @param lineCharEnd - Character offset in original text where this line ends
405
+ * @param bgSegments - Background color segments to apply
406
+ */
407
+ function applyBgSegmentsToLine(
408
+ buffer: TerminalBuffer,
409
+ x: number,
410
+ y: number,
411
+ lineText: string,
412
+ lineCharStart: number,
413
+ lineCharEnd: number,
414
+ bgSegments: BgSegment[],
415
+ ctx?: PipelineContext,
416
+ ): void {
417
+ if (bgSegments.length === 0) return
418
+ if (y < 0 || y >= buffer.height) return
419
+
420
+ // Reusable cell for readCellInto to avoid per-character allocation
421
+ const bgCell = createMutableCell()
422
+ const gWidthFn = ctx ? ctx.measurer.graphemeWidth : graphemeWidth
423
+
424
+ // For each bg segment that overlaps this line's character range,
425
+ // calculate the screen columns and fill the bg
426
+ for (const seg of bgSegments) {
427
+ // Check overlap between segment [seg.start, seg.end) and line [lineCharStart, lineCharEnd)
428
+ const overlapStart = Math.max(seg.start, lineCharStart)
429
+ const overlapEnd = Math.min(seg.end, lineCharEnd)
430
+ if (overlapStart >= overlapEnd) continue
431
+
432
+ // Convert display-width offsets to column positions within the line.
433
+ // BgSegment offsets and lineCharStart/lineCharEnd are all in display-width
434
+ // coordinates, so relStart/relEnd are display-width offsets within the line.
435
+ const relStart = overlapStart - lineCharStart
436
+ const relEnd = overlapEnd - lineCharStart
437
+
438
+ // Walk through the line's visible characters to find screen columns.
439
+ // Use display-width offset (col - x) to match BgSegment coordinate system.
440
+ let col = x
441
+ const graphemes = splitGraphemes(hasAnsi(lineText) ? stripAnsiForBg(lineText) : lineText)
442
+
443
+ for (const grapheme of graphemes) {
444
+ const gWidth = gWidthFn(grapheme)
445
+ if (gWidth === 0) continue
446
+
447
+ const displayOffset = col - x
448
+ if (displayOffset >= relStart && displayOffset < relEnd) {
449
+ // This character is within the bg segment -- set bg on its cells.
450
+ // Use readCellInto to avoid allocating a new Cell per iteration.
451
+ buffer.readCellInto(col, y, bgCell)
452
+ bgCell.bg = seg.bg
453
+ buffer.setCell(col, y, bgCell)
454
+ if (gWidth === 2 && col + 1 < buffer.width) {
455
+ buffer.readCellInto(col + 1, y, bgCell)
456
+ bgCell.bg = seg.bg
457
+ buffer.setCell(col + 1, y, bgCell)
458
+ }
459
+ }
460
+
461
+ col += gWidth
462
+ if (col - x >= relEnd) break
463
+ }
464
+ }
465
+ }
466
+
467
+ /**
468
+ * Strip ANSI escape codes from text for character counting.
469
+ */
470
+ function stripAnsiForBg(text: string): string {
471
+ return text
472
+ .replace(/\x1b\[[0-9;:?]*[A-Za-z]/g, "")
473
+ .replace(/\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g, "")
474
+ .replace(/\x1b[DME78]/g, "")
475
+ .replace(/\x1b\(B/g, "")
476
+ }
477
+
478
+ /**
479
+ * Map formatted lines back to character offsets in the original text.
480
+ *
481
+ * After wrapping/truncation, each output line corresponds to a range
482
+ * of characters in the original text. This function computes those ranges
483
+ * by searching for each line's content in the normalized text.
484
+ *
485
+ * Handles characters consumed by word wrapping (spaces at break points,
486
+ * newlines) and characters added by truncation (ellipsis).
487
+ *
488
+ * Returns display-width offsets (not UTF-16 code units) to match BgSegment
489
+ * coordinate system. BgSegments use display-width via getTextWidth/plainLen.
490
+ *
491
+ * @param originalText - The original collected text (with ANSI, before wrapping)
492
+ * @param formattedLines - The wrapped/truncated output lines
493
+ * @param ctx - Pipeline context for width measurement
494
+ * @returns Array of { start, end } display-width offsets for each formatted line
495
+ */
496
+ function mapLinesToCharOffsets(
497
+ originalText: string,
498
+ formattedLines: string[],
499
+ ctx?: PipelineContext,
500
+ ): Array<{ start: number; end: number }> {
501
+ // Strip ANSI from the original to get the plain text character sequence
502
+ const plainOriginal = hasAnsi(originalText) ? stripAnsiForBg(originalText) : originalText
503
+ // Normalize tabs to match formatTextLines behavior
504
+ const normalized = plainOriginal.replace(/\t/g, " ")
505
+
506
+ const result: Array<{ start: number; end: number }> = []
507
+ let charOffset = 0 // UTF-16 offset for string matching (findLineStart)
508
+ let displayOffset = 0 // Display-width offset for BgSegment matching
509
+
510
+ for (const line of formattedLines) {
511
+ const plainLine = hasAnsi(line) ? stripAnsiForBg(line) : line
512
+
513
+ // Find where this line starts in the normalized text (UTF-16 matching).
514
+ const lineStart = findLineStart(normalized, plainLine, charOffset)
515
+
516
+ // Convert skipped characters (between previous line end and this line start)
517
+ // to display width. These are whitespace/newlines consumed by wrapping.
518
+ if (lineStart > charOffset) {
519
+ const skipped = normalized.slice(charOffset, lineStart)
520
+ displayOffset += getTextWidth(skipped, ctx)
521
+ }
522
+
523
+ // Line content display width
524
+ const lineDisplayWidth = getTextWidth(plainLine, ctx)
525
+ result.push({ start: displayOffset, end: displayOffset + lineDisplayWidth })
526
+
527
+ // Advance both offset trackers
528
+ const lineLen = Math.min(plainLine.length, normalized.length - lineStart)
529
+ charOffset = lineStart + lineLen
530
+ displayOffset += lineDisplayWidth
531
+ }
532
+
533
+ return result
534
+ }
535
+
536
+ /**
537
+ * Find where a formatted line starts in the normalized original text.
538
+ *
539
+ * Scans forward from the given offset, matching the line content
540
+ * character by character. Skips newlines and whitespace that were
541
+ * consumed by wrapping between lines.
542
+ */
543
+ function findLineStart(normalized: string, plainLine: string, fromOffset: number): number {
544
+ if (plainLine.length === 0) {
545
+ // Empty line -- skip to next newline
546
+ let pos = fromOffset
547
+ while (pos < normalized.length && normalized[pos] === "\n") {
548
+ pos++
549
+ }
550
+ return pos
551
+ }
552
+
553
+ // Try exact match at current offset first (fast path for first line
554
+ // and for lines that follow explicit newlines without space trimming)
555
+ if (normalized.startsWith(plainLine, fromOffset)) {
556
+ return fromOffset
557
+ }
558
+
559
+ // For truncated lines, extract prefix before ellipsis for matching.
560
+ // startsWith fails when the line has "…" that doesn't exist in the original.
561
+ const ELLIPSIS = "\u2026"
562
+ const ellipsisIdx = plainLine.indexOf(ELLIPSIS)
563
+ const truncatedPrefix = ellipsisIdx > 0 ? plainLine.slice(0, ellipsisIdx) : null
564
+
565
+ if (truncatedPrefix && normalized.startsWith(truncatedPrefix, fromOffset)) {
566
+ return fromOffset
567
+ }
568
+
569
+ // Scan forward, skipping newlines and spaces consumed by wrapping
570
+ let pos = fromOffset
571
+ while (pos < normalized.length) {
572
+ const ch = normalized[pos]!
573
+ if (ch === "\n" || ch === " ") {
574
+ pos++
575
+ continue
576
+ }
577
+ // Found a non-whitespace character -- check if line starts here
578
+ if (normalized.startsWith(plainLine, pos)) {
579
+ return pos
580
+ }
581
+ // Check truncated prefix match (e.g. "abcde…" -> match "abcde")
582
+ if (truncatedPrefix && normalized.startsWith(truncatedPrefix, pos)) {
583
+ return pos
584
+ }
585
+ pos++
586
+ }
587
+
588
+ // Fallback: return current position
589
+ return fromOffset
590
+ }
591
+
592
+ // ============================================================================
593
+ // Text Formatting
594
+ // ============================================================================
595
+
596
+ /**
597
+ * Format text into lines based on wrap mode.
598
+ *
599
+ * @param trim - When true, trims trailing spaces on broken lines and skips leading
600
+ * spaces on continuation lines. When false (e.g., text has backgroundColor),
601
+ * preserves trailing spaces so background color covers them. Defaults to true.
602
+ */
603
+ export function formatTextLines(
604
+ text: string,
605
+ width: number,
606
+ wrap: TextProps["wrap"],
607
+ ctx?: PipelineContext,
608
+ trim = true,
609
+ ): string[] {
610
+ // Guard against width <= 0 to prevent infinite loops
611
+ // This can happen with display="none" nodes (0x0 dimensions)
612
+ if (width <= 0) {
613
+ return []
614
+ }
615
+
616
+ // Convert tabs to spaces (tabs have 0 display width in string-width library)
617
+ const normalizedText = text.replace(/\t/g, " ")
618
+ const lines = normalizedText.split("\n")
619
+
620
+ // Hard clip: truncate without ellipsis (used by Fill component)
621
+ if (wrap === "clip") {
622
+ const sliceFn = ctx ? ctx.measurer.sliceByWidth : sliceByWidth
623
+ return lines.map((line) => {
624
+ if (getTextWidth(line, ctx) <= width) return line
625
+ return sliceFn(line, width)
626
+ })
627
+ }
628
+
629
+ // No wrapping, just truncate at end
630
+ if (wrap === false || wrap === "truncate-end" || wrap === "truncate") {
631
+ return lines.map((line) => truncateText(line, width, "end", ctx))
632
+ }
633
+
634
+ if (wrap === "truncate-start") {
635
+ return lines.map((line) => truncateText(line, width, "start", ctx))
636
+ }
637
+
638
+ if (wrap === "truncate-middle") {
639
+ return lines.map((line) => truncateText(line, width, "middle", ctx))
640
+ }
641
+
642
+ // wrap === true or wrap === 'wrap' - word-aware wrapping
643
+ // Uses wrapText from unicode.ts with trim for rendering
644
+ // (when trim=true, trims trailing spaces on broken lines, skips leading spaces
645
+ // on continuation lines; when trim=false, preserves spaces for bg-colored text)
646
+ if (ctx) return ctx.measurer.wrapText(normalizedText, width, true, trim)
647
+ return wrapText(normalizedText, width, true, trim)
648
+ }
649
+
650
+ /**
651
+ * Truncate text to fit within width.
652
+ */
653
+ export function truncateText(
654
+ text: string,
655
+ width: number,
656
+ mode: "start" | "middle" | "end",
657
+ ctx?: PipelineContext,
658
+ ): string {
659
+ const textWidth = getTextWidth(text, ctx)
660
+ if (textWidth <= width) return text
661
+
662
+ const ellipsis = "\u2026" // ...
663
+ const availableWidth = width - 1 // Reserve space for ellipsis
664
+
665
+ if (availableWidth <= 0) {
666
+ return width > 0 ? ellipsis : ""
667
+ }
668
+
669
+ const sliceFn = ctx ? ctx.measurer.sliceByWidth : sliceByWidth
670
+ const sliceEndFn = ctx ? ctx.measurer.sliceByWidthFromEnd : sliceByWidthFromEnd
671
+
672
+ if (mode === "end") {
673
+ return sliceFn(text, availableWidth) + ellipsis
674
+ }
675
+
676
+ if (mode === "start") {
677
+ return ellipsis + sliceEndFn(text, availableWidth)
678
+ }
679
+
680
+ // middle
681
+ const halfWidth = Math.floor(availableWidth / 2)
682
+ const startPart = sliceFn(text, halfWidth)
683
+ const endPart = sliceEndFn(text, availableWidth - halfWidth)
684
+ return startPart + ellipsis + endPart
685
+ }
686
+
687
+ // ============================================================================
688
+ // Text Line Rendering
689
+ // ============================================================================
690
+
691
+ /**
692
+ * Render a single line of text to the buffer.
693
+ *
694
+ * @param maxCol - Right edge of the text node's layout area. Wide characters
695
+ * whose continuation cell would exceed this boundary are replaced with a
696
+ * space, matching terminal behavior for wide chars at the screen edge.
697
+ * Without this, continuation cells overflow into adjacent containers and
698
+ * become stale during incremental rendering (the owning container's dirty
699
+ * tracking doesn't cover cells outside its layout bounds).
700
+ */
701
+ export function renderTextLine(
702
+ buffer: TerminalBuffer,
703
+ x: number,
704
+ y: number,
705
+ text: string,
706
+ baseStyle: Style,
707
+ maxCol?: number,
708
+ inheritedBg?: Color,
709
+ ctx?: PipelineContext,
710
+ ): void {
711
+ // Check if text contains ANSI escape sequences
712
+ if (hasAnsi(text)) {
713
+ renderAnsiTextLine(buffer, x, y, text, baseStyle, maxCol, inheritedBg, ctx)
714
+ return
715
+ }
716
+
717
+ renderGraphemes(buffer, splitGraphemes(text), x, y, baseStyle, maxCol, inheritedBg, ctx)
718
+ }
719
+
720
+ /**
721
+ * Like renderTextLine but returns the column position after the last rendered character.
722
+ * Used by renderText to know where to clear remaining cells.
723
+ */
724
+ function renderTextLineReturn(
725
+ buffer: TerminalBuffer,
726
+ x: number,
727
+ y: number,
728
+ text: string,
729
+ baseStyle: Style,
730
+ maxCol?: number,
731
+ inheritedBg?: Color,
732
+ ctx?: PipelineContext,
733
+ ): number {
734
+ if (hasAnsi(text)) {
735
+ return renderAnsiTextLineReturn(buffer, x, y, text, baseStyle, maxCol, inheritedBg, ctx)
736
+ }
737
+ return renderGraphemes(buffer, splitGraphemes(text), x, y, baseStyle, maxCol, inheritedBg, ctx)
738
+ }
739
+
740
+ /**
741
+ * Render graphemes to buffer cells with proper Unicode handling.
742
+ * Shared by renderTextLine (plain text) and renderAnsiTextLine (per-segment).
743
+ *
744
+ * @param maxCol - Right edge of the text node's layout area (exclusive).
745
+ * Wide characters whose continuation cell would reach or exceed this
746
+ * boundary are replaced with a space character. This matches terminal
747
+ * behavior for wide chars at the right edge of a container and prevents
748
+ * continuation cells from overflowing into adjacent containers, where
749
+ * they become stale during incremental rendering.
750
+ *
751
+ * Returns the column position after the last rendered grapheme.
752
+ */
753
+ function renderGraphemes(
754
+ buffer: TerminalBuffer,
755
+ graphemes: string[],
756
+ startCol: number,
757
+ y: number,
758
+ style: Style,
759
+ maxCol?: number,
760
+ inheritedBg?: Color,
761
+ ctx?: PipelineContext,
762
+ ): number {
763
+ let col = startCol
764
+ // Effective right boundary: text node's layout edge or buffer edge
765
+ const rightEdge = maxCol !== undefined ? Math.min(maxCol, buffer.width) : buffer.width
766
+ const gWidthFn = ctx ? ctx.measurer.graphemeWidth : graphemeWidth
767
+
768
+ for (const grapheme of graphemes) {
769
+ if (col >= rightEdge) break
770
+
771
+ const width = gWidthFn(grapheme)
772
+ if (width === 0) continue
773
+
774
+ // Determine background color for this cell.
775
+ // Priority: 1) Text's own bg, 2) inherited bg from ancestor Box, 3) buffer read (legacy fallback).
776
+ // Using inherited bg instead of getCellBg decouples text rendering from buffer state,
777
+ // which is critical for incremental rendering: the cloned buffer may have stale bg
778
+ // at positions outside the parent's bg-filled region (e.g., overflow text).
779
+ const existingBg = style.bg !== null ? style.bg : inheritedBg !== undefined ? inheritedBg : buffer.getCellBg(col, y)
780
+
781
+ // Wide character at the boundary: the continuation cell would overflow
782
+ // into an adjacent container. Replace with a space to match terminal
783
+ // behavior (real terminals leave the last column blank for wide chars
784
+ // that don't fit). Without this, the continuation cell extends outside
785
+ // the text node's layout bounds and becomes stale during incremental
786
+ // rendering — the owning container's dirty flag tracking doesn't cover
787
+ // cells outside its layout area.
788
+ if (width === 2 && col + 1 >= rightEdge) {
789
+ buffer.setCell(col, y, {
790
+ char: " ",
791
+ fg: style.fg,
792
+ bg: existingBg,
793
+ underlineColor: style.underlineColor ?? null,
794
+ attrs: style.attrs,
795
+ wide: false,
796
+ continuation: false,
797
+ hyperlink: style.hyperlink,
798
+ })
799
+ col += 1
800
+ continue
801
+ }
802
+
803
+ // For text-presentation emoji, add VS16 so terminals render at 2 columns
804
+ const outputChar = width === 2 ? ensureEmojiPresentation(grapheme) : grapheme
805
+
806
+ buffer.setCell(col, y, {
807
+ char: outputChar,
808
+ fg: style.fg,
809
+ bg: existingBg,
810
+ underlineColor: style.underlineColor ?? null,
811
+ attrs: style.attrs,
812
+ wide: width === 2,
813
+ continuation: false,
814
+ hyperlink: style.hyperlink,
815
+ })
816
+
817
+ if (width === 2 && col + 1 < buffer.width) {
818
+ const existingBg2 =
819
+ style.bg !== null ? style.bg : inheritedBg !== undefined ? inheritedBg : buffer.getCellBg(col + 1, y)
820
+ buffer.setCell(col + 1, y, {
821
+ char: "",
822
+ fg: style.fg,
823
+ bg: existingBg2,
824
+ underlineColor: style.underlineColor ?? null,
825
+ attrs: style.attrs,
826
+ wide: false,
827
+ continuation: true,
828
+ hyperlink: style.hyperlink,
829
+ })
830
+ col += 2
831
+ } else {
832
+ col += width
833
+ }
834
+ }
835
+
836
+ return col
837
+ }
838
+
839
+ /**
840
+ * Render text line with ANSI escape sequences.
841
+ * Parses ANSI codes and applies styles to individual segments.
842
+ */
843
+ export function renderAnsiTextLine(
844
+ buffer: TerminalBuffer,
845
+ x: number,
846
+ y: number,
847
+ text: string,
848
+ baseStyle: Style,
849
+ maxCol?: number,
850
+ inheritedBg?: Color,
851
+ ctx?: PipelineContext,
852
+ ): void {
853
+ renderAnsiTextLineReturn(buffer, x, y, text, baseStyle, maxCol, inheritedBg, ctx)
854
+ }
855
+
856
+ /**
857
+ * Like renderAnsiTextLine but returns the column position after the last rendered character.
858
+ */
859
+ function renderAnsiTextLineReturn(
860
+ buffer: TerminalBuffer,
861
+ x: number,
862
+ y: number,
863
+ text: string,
864
+ baseStyle: Style,
865
+ maxCol?: number,
866
+ inheritedBg?: Color,
867
+ ctx?: PipelineContext,
868
+ ): number {
869
+ const segments = parseAnsiText(text)
870
+ let col = x
871
+
872
+ for (const segment of segments) {
873
+ // Merge segment style with base style
874
+ const style = mergeAnsiStyle(baseStyle, segment)
875
+
876
+ // Detect background conflict: chalk.bg* overwrites existing silvery background
877
+ // Check both: 1) Text's own backgroundColor, 2) Parent Box's bg already in buffer
878
+ // Skip if segment has bgOverride flag (explicit opt-out via ansi.bgOverride)
879
+ const effectiveBgConflictMode = ctx?.bgConflictMode ?? getBgConflictMode()
880
+ if (
881
+ effectiveBgConflictMode !== "ignore" &&
882
+ !segment.bgOverride &&
883
+ segment.bg !== undefined &&
884
+ segment.bg !== null
885
+ ) {
886
+ // Check if there's an existing background (from Text prop or parent Box fill)
887
+ const existingBufBg = col < buffer.width ? buffer.getCellBg(col, y) : null
888
+ const hasExistingBg = baseStyle.bg !== null || existingBufBg !== null
889
+
890
+ if (hasExistingBg) {
891
+ const preview = segment.text.slice(0, 30)
892
+ const msg = `[silvery] Background conflict: chalk.bg* on text that already has silvery background. Chalk bg will override only text characters, causing visual gaps in padding. Use ansi.bgOverride() to suppress if intentional. Text: "${preview}${segment.text.length > 30 ? "..." : ""}"`
893
+
894
+ if (effectiveBgConflictMode === "throw") {
895
+ throw new Error(msg)
896
+ }
897
+ // 'warn' mode - deduplicate
898
+ const effectiveWarnedBgConflicts = ctx?.warnedBgConflicts ?? warnedBgConflicts
899
+ const key = `${JSON.stringify(existingBufBg)}-${segment.bg}-${preview}`
900
+ if (!effectiveWarnedBgConflicts.has(key)) {
901
+ effectiveWarnedBgConflicts.add(key)
902
+ console.warn(msg)
903
+ }
904
+ }
905
+ }
906
+
907
+ col = renderGraphemes(buffer, splitGraphemes(segment.text), col, y, style, maxCol, inheritedBg, ctx)
908
+ }
909
+ return col
910
+ }
911
+
912
+ // ============================================================================
913
+ // Style Merging (Category-Based)
914
+ // ============================================================================
915
+
916
+ /**
917
+ * Options for category-based style merging.
918
+ */
919
+ export interface MergeStylesOptions {
920
+ /**
921
+ * Preserve decoration attributes through layers (OR merge).
922
+ * Affects: underline, underlineStyle, underlineColor, strikethrough
923
+ * Default: true
924
+ */
925
+ preserveDecorations?: boolean
926
+ /**
927
+ * Preserve emphasis attributes through layers (OR merge).
928
+ * Affects: bold, dim, italic
929
+ * Default: true
930
+ */
931
+ preserveEmphasis?: boolean
932
+ }
933
+
934
+ /**
935
+ * Merge two styles using category-based semantics.
936
+ *
937
+ * Categories and their merge behavior:
938
+ * - Container (bg): overlay replaces base
939
+ * - Text (fg): overlay replaces base
940
+ * - Decorations (underline*, strikethrough): OR merge if preserveDecorations=true
941
+ * - Emphasis (bold, dim, italic): OR merge if preserveEmphasis=true
942
+ * - Transform (inverse, hidden, blink): overlay only, not inherited
943
+ *
944
+ * @param base - The base style (from parent/container)
945
+ * @param overlay - The overlay style (from child/content)
946
+ * @param options - Merge behavior options
947
+ */
948
+ export function mergeStyles(base: Style, overlay: Partial<Style>, options: MergeStylesOptions = {}): Style {
949
+ const { preserveDecorations = true, preserveEmphasis = true } = options
950
+
951
+ const baseAttrs = base.attrs ?? {}
952
+ const overlayAttrs = overlay.attrs ?? {}
953
+
954
+ // Merge attributes by category
955
+ const attrs: CellAttrs = {}
956
+
957
+ // Decorations: OR if preserving, otherwise overlay takes precedence
958
+ if (preserveDecorations) {
959
+ // Underline: OR the boolean, but style from overlay wins if specified
960
+ const hasBaseUnderline = baseAttrs.underline || baseAttrs.underlineStyle
961
+ const hasOverlayUnderline = overlayAttrs.underline || overlayAttrs.underlineStyle
962
+ if (hasBaseUnderline || hasOverlayUnderline) {
963
+ attrs.underline = true
964
+ // Style: overlay wins if specified, else base
965
+ attrs.underlineStyle = overlayAttrs.underlineStyle ?? baseAttrs.underlineStyle ?? "single"
966
+ }
967
+ attrs.strikethrough = overlayAttrs.strikethrough || baseAttrs.strikethrough
968
+ } else {
969
+ attrs.underline = overlayAttrs.underline ?? baseAttrs.underline
970
+ attrs.underlineStyle = overlayAttrs.underlineStyle ?? baseAttrs.underlineStyle
971
+ attrs.strikethrough = overlayAttrs.strikethrough ?? baseAttrs.strikethrough
972
+ }
973
+
974
+ // Emphasis: OR if preserving
975
+ if (preserveEmphasis) {
976
+ attrs.bold = overlayAttrs.bold || baseAttrs.bold
977
+ attrs.dim = overlayAttrs.dim || baseAttrs.dim
978
+ attrs.italic = overlayAttrs.italic || baseAttrs.italic
979
+ } else {
980
+ attrs.bold = overlayAttrs.bold ?? baseAttrs.bold
981
+ attrs.dim = overlayAttrs.dim ?? baseAttrs.dim
982
+ attrs.italic = overlayAttrs.italic ?? baseAttrs.italic
983
+ }
984
+
985
+ // Transform: overlay only, not inherited from base
986
+ attrs.inverse = overlayAttrs.inverse
987
+ attrs.hidden = overlayAttrs.hidden
988
+ attrs.blink = overlayAttrs.blink
989
+
990
+ return {
991
+ // Container/Text: overlay wins if specified
992
+ fg: overlay.fg ?? base.fg,
993
+ bg: overlay.bg ?? base.bg,
994
+ // Underline color: always use overlay ?? base (part of decoration preservation)
995
+ underlineColor: overlay.underlineColor ?? base.underlineColor,
996
+ attrs,
997
+ }
998
+ }
999
+
1000
+ // ============================================================================
1001
+ // ANSI Style Helpers
1002
+ // ============================================================================
1003
+
1004
+ /**
1005
+ * Merge ANSI segment style with base style.
1006
+ * Uses category-based merging to preserve decorations and emphasis.
1007
+ */
1008
+ function mergeAnsiStyle(base: Style, segment: StyledSegment, options: MergeStylesOptions = {}): Style {
1009
+ const { preserveDecorations = true, preserveEmphasis = true } = options
1010
+
1011
+ // Convert ANSI SGR codes to overlay style
1012
+ let fg: Color = base.fg
1013
+ let bg: Color = base.bg
1014
+ let underlineColor: Color = base.underlineColor ?? null
1015
+
1016
+ if (segment.fg !== undefined && segment.fg !== null) {
1017
+ fg = ansiColorToColor(segment.fg)
1018
+ }
1019
+ if (segment.bg !== undefined && segment.bg !== null) {
1020
+ bg = ansiColorToColor(segment.bg)
1021
+ }
1022
+ if (segment.underlineColor !== undefined && segment.underlineColor !== null) {
1023
+ underlineColor = ansiColorToColor(segment.underlineColor)
1024
+ }
1025
+
1026
+ // Build overlay attrs from segment
1027
+ const overlayAttrs: CellAttrs = {}
1028
+ if (segment.bold !== undefined) overlayAttrs.bold = segment.bold
1029
+ if (segment.dim !== undefined) overlayAttrs.dim = segment.dim
1030
+ if (segment.italic !== undefined) overlayAttrs.italic = segment.italic
1031
+ if (segment.underline !== undefined) {
1032
+ overlayAttrs.underline = segment.underline
1033
+ }
1034
+ if (segment.underlineStyle !== undefined) {
1035
+ overlayAttrs.underlineStyle = segment.underlineStyle as UnderlineStyle
1036
+ }
1037
+ if (segment.inverse !== undefined) overlayAttrs.inverse = segment.inverse
1038
+
1039
+ // Use mergeStyles for consistent category-based merging
1040
+ const merged = mergeStyles(
1041
+ base,
1042
+ { fg, bg, underlineColor, attrs: overlayAttrs },
1043
+ { preserveDecorations, preserveEmphasis },
1044
+ )
1045
+
1046
+ // Pass through OSC 8 hyperlink from segment (not an SGR attribute)
1047
+ if (segment.hyperlink) {
1048
+ merged.hyperlink = segment.hyperlink
1049
+ }
1050
+
1051
+ return merged
1052
+ }
1053
+
1054
+ /**
1055
+ * Convert ANSI SGR color code to our Color type.
1056
+ * Color is: number (256-color index) | { r, g, b } (true color) | null
1057
+ */
1058
+ function ansiColorToColor(code: number): Color {
1059
+ // True color (packed RGB with 0x1000000 marker from parseAnsiText)
1060
+ if (code >= 0x1000000) {
1061
+ const r = (code >> 16) & 0xff
1062
+ const g = (code >> 8) & 0xff
1063
+ const b = code & 0xff
1064
+ return { r, g, b }
1065
+ }
1066
+
1067
+ // 256 color palette index (0-255)
1068
+ if (code < 30 || (code >= 38 && code < 40) || (code >= 48 && code < 90)) {
1069
+ // Direct palette index (0-255) — return as-is
1070
+ return code
1071
+ }
1072
+
1073
+ // Standard foreground colors (30-37) map to palette 0-7
1074
+ if (code >= 30 && code <= 37) {
1075
+ return code - 30
1076
+ }
1077
+
1078
+ // Standard background colors (40-47) map to palette 0-7
1079
+ if (code >= 40 && code <= 47) {
1080
+ return code - 40
1081
+ }
1082
+
1083
+ // Bright foreground colors (90-97) map to palette 8-15
1084
+ if (code >= 90 && code <= 97) {
1085
+ return code - 90 + 8
1086
+ }
1087
+
1088
+ // Bright background colors (100-107) map to palette 8-15
1089
+ if (code >= 100 && code <= 107) {
1090
+ return code - 100 + 8
1091
+ }
1092
+
1093
+ return null
1094
+ }
1095
+
1096
+ // ============================================================================
1097
+ // Render Text Node (Main Entry Point)
1098
+ // ============================================================================
1099
+
1100
+ /**
1101
+ * Render a Text node.
1102
+ *
1103
+ * Background colors from nested Text elements are handled at the buffer level
1104
+ * (not via ANSI codes) to prevent bg bleed across wrapped text lines.
1105
+ * See km-silvery.bg-bleed for details.
1106
+ */
1107
+ export function renderText(
1108
+ node: TeaNode,
1109
+ buffer: TerminalBuffer,
1110
+ layout: { x: number; y: number; width: number; height: number },
1111
+ props: TextProps,
1112
+ nodeState: NodeRenderState,
1113
+ inheritedBg?: Color,
1114
+ inheritedFg?: Color,
1115
+ ctx?: PipelineContext,
1116
+ ): void {
1117
+ const { scrollOffset, clipBounds } = nodeState
1118
+ const { x, width, height } = layout
1119
+ let { y } = layout
1120
+
1121
+ // Apply scroll offset
1122
+ y -= scrollOffset
1123
+
1124
+ // Explicit backgroundColor="" on a Text node means "no background" — force
1125
+ // null bg to override both inherited bg from ancestor Boxes and any bg
1126
+ // already in the buffer cells (set by Box's renderBox fill). The sentinel
1127
+ // value `null` is used instead of `undefined` so renderGraphemes uses it
1128
+ // directly instead of falling back to buffer.getCellBg().
1129
+ if (props.backgroundColor === "") {
1130
+ inheritedBg = null
1131
+ }
1132
+
1133
+ // Clip to bounds if specified
1134
+ if (clipBounds) {
1135
+ if (y + height <= clipBounds.top || y >= clipBounds.bottom) {
1136
+ return // Completely outside vertical clip bounds
1137
+ }
1138
+ if (clipBounds.left !== undefined && clipBounds.right !== undefined) {
1139
+ if (x + width <= clipBounds.left || x >= clipBounds.right) {
1140
+ return // Completely outside horizontal clip bounds
1141
+ }
1142
+ }
1143
+ }
1144
+
1145
+ // Compute DOM-level display width budget for truncate-end modes.
1146
+ // This limits how much text collectTextWithBg gathers BEFORE ANSI serialization,
1147
+ // making OSC 8 hyperlinks and other escape sequences safe by construction.
1148
+ // Only applies to end-truncation (truncate, truncate-end, false) where we keep
1149
+ // text from the start. Start/middle truncation keep text from the end or both
1150
+ // ends, so they fall back to ANSI-level truncation in formatTextLines.
1151
+ // Budget is width + 1 display columns per line to ensure formatTextLines sees
1152
+ // text wider than the container and adds the ellipsis character.
1153
+ let maxDisplayWidth: number | undefined
1154
+ const isTruncateEnd =
1155
+ props.wrap === false || props.wrap === "truncate-end" || props.wrap === "truncate" || props.wrap === "clip"
1156
+ if (isTruncateEnd && width > 0) {
1157
+ const plainText = collectPlainText(node)
1158
+ const lineCount = (plainText.match(/\n/g)?.length ?? 0) + 1
1159
+ // Each line needs width+1 columns to trigger ellipsis. Multiply by line count.
1160
+ maxDisplayWidth = (width + 1) * lineCount
1161
+ }
1162
+
1163
+ // Collect text content and background segments from this node and all children.
1164
+ // Background color from nested Text elements is tracked as BgSegments
1165
+ // (not embedded as ANSI codes) to survive text wrapping correctly.
1166
+ const { text, bgSegments } = collectTextWithBg(node, {}, 0, maxDisplayWidth, ctx)
1167
+
1168
+ // Get style for this Text node.
1169
+ // Inherit foreground from nearest ancestor Box with color prop (CSS semantics).
1170
+ const style = getTextStyle(props)
1171
+ if (style.fg === null && inheritedFg !== undefined) {
1172
+ style.fg = inheritedFg
1173
+ }
1174
+
1175
+ // Handle wrapping/truncation
1176
+ // When text has background color (from own prop, nested children, or inherited
1177
+ // from parent Box), preserve trailing spaces on wrapped lines so the background
1178
+ // color covers them. Ink preserves these spaces for the same reason.
1179
+ const hasBg = style.bg !== null || bgSegments.length > 0 || (inheritedBg !== undefined && inheritedBg !== null)
1180
+ let lines = formatTextLines(text, width, props.wrap, ctx, !hasBg)
1181
+
1182
+ // Apply internal_transform if present (used by Transform component).
1183
+ // Transform is applied per-line after formatting, matching ink's behavior.
1184
+ // The transform may change the length of each line (e.g., adding brackets
1185
+ // or line numbers). We track whether a transform is active so the rendering
1186
+ // loop can expand maxCol to accommodate the transformed content.
1187
+ const internalTransform = props.internal_transform
1188
+ if (internalTransform) {
1189
+ lines = lines.map((line, index) => internalTransform(line, index))
1190
+ }
1191
+
1192
+ // Map formatted lines back to display-width offsets for bg segment application
1193
+ const lineOffsets = bgSegments.length > 0 ? mapLinesToCharOffsets(text, lines, ctx) : []
1194
+
1195
+ // Render each line
1196
+ for (let lineIdx = 0; lineIdx < lines.length && lineIdx < height; lineIdx++) {
1197
+ const lineY = y + lineIdx
1198
+ // Skip lines outside clip bounds
1199
+ if (clipBounds && (lineY < clipBounds.top || lineY >= clipBounds.bottom)) {
1200
+ continue
1201
+ }
1202
+ const line = lines[lineIdx]!
1203
+
1204
+ // Pass maxCol to prevent wide characters from overflowing into adjacent
1205
+ // containers. Without this, continuation cells outside the text node's
1206
+ // layout bounds become stale during incremental rendering.
1207
+ // Clip right edge to horizontal clip bounds (overflow:hidden containers).
1208
+ // When internal_transform is active, expand maxCol to buffer width so the
1209
+ // transformed text (which may be wider than the original layout) is not clipped.
1210
+ const layoutRight = internalTransform ? buffer.width : x + width
1211
+ const maxCol =
1212
+ clipBounds && "right" in clipBounds && clipBounds.right !== undefined
1213
+ ? Math.min(layoutRight, clipBounds.right)
1214
+ : layoutRight
1215
+ const endCol = renderTextLineReturn(buffer, x, lineY, line, style, maxCol, inheritedBg, ctx)
1216
+
1217
+ // Clear remaining cells after text to end of layout width (clipped).
1218
+ // When text content shrinks (e.g., breadcrumb changes from long to short path),
1219
+ // the parent Box may skip its bg fill (skipBgFill=true when only subtreeDirty).
1220
+ // Without explicit clearing here, stale chars from the previous longer text
1221
+ // survive in the cloned buffer. This is safe: we only clear within our own
1222
+ // layout area, writing spaces with the correct inherited background.
1223
+ if (endCol < maxCol) {
1224
+ const clearBg = inheritedBg ?? null
1225
+ for (let cx = endCol; cx < maxCol && cx < buffer.width; cx++) {
1226
+ buffer.setCell(cx, lineY, {
1227
+ char: " ",
1228
+ fg: style.fg,
1229
+ bg: clearBg,
1230
+ underlineColor: null,
1231
+ attrs: {
1232
+ bold: false,
1233
+ dim: false,
1234
+ italic: false,
1235
+ underline: false,
1236
+ inverse: false,
1237
+ strikethrough: false,
1238
+ blink: false,
1239
+ hidden: false,
1240
+ },
1241
+ wide: false,
1242
+ continuation: false,
1243
+ })
1244
+ }
1245
+ }
1246
+
1247
+ // Apply background segments from nested Text elements to the buffer.
1248
+ // This happens after renderTextLine so the bg is applied to cells
1249
+ // that already have the correct character/fg/attrs written.
1250
+ if (bgSegments.length > 0 && lineIdx < lineOffsets.length) {
1251
+ const { start, end } = lineOffsets[lineIdx]!
1252
+ applyBgSegmentsToLine(buffer, x, lineY, line, start, end, bgSegments, ctx)
1253
+ }
1254
+ }
1255
+ }