@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
package/src/unicode.ts ADDED
@@ -0,0 +1,1763 @@
1
+ /**
2
+ * Unicode handling for Silvery.
3
+ *
4
+ * Uses Intl.Segmenter for proper grapheme cluster segmentation and
5
+ * string-width for accurate terminal width calculation.
6
+ *
7
+ * Key concepts:
8
+ * - Grapheme: A user-perceived character (may be multiple code points)
9
+ * - Display width: How many terminal columns a character occupies (0, 1, or 2)
10
+ * - Wide characters: CJK ideographs, emoji, etc. that take 2 columns
11
+ * - Combining characters: Diacritics, emoji modifiers that take 0 columns
12
+ */
13
+
14
+ import { BG_OVERRIDE_CODE } from "./ansi/index"
15
+ import sliceAnsi from "slice-ansi"
16
+ import stringWidth from "string-width"
17
+ import { type Cell, type Style, type TerminalBuffer, type UnderlineStyle, createMutableCell } from "./buffer"
18
+ import { isPrivateUseArea } from "./text-sizing"
19
+
20
+ // Re-export for consumers of silvery
21
+ export { BG_OVERRIDE_CODE }
22
+
23
+ // ============================================================================
24
+ // Grapheme Segmentation
25
+ // ============================================================================
26
+
27
+ // Singleton Intl.Segmenter instance (stateless, reusable)
28
+ const segmenter = new Intl.Segmenter(undefined, { granularity: "grapheme" })
29
+
30
+ // ============================================================================
31
+ // Performance: LRU Cache for displayWidth
32
+ // ============================================================================
33
+
34
+ /**
35
+ * Simple LRU cache for displayWidth results.
36
+ * String width calculation is expensive (~8us for ASCII text),
37
+ * but the same strings are often measured repeatedly.
38
+ */
39
+ class DisplayWidthCache {
40
+ private cache = new Map<string, number>()
41
+ private maxSize: number
42
+
43
+ constructor(maxSize = 1000) {
44
+ this.maxSize = maxSize
45
+ }
46
+
47
+ get(text: string): number | undefined {
48
+ const cached = this.cache.get(text)
49
+ if (cached !== undefined) {
50
+ // Move to end (most recently used)
51
+ this.cache.delete(text)
52
+ this.cache.set(text, cached)
53
+ }
54
+ return cached
55
+ }
56
+
57
+ set(text: string, width: number): void {
58
+ // Evict oldest if at capacity
59
+ if (this.cache.size >= this.maxSize) {
60
+ const firstKey = this.cache.keys().next().value
61
+ if (firstKey !== undefined) {
62
+ this.cache.delete(firstKey)
63
+ }
64
+ }
65
+ this.cache.set(text, width)
66
+ }
67
+
68
+ clear(): void {
69
+ this.cache.clear()
70
+ }
71
+ }
72
+
73
+ // Cache size: 10K entries should be enough for most TUI apps
74
+ // Each entry is a string key + number value, ~100 bytes, so 10K = ~1MB
75
+ const displayWidthCache = new DisplayWidthCache(10000)
76
+
77
+ // ============================================================================
78
+ // Text Sizing Protocol (OSC 66) State
79
+ // ============================================================================
80
+
81
+ /**
82
+ * Default text-presentation emoji width: true (modern terminal assumption).
83
+ * Modern terminals (Ghostty, iTerm, Kitty) render text-presentation emoji
84
+ * as 2-wide. Terminal.app renders them as 1-wide.
85
+ */
86
+ const DEFAULT_TEXT_EMOJI_WIDE = true
87
+
88
+ /**
89
+ * Default text sizing mode: false.
90
+ * When enabled via a Measurer, PUA characters are treated as 2-wide.
91
+ */
92
+ const DEFAULT_TEXT_SIZING_ENABLED = false
93
+
94
+ // ============================================================================
95
+ // Scoped Measurer (pipeline execution context)
96
+ // ============================================================================
97
+
98
+ /**
99
+ * Active measurer for the current pipeline execution.
100
+ * Set by runWithMeasurer() during executeRender, restored after.
101
+ * When null, module-level functions use the lazy default measurer.
102
+ */
103
+ let _scopedMeasurer: WidthMeasurer | null = null
104
+
105
+ /**
106
+ * Run a function with a specific measurer as the active scope.
107
+ * Module-level convenience functions (graphemeWidth, displayWidth, etc.)
108
+ * will use this measurer instead of the lazy default for the duration.
109
+ */
110
+ export function runWithMeasurer<T>(measurer: WidthMeasurer, fn: () => T): T {
111
+ const prev = _scopedMeasurer
112
+ _scopedMeasurer = measurer
113
+ try {
114
+ return fn()
115
+ } finally {
116
+ _scopedMeasurer = prev
117
+ }
118
+ }
119
+
120
+ /**
121
+ * @deprecated Use createWidthMeasurer() with { textEmojiWide } instead.
122
+ * Kept for backward compatibility but is a no-op.
123
+ */
124
+ export function setTextEmojiWide(_wide: boolean): void {
125
+ // No-op: use createWidthMeasurer() with { textEmojiWide } instead
126
+ }
127
+
128
+ /**
129
+ * @deprecated Use createWidthMeasurer() with { textSizingEnabled } instead.
130
+ * Kept for backward compatibility but is a no-op.
131
+ */
132
+ export function setTextSizingEnabled(_enabled: boolean): void {
133
+ // No-op: use createWidthMeasurer() with { textSizingEnabled } instead
134
+ }
135
+
136
+ /**
137
+ * Check if text sizing mode is currently enabled.
138
+ * Returns the default (false) since globals have been removed.
139
+ * Use measurer.textSizingEnabled for scoped queries.
140
+ */
141
+ export function isTextSizingEnabled(): boolean {
142
+ if (_scopedMeasurer) return _scopedMeasurer.textSizingEnabled
143
+ return DEFAULT_TEXT_SIZING_ENABLED
144
+ }
145
+
146
+ // ============================================================================
147
+ // Width Measurer (per-term instance, no globals)
148
+ // ============================================================================
149
+
150
+ /**
151
+ * Width measurement functions scoped to specific terminal capabilities.
152
+ * Created by createWidthMeasurer() from TerminalCaps.
153
+ */
154
+ export interface Measurer {
155
+ readonly textEmojiWide: boolean
156
+ readonly textSizingEnabled: boolean
157
+ displayWidth(text: string): number
158
+ displayWidthAnsi(text: string): number
159
+ graphemeWidth(grapheme: string): number
160
+ wrapText(text: string, width: number, trim?: boolean, hard?: boolean): string[]
161
+ sliceByWidth(text: string, maxWidth: number): string
162
+ sliceByWidthFromEnd(text: string, maxWidth: number): string
163
+ }
164
+
165
+ /** Backward-compatible alias for Measurer. */
166
+ export type WidthMeasurer = Measurer
167
+
168
+ /**
169
+ * Strip OSC 8 hyperlink sequences before passing to slice-ansi.
170
+ * slice-ansi doesn't understand OSC sequences and corrupts them.
171
+ */
172
+ const OSC8_RE = /\x1b\]8;;[^\x07\x1b]*(?:\x07|\x1b\\)/g
173
+ function stripOsc8ForSlice(text: string): string {
174
+ return text.replace(OSC8_RE, "")
175
+ }
176
+
177
+ /**
178
+ * Create a width measurer scoped to terminal capabilities.
179
+ * Each measurer has its own caches (no shared global state).
180
+ */
181
+ export function createWidthMeasurer(caps: { textEmojiWide?: boolean; textSizingEnabled?: boolean } = {}): Measurer {
182
+ const textEmojiWide = caps.textEmojiWide ?? true
183
+ const textSizingEnabled = caps.textSizingEnabled ?? false
184
+ const cache = new DisplayWidthCache(10000)
185
+
186
+ function measuredGraphemeWidth(grapheme: string): number {
187
+ const width = stringWidth(grapheme)
188
+ if (width !== 1) return width
189
+ if (textEmojiWide && isTextPresentationEmoji(grapheme)) return 2
190
+ if (textSizingEnabled) {
191
+ const cp = grapheme.codePointAt(0)
192
+ if (cp !== undefined && isPrivateUseArea(cp)) return 2
193
+ }
194
+ return width
195
+ }
196
+
197
+ function measuredDisplayWidth(text: string): number {
198
+ const cached = cache.get(text)
199
+ if (cached !== undefined) return cached
200
+
201
+ let width: number
202
+ const needsSlowPath = MAY_CONTAIN_TEXT_EMOJI.test(text) || (textSizingEnabled && MAY_CONTAIN_PUA.test(text))
203
+ if (!needsSlowPath) {
204
+ width = stringWidth(text)
205
+ } else {
206
+ const stripped = stripAnsi(text)
207
+ width = 0
208
+ for (const grapheme of splitGraphemes(stripped)) {
209
+ width += measuredGraphemeWidth(grapheme)
210
+ }
211
+ }
212
+ cache.set(text, width)
213
+ return width
214
+ }
215
+
216
+ function measuredDisplayWidthAnsi(text: string): number {
217
+ return measuredDisplayWidth(stripAnsi(text))
218
+ }
219
+
220
+ function measuredSliceByWidth(text: string, maxWidth: number): string {
221
+ if (hasAnsi(text)) {
222
+ return sliceAnsi(stripOsc8ForSlice(text), 0, maxWidth)
223
+ }
224
+ let width = 0
225
+ let result = ""
226
+ const graphemes = splitGraphemes(text)
227
+ for (const grapheme of graphemes) {
228
+ const gWidth = measuredGraphemeWidth(grapheme)
229
+ if (width + gWidth > maxWidth) break
230
+ result += grapheme
231
+ width += gWidth
232
+ }
233
+ return result
234
+ }
235
+
236
+ function measuredSliceByWidthFromEnd(text: string, maxWidth: number): string {
237
+ const totalWidth = measuredDisplayWidthAnsi(text)
238
+ if (totalWidth <= maxWidth) return text
239
+ if (hasAnsi(text)) {
240
+ const cleaned = stripOsc8ForSlice(text)
241
+ const cleanedWidth = measuredDisplayWidthAnsi(cleaned)
242
+ const startIndex = cleanedWidth - maxWidth
243
+ return sliceAnsi(cleaned, startIndex)
244
+ }
245
+ const graphemes = splitGraphemes(text)
246
+ let width = 0
247
+ let startIdx = graphemes.length
248
+ for (let i = graphemes.length - 1; i >= 0; i--) {
249
+ const gWidth = measuredGraphemeWidth(graphemes[i]!)
250
+ if (width + gWidth > maxWidth) break
251
+ width += gWidth
252
+ startIdx = i
253
+ }
254
+ return graphemes.slice(startIdx).join("")
255
+ }
256
+
257
+ function measuredWrapText(text: string, width: number, trim?: boolean, hard?: boolean): string[] {
258
+ return wrapTextWithMeasurer(text, width, measurer, trim ?? false, hard ?? false)
259
+ }
260
+
261
+ const measurer: Measurer = {
262
+ textEmojiWide,
263
+ textSizingEnabled,
264
+ displayWidth: measuredDisplayWidth,
265
+ displayWidthAnsi: measuredDisplayWidthAnsi,
266
+ graphemeWidth: measuredGraphemeWidth,
267
+ wrapText: measuredWrapText,
268
+ sliceByWidth: measuredSliceByWidth,
269
+ sliceByWidthFromEnd: measuredSliceByWidthFromEnd,
270
+ }
271
+
272
+ return measurer
273
+ }
274
+
275
+ /** Alias for createWidthMeasurer. */
276
+ export const createMeasurer = createWidthMeasurer
277
+
278
+ // ============================================================================
279
+ // Default Measurer (lazy singleton for module-level convenience functions)
280
+ // ============================================================================
281
+
282
+ let _defaultMeasurer: Measurer | undefined
283
+
284
+ /** Get the default measurer (lazy init, uses default caps). */
285
+ function getDefaultMeasurer(): Measurer {
286
+ if (!_defaultMeasurer) {
287
+ _defaultMeasurer = createWidthMeasurer()
288
+ }
289
+ return _defaultMeasurer
290
+ }
291
+
292
+ /**
293
+ * @deprecated Use createWidthMeasurer() and pass the measurer explicitly.
294
+ * Kept as a no-op for backward compatibility.
295
+ */
296
+ export function withMeasurer<T>(_measurer: WidthMeasurer, fn: () => T): T {
297
+ return fn()
298
+ }
299
+
300
+ /**
301
+ * Split a string into grapheme clusters.
302
+ * Each grapheme is a user-perceived character that may consist of
303
+ * multiple Unicode code points.
304
+ *
305
+ * Examples:
306
+ * - "cafe\u0301" (café with combining accent) -> ["c", "a", "f", "e\u0301"]
307
+ * - "👨‍👩‍👧" (family emoji) -> ["👨‍👩‍👧"]
308
+ * - "한국어" -> ["한", "국", "어"]
309
+ */
310
+ export function splitGraphemes(text: string): string[] {
311
+ return [...segmenter.segment(text)].map((s) => s.segment)
312
+ }
313
+
314
+ /**
315
+ * Count the number of graphemes in a string.
316
+ */
317
+ export function graphemeCount(text: string): number {
318
+ let count = 0
319
+ for (const _ of segmenter.segment(text)) count++
320
+ return count
321
+ }
322
+
323
+ // ============================================================================
324
+ // Emoji Width Correction
325
+ // ============================================================================
326
+
327
+ /**
328
+ * Regex for Extended_Pictographic characters that have default text presentation.
329
+ * These characters are reported as width 1 by string-width (per Unicode EAW),
330
+ * but most modern terminals render them as 2 columns wide using emoji glyphs.
331
+ *
332
+ * Specifically: Extended_Pictographic AND NOT Emoji_Presentation.
333
+ * Examples: ⚠ (U+26A0), ☑ (U+2611), ✈ (U+2708), ❤ (U+2764)
334
+ * Counter-examples: 📁 (U+1F4C1) has Emoji_Presentation so string-width is correct.
335
+ *
336
+ * Uses the RGI_Emoji regex with VS16 to detect characters that support
337
+ * emoji presentation -- if char+VS16 is RGI emoji, the terminal likely
338
+ * renders the bare char as 2-wide.
339
+ */
340
+ const TEXT_PRESENTATION_EMOJI_REGEX = /^\p{Extended_Pictographic}$/u
341
+ const EMOJI_PRESENTATION_REGEX = /^\p{Emoji_Presentation}$/u
342
+ // @ts-expect-error -- RGI_Emoji v flag needs es2024 target but works at runtime
343
+ const RGI_EMOJI_REGEX = /^\p{RGI_Emoji}$/v
344
+
345
+ /**
346
+ * Cache for isTextPresentationEmoji results.
347
+ * Maps first code point to boolean.
348
+ */
349
+ const textPresentationEmojiCache = new Map<number, boolean>()
350
+
351
+ /**
352
+ * Check if a grapheme is a text-presentation emoji that terminals render wide.
353
+ *
354
+ * Returns true for characters that are Extended_Pictographic, do NOT have
355
+ * the Emoji_Presentation property, but become RGI emoji when followed by
356
+ * VS16 (U+FE0F). These characters are rendered as 2 columns in most
357
+ * modern terminals despite string-width reporting width 1.
358
+ */
359
+ export function isTextPresentationEmoji(grapheme: string): boolean {
360
+ const cp = grapheme.codePointAt(0)
361
+ if (cp === undefined) return false
362
+
363
+ // Check cache
364
+ const cached = textPresentationEmojiCache.get(cp)
365
+ if (cached !== undefined) return cached
366
+
367
+ // Multi-codepoint graphemes (with VS16, ZWJ, etc.) are already handled
368
+ // correctly by string-width. Only check single-codepoint graphemes.
369
+ const singleChar = String.fromCodePoint(cp)
370
+ if (singleChar.length !== grapheme.length) {
371
+ textPresentationEmojiCache.set(cp, false)
372
+ return false
373
+ }
374
+
375
+ // Must be Extended_Pictographic but NOT Emoji_Presentation
376
+ const isExtPict = TEXT_PRESENTATION_EMOJI_REGEX.test(grapheme)
377
+ const isEmojiPres = EMOJI_PRESENTATION_REGEX.test(grapheme)
378
+ if (!isExtPict || isEmojiPres) {
379
+ textPresentationEmojiCache.set(cp, false)
380
+ return false
381
+ }
382
+
383
+ // Check if adding VS16 makes it an RGI emoji sequence
384
+ const withVs16 = grapheme + "\uFE0F"
385
+ const result = RGI_EMOJI_REGEX.test(withVs16)
386
+ textPresentationEmojiCache.set(cp, result)
387
+ return result
388
+ }
389
+
390
+ // ============================================================================
391
+ // Private Use Area (PUA) — Nerdfont / Powerline Icons
392
+ // ============================================================================
393
+
394
+ /**
395
+ * Append VS16 (U+FE0F) to emoji characters that have default text presentation.
396
+ *
397
+ * Use this to normalize icon characters for consistent terminal rendering.
398
+ * Characters that already have emoji presentation or VS16 are returned unchanged.
399
+ *
400
+ * @example
401
+ * ```ts
402
+ * ensureEmojiPresentation('⚠') // '⚠\uFE0F' (⚠️)
403
+ * ensureEmojiPresentation('☑') // '☑\uFE0F' (☑️)
404
+ * ensureEmojiPresentation('☐') // '☐' (unchanged, not an emoji)
405
+ * ensureEmojiPresentation('📁') // '📁' (unchanged, already emoji presentation)
406
+ * ```
407
+ */
408
+ export function ensureEmojiPresentation(char: string): string {
409
+ if (char.includes("\uFE0F")) return char // Already has VS16
410
+ if (isTextPresentationEmoji(char)) return char + "\uFE0F"
411
+ return char
412
+ }
413
+
414
+ // ============================================================================
415
+ // Display Width Calculation
416
+ // ============================================================================
417
+
418
+ /**
419
+ * Regex to detect strings that MAY contain text-presentation emoji.
420
+ * Used as a fast pre-check before the more expensive grapheme-based calculation.
421
+ * Covers the Unicode blocks where Extended_Pictographic characters live:
422
+ * - Miscellaneous Technical (U+2300-U+23FF)
423
+ * - Miscellaneous Symbols (U+2600-U+26FF)
424
+ * - Dingbats (U+2700-U+27BF)
425
+ * - Miscellaneous Symbols and Arrows (U+2B00-U+2BFF)
426
+ * - Other scattered ranges
427
+ */
428
+ const MAY_CONTAIN_TEXT_EMOJI =
429
+ /[\u203C\u2049\u2122\u2139\u2194-\u2199\u21A9\u21AA\u2328\u23CF\u23ED-\u23EF\u23F1\u23F2\u23F8-\u23FA\u25AA\u25AB\u25B6\u25C0\u25FB-\u25FE\u2600-\u2604\u260E\u2611\u2614\u2615\u2618\u261D\u2620\u2622\u2623\u2626\u262A\u262E\u262F\u2638-\u263A\u2640\u2642\u2648-\u2653\u265F\u2660\u2663\u2665\u2666\u2668\u267B\u267E\u267F\u2692-\u2697\u2699\u269B\u269C\u26A0\u26A1\u26A7\u26AA\u26AB\u26B0\u26B1\u26BD\u26BE\u26C4\u26C5\u26C8\u26CE\u26CF\u26D1\u26D3\u26D4\u26E9\u26EA\u26F0-\u26F5\u26F7-\u26FA\u26FD\u2702\u2705\u2708-\u270D\u270F\u2712\u2714\u2716\u271D\u2721\u2728\u2733\u2734\u2744\u2747\u274C\u274E\u2753-\u2755\u2757\u2763\u2764\u2795-\u2797\u27A1\u27B0\u27BF\u2934\u2935\u2B05-\u2B07\u2B1B\u2B1C\u2B50\u2B55\u3030\u303D\u3297\u3299]/
430
+
431
+ /**
432
+ * Fast pre-check regex for BMP Private Use Area characters (U+E000-U+F8FF).
433
+ * Used to gate the slow grapheme-by-grapheme path when text sizing is enabled.
434
+ */
435
+ const MAY_CONTAIN_PUA = /[\uE000-\uF8FF]/
436
+
437
+ /**
438
+ * Get the display width of a string (number of terminal columns).
439
+ * Uses string-width which handles:
440
+ * - Wide characters (CJK) -> 2 columns
441
+ * - Regular ASCII -> 1 column
442
+ * - Zero-width characters (combining, ZWJ) -> 0 columns
443
+ * - Emoji -> varies (1 or 2)
444
+ * - ANSI escape sequences -> 0 columns (stripped)
445
+ *
446
+ * Corrects string-width for text-presentation emoji characters
447
+ * (e.g., ⚠ U+26A0) that terminals render as 2 columns wide.
448
+ *
449
+ * Results are cached for performance.
450
+ */
451
+ export function displayWidth(text: string): number {
452
+ if (_scopedMeasurer) return _scopedMeasurer.displayWidth(text)
453
+ // Check cache first
454
+ const cached = displayWidthCache.get(text)
455
+ if (cached !== undefined) {
456
+ return cached
457
+ }
458
+
459
+ let width: number
460
+ // Fast path: if text cannot contain text-presentation emoji, use string-width directly.
461
+ // Default measurer does not enable text sizing, so PUA check uses the constant default.
462
+ const needsSlowPath = MAY_CONTAIN_TEXT_EMOJI.test(text) || (DEFAULT_TEXT_SIZING_ENABLED && MAY_CONTAIN_PUA.test(text))
463
+ if (!needsSlowPath) {
464
+ width = stringWidth(text)
465
+ } else {
466
+ // Slow path: strip ANSI codes first (they'd inflate the grapheme count),
467
+ // then split into graphemes and sum corrected widths
468
+ const stripped = stripAnsi(text)
469
+ width = 0
470
+ for (const grapheme of splitGraphemes(stripped)) {
471
+ width += graphemeWidth(grapheme)
472
+ }
473
+ }
474
+
475
+ displayWidthCache.set(text, width)
476
+ return width
477
+ }
478
+
479
+ /**
480
+ * Get the display width of a single grapheme.
481
+ *
482
+ * Overrides string-width for characters that are Extended_Pictographic with
483
+ * default text presentation. These characters (e.g., ⚠ U+26A0, ☑ U+2611)
484
+ * are reported as width 1 by string-width (per Unicode EAW tables), but most
485
+ * modern terminals render them as 2 columns wide using emoji glyphs.
486
+ *
487
+ * The mismatch causes text after these characters to be placed at the wrong
488
+ * column, leading to truncation or overlap.
489
+ */
490
+ export function graphemeWidth(grapheme: string): number {
491
+ if (_scopedMeasurer) return _scopedMeasurer.graphemeWidth(grapheme)
492
+ const width = stringWidth(grapheme)
493
+ // If string-width already says 2 (or 0), trust it
494
+ if (width !== 1) return width
495
+ // Check if this is a text-presentation emoji that terminals render wide.
496
+ // Uses DEFAULT_TEXT_EMOJI_WIDE (true) — assumes modern terminal.
497
+ if (DEFAULT_TEXT_EMOJI_WIDE && isTextPresentationEmoji(grapheme)) return 2
498
+ // Default module-level function does not enable text sizing.
499
+ // Scoped measurers handle PUA via their own graphemeWidth.
500
+ if (DEFAULT_TEXT_SIZING_ENABLED) {
501
+ const cp = grapheme.codePointAt(0)
502
+ if (cp !== undefined && isPrivateUseArea(cp)) return 2
503
+ }
504
+ return width
505
+ }
506
+
507
+ /**
508
+ * Check if a grapheme is a wide character (takes 2 columns).
509
+ */
510
+ export function isWideGrapheme(grapheme: string): boolean {
511
+ return graphemeWidth(grapheme) === 2
512
+ }
513
+
514
+ /**
515
+ * Check if a grapheme is zero-width (combining character, ZWJ, etc.).
516
+ */
517
+ export function isZeroWidthGrapheme(grapheme: string): boolean {
518
+ return stringWidth(grapheme) === 0
519
+ }
520
+
521
+ // ============================================================================
522
+ // Text Manipulation
523
+ // ============================================================================
524
+
525
+ /**
526
+ * Truncate a string to fit within a given display width.
527
+ * Handles wide characters and ANSI escape sequences (including OSC 8 hyperlinks) correctly.
528
+ *
529
+ * @param text - The text to truncate (may contain ANSI escape sequences)
530
+ * @param maxWidth - Maximum display width
531
+ * @param ellipsis - Ellipsis to append if truncated (default: "...")
532
+ * @returns Truncated string
533
+ */
534
+ export function truncateText(
535
+ text: string,
536
+ maxWidth: number,
537
+ ellipsis = "\u2026", // Unicode ellipsis (single character)
538
+ ): string {
539
+ const textWidth = displayWidth(text)
540
+
541
+ // No truncation needed
542
+ if (textWidth <= maxWidth) {
543
+ return text
544
+ }
545
+
546
+ const ellipsisWidth = displayWidth(ellipsis)
547
+ const targetWidth = maxWidth - ellipsisWidth
548
+
549
+ if (targetWidth <= 0) {
550
+ // Not enough space for even the ellipsis
551
+ return maxWidth > 0 ? ellipsis.slice(0, maxWidth) : ""
552
+ }
553
+
554
+ // Use ANSI-aware grapheme splitting when text contains escape sequences
555
+ // (including OSC 8 hyperlinks) to avoid counting escape bytes as visible width.
556
+ const graphemes = hasAnsi(text) ? splitGraphemesAnsiAware(text) : splitGraphemes(text)
557
+ let result = ""
558
+ let currentWidth = 0
559
+
560
+ for (const grapheme of graphemes) {
561
+ const gWidth = graphemeWidth(grapheme)
562
+ if (currentWidth + gWidth > targetWidth) {
563
+ break
564
+ }
565
+ result += grapheme
566
+ currentWidth += gWidth
567
+ }
568
+
569
+ return result + ellipsis
570
+ }
571
+
572
+ /**
573
+ * Pad a string to a given display width.
574
+ *
575
+ * @param text - The text to pad
576
+ * @param width - Target display width
577
+ * @param align - Alignment: 'left', 'right', or 'center'
578
+ * @param padChar - Character to use for padding (default: space)
579
+ * @returns Padded string
580
+ */
581
+ export function padText(
582
+ text: string,
583
+ width: number,
584
+ align: "left" | "right" | "center" = "left",
585
+ padChar = " ",
586
+ ): string {
587
+ const textWidth = displayWidth(text)
588
+ const padWidth = width - textWidth
589
+
590
+ if (padWidth <= 0) {
591
+ return text
592
+ }
593
+
594
+ const padCharWidth = displayWidth(padChar)
595
+ if (padCharWidth === 0) {
596
+ // Can't pad with zero-width characters
597
+ return text
598
+ }
599
+
600
+ // Calculate number of pad characters needed
601
+ const padCount = Math.floor(padWidth / padCharWidth)
602
+
603
+ switch (align) {
604
+ case "left":
605
+ return text + padChar.repeat(padCount)
606
+ case "right":
607
+ return padChar.repeat(padCount) + text
608
+ case "center": {
609
+ const leftPad = Math.floor(padCount / 2)
610
+ const rightPad = padCount - leftPad
611
+ return padChar.repeat(leftPad) + text + padChar.repeat(rightPad)
612
+ }
613
+ }
614
+ }
615
+
616
+ /**
617
+ * Constrain text to width and height limits.
618
+ * Combines wrapping and truncation to fit text in a box.
619
+ *
620
+ * @param text - Text to constrain (may contain ANSI codes)
621
+ * @param width - Maximum display width per line
622
+ * @param maxLines - Maximum number of lines
623
+ * @param pad - If true, pad lines to full width
624
+ * @param ellipsis - Custom ellipsis character (default: "…")
625
+ * @returns Object with lines array and truncated flag
626
+ */
627
+ export function constrainText(
628
+ text: string,
629
+ width: number,
630
+ maxLines: number,
631
+ pad = false,
632
+ ellipsis = "…",
633
+ ): { lines: string[]; truncated: boolean } {
634
+ const allLines = wrapText(text, width)
635
+ const truncated = allLines.length > maxLines
636
+ let lines = allLines.slice(0, maxLines)
637
+
638
+ if (truncated && lines.length > 0) {
639
+ const lastIdx = lines.length - 1
640
+ const lastLine = lines[lastIdx]
641
+ if (lastLine) {
642
+ const ellipsisLen = displayWidth(ellipsis)
643
+ const lastLineLen = displayWidth(lastLine)
644
+ if (lastLineLen + ellipsisLen <= width) {
645
+ lines[lastIdx] = lastLine + ellipsis
646
+ } else {
647
+ lines[lastIdx] = truncateText(lastLine, width, ellipsis)
648
+ }
649
+ }
650
+ }
651
+
652
+ if (pad) {
653
+ lines = lines.map((line) => padText(line, width))
654
+ }
655
+
656
+ return { lines, truncated }
657
+ }
658
+
659
+ /**
660
+ * Check if a grapheme is a word boundary character (space, hyphen, etc.)
661
+ */
662
+ function isWordBoundary(grapheme: string): boolean {
663
+ // Common word boundary characters
664
+ return grapheme === " " || grapheme === "-" || grapheme === "\t"
665
+ }
666
+
667
+ /**
668
+ * Look ahead from a space to check if the next word is a single-character
669
+ * operator (like +, =, *, /, etc.) followed by another space. Breaking before
670
+ * such operators looks bad — e.g. "$12k\n+ $400" — so we suppress the break
671
+ * point to keep the operator with its left operand.
672
+ *
673
+ * Accepts an explicit graphemeWidth function so it works with both the
674
+ * module-level default and per-measurer instances.
675
+ */
676
+ function isBreakBeforeOperatorWith(graphemes: string[], spaceIndex: number, gWidthFn: (g: string) => number): boolean {
677
+ // Look for pattern: [current space] [operator] [space]
678
+ // spaceIndex is the index of the current space in the graphemes array
679
+ let j = spaceIndex + 1
680
+ // Skip any zero-width characters (ANSI escapes)
681
+ while (j < graphemes.length && gWidthFn(graphemes[j]!) === 0) j++
682
+ if (j >= graphemes.length) return false
683
+ const nextChar = graphemes[j]!
684
+ // Must be a single visible character that is not alphanumeric or space
685
+ if (gWidthFn(nextChar) !== 1) return false
686
+ if (/^[a-zA-Z0-9\s]$/.test(nextChar)) return false
687
+ // Check that it's followed by a space (it's an infix operator, not a prefix)
688
+ let k = j + 1
689
+ while (k < graphemes.length && gWidthFn(graphemes[k]!) === 0) k++
690
+ if (k >= graphemes.length) return false
691
+ return graphemes[k] === " "
692
+ }
693
+
694
+ /**
695
+ * Check if a grapheme can break anywhere (CJK characters).
696
+ * CJK text doesn't use spaces between words, so any character boundary is valid.
697
+ */
698
+ function canBreakAnywhere(grapheme: string): boolean {
699
+ return isCJK(grapheme)
700
+ }
701
+
702
+ // ANSI CSI pattern: ESC [ (params) (letter)
703
+ const ANSI_CSI_RE = /^\x1b\[[0-9;:?]*[A-Za-z]/
704
+ // ANSI OSC pattern: ESC ] ... (BEL or ST)
705
+ const ANSI_OSC_RE = /^\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/
706
+ // Single-char escape: ESC followed by one letter
707
+ const ANSI_SINGLE_RE = /^\x1b[DME78(B]/
708
+
709
+ /**
710
+ * Split text into graphemes, keeping ANSI escape sequences as single zero-width tokens.
711
+ * Without this, `splitGraphemes` would split `\x1b[38;5;1m` into individual characters
712
+ * like `[`, `3`, `8`, `;`, etc., each consuming display width.
713
+ */
714
+ function splitGraphemesAnsiAware(text: string): string[] {
715
+ if (!hasAnsi(text)) {
716
+ return splitGraphemes(text)
717
+ }
718
+
719
+ const result: string[] = []
720
+ let pos = 0
721
+
722
+ while (pos < text.length) {
723
+ if (text[pos] === "\x1b") {
724
+ // Try to match an ANSI sequence starting at pos
725
+ const remaining = text.slice(pos)
726
+ const csi = remaining.match(ANSI_CSI_RE)
727
+ if (csi) {
728
+ result.push(csi[0])
729
+ pos += csi[0].length
730
+ continue
731
+ }
732
+ const osc = remaining.match(ANSI_OSC_RE)
733
+ if (osc) {
734
+ result.push(osc[0])
735
+ pos += osc[0].length
736
+ continue
737
+ }
738
+ const single = remaining.match(ANSI_SINGLE_RE)
739
+ if (single) {
740
+ result.push(single[0])
741
+ pos += single[0].length
742
+ continue
743
+ }
744
+ }
745
+
746
+ // Find the next ESC or end of string
747
+ const nextEsc = text.indexOf("\x1b", pos + 1)
748
+ const chunk = nextEsc === -1 ? text.slice(pos) : text.slice(pos, nextEsc)
749
+
750
+ // Split this non-ANSI chunk into graphemes
751
+ for (const g of splitGraphemes(chunk)) {
752
+ result.push(g)
753
+ }
754
+ pos += chunk.length
755
+ }
756
+
757
+ return result
758
+ }
759
+
760
+ /**
761
+ * Wrap text to fit within a given width.
762
+ *
763
+ * Implements word-boundary wrapping:
764
+ * 1. Breaks at word boundaries (spaces, hyphens) when possible
765
+ * 2. Falls back to character wrap only when necessary (very long words)
766
+ * 3. Handles CJK text properly (can break anywhere since CJK has no word spaces)
767
+ * 4. Preserves intentional line breaks
768
+ *
769
+ * @param text - The text to wrap (may contain ANSI escape sequences)
770
+ * @param width - Maximum display width per line
771
+ * @param preserveNewlines - Whether to preserve existing newlines
772
+ * @param trim - Trim trailing spaces on broken lines and skip leading spaces on continuation lines (useful for rendering)
773
+ * @returns Array of wrapped lines
774
+ */
775
+ export function wrapText(text: string, width: number, preserveNewlines = true, trim = false): string[] {
776
+ return wrapTextWithMeasurer(text, width, _scopedMeasurer ?? undefined, trim, false, preserveNewlines)
777
+ }
778
+
779
+ /**
780
+ * Internal: wrap text using an explicit measurer for grapheme width calculations.
781
+ * When measurer is undefined, falls back to the module-level graphemeWidth.
782
+ */
783
+ function wrapTextWithMeasurer(
784
+ text: string,
785
+ width: number,
786
+ measurer: Measurer | undefined,
787
+ trim = false,
788
+ _hard = false,
789
+ preserveNewlines = true,
790
+ ): string[] {
791
+ if (width <= 0) {
792
+ return []
793
+ }
794
+
795
+ const gWidthFn = measurer ? measurer.graphemeWidth.bind(measurer) : graphemeWidth
796
+
797
+ const lines: string[] = []
798
+
799
+ // Split by newlines first if preserving
800
+ const inputLines = preserveNewlines ? text.split("\n") : [text.replace(/\n/g, " ")]
801
+
802
+ for (const line of inputLines) {
803
+ // Handle empty lines
804
+ if (line === "") {
805
+ lines.push("")
806
+ continue
807
+ }
808
+
809
+ // If the line contains ANSI escape sequences, split them out so they
810
+ // don't consume display width. We interleave visible graphemes with
811
+ // zero-width ANSI "tokens" that are appended to currentLine untouched.
812
+ const graphemes = splitGraphemesAnsiAware(line)
813
+ let currentLine = ""
814
+ let currentWidth = 0
815
+ let isFirstLineOfParagraph = true
816
+
817
+ // Track the last valid break point
818
+ let lastBreakIndex = -1 // Index in currentLine (character position)
819
+ let lastBreakWidth = 0 // Width at break point
820
+ let lastBreakGraphemeIndex = -1 // Index in graphemes array
821
+
822
+ for (let i = 0; i < graphemes.length; i++) {
823
+ const grapheme = graphemes[i]!
824
+ const gWidth = gWidthFn(grapheme)
825
+
826
+ // Handle zero-width characters
827
+ if (gWidth === 0) {
828
+ currentLine += grapheme
829
+ continue
830
+ }
831
+
832
+ // In trim mode, skip leading spaces on continuation lines
833
+ if (trim && !isFirstLineOfParagraph && currentWidth === 0 && isWordBoundary(grapheme) && grapheme !== "-") {
834
+ continue
835
+ }
836
+
837
+ // Check if this grapheme is a break point
838
+ // Break AFTER spaces/hyphens, or BEFORE CJK characters
839
+ if (isWordBoundary(grapheme)) {
840
+ // Include the boundary character, then mark as break point
841
+ if (currentWidth + gWidth <= width) {
842
+ currentLine += grapheme
843
+ currentWidth += gWidth
844
+ // Suppress break point if the next word is a lone operator (e.g. "+", "=")
845
+ // to avoid orphaning operators at the start of the next line.
846
+ if (grapheme !== " " || !isBreakBeforeOperatorWith(graphemes, i, gWidthFn)) {
847
+ lastBreakIndex = currentLine.length
848
+ lastBreakWidth = currentWidth
849
+ lastBreakGraphemeIndex = i + 1
850
+ }
851
+ continue
852
+ }
853
+ // Space/hyphen doesn't fit — break here (before the boundary char).
854
+ // The current line is complete; the boundary char is consumed as the break.
855
+ if (currentLine) {
856
+ let lineToAdd = currentLine
857
+ if (trim) lineToAdd = lineToAdd.trimEnd()
858
+ lines.push(lineToAdd)
859
+ isFirstLineOfParagraph = false
860
+ }
861
+ currentLine = ""
862
+ currentWidth = 0
863
+ lastBreakIndex = -1
864
+ lastBreakWidth = 0
865
+ lastBreakGraphemeIndex = -1
866
+ continue
867
+ } else if (canBreakAnywhere(grapheme)) {
868
+ // CJK: can break before this character
869
+ lastBreakIndex = currentLine.length
870
+ lastBreakWidth = currentWidth
871
+ lastBreakGraphemeIndex = i
872
+ }
873
+
874
+ // Would this grapheme overflow?
875
+ if (currentWidth + gWidth > width) {
876
+ if (lastBreakIndex > 0) {
877
+ // We have a valid break point - use it
878
+ let lineToAdd = currentLine.slice(0, lastBreakIndex)
879
+ if (trim) lineToAdd = lineToAdd.trimEnd()
880
+ lines.push(lineToAdd)
881
+ isFirstLineOfParagraph = false
882
+
883
+ // Reset and continue from break point
884
+ currentLine = currentLine.slice(lastBreakIndex)
885
+ currentWidth = currentWidth - lastBreakWidth
886
+
887
+ // Rewind to process graphemes after the break
888
+ i = lastBreakGraphemeIndex - 1
889
+ currentLine = ""
890
+ currentWidth = 0
891
+ lastBreakIndex = -1
892
+ lastBreakWidth = 0
893
+ lastBreakGraphemeIndex = -1
894
+ } else {
895
+ // No break point found - must do character wrap
896
+ if (currentLine) {
897
+ if (trim) currentLine = currentLine.trimEnd()
898
+ lines.push(currentLine)
899
+ isFirstLineOfParagraph = false
900
+ }
901
+ currentLine = grapheme
902
+ currentWidth = gWidth
903
+ lastBreakIndex = -1
904
+ lastBreakWidth = 0
905
+ lastBreakGraphemeIndex = -1
906
+ }
907
+ } else {
908
+ currentLine += grapheme
909
+ currentWidth += gWidth
910
+ }
911
+ }
912
+
913
+ // Push remaining content
914
+ if (currentLine) {
915
+ lines.push(currentLine)
916
+ }
917
+ }
918
+
919
+ return lines
920
+ }
921
+
922
+ /**
923
+ * Slice text by display width (from start).
924
+ * Returns the first `maxWidth` columns of text.
925
+ * Uses the default measurer for width calculations.
926
+ * Handles both ANSI-styled and plain text.
927
+ *
928
+ * @param text - The text to slice
929
+ * @param maxWidth - Maximum display width to keep from the start
930
+ * @returns Sliced string from the start
931
+ */
932
+ export function sliceByWidth(text: string, maxWidth: number): string {
933
+ return (_scopedMeasurer ?? getDefaultMeasurer()).sliceByWidth(text, maxWidth)
934
+ }
935
+
936
+ /**
937
+ * Slice a string by display width range.
938
+ * Like string.slice() but works with display columns.
939
+ *
940
+ * @param text - The text to slice
941
+ * @param start - Start display column (inclusive)
942
+ * @param end - End display column (exclusive)
943
+ * @returns Sliced string
944
+ */
945
+ export function sliceByWidthRange(text: string, start: number, end?: number): string {
946
+ const graphemes = splitGraphemes(text)
947
+ let result = ""
948
+ let currentCol = 0
949
+ const endCol = end ?? Number.POSITIVE_INFINITY
950
+
951
+ for (const grapheme of graphemes) {
952
+ const gWidth = graphemeWidth(grapheme)
953
+
954
+ // Haven't reached start yet
955
+ if (currentCol + gWidth <= start) {
956
+ currentCol += gWidth
957
+ continue
958
+ }
959
+
960
+ // Past the end
961
+ if (currentCol >= endCol) {
962
+ break
963
+ }
964
+
965
+ // This grapheme is at least partially in range
966
+ result += grapheme
967
+ currentCol += gWidth
968
+ }
969
+
970
+ return result
971
+ }
972
+
973
+ /**
974
+ * Slice text by display width from the end.
975
+ * Returns the last `maxWidth` columns of text.
976
+ * Uses the default measurer for width calculations.
977
+ *
978
+ * @param text - The text to slice
979
+ * @param maxWidth - Maximum display width to keep from the end
980
+ * @returns Sliced string from the end
981
+ */
982
+ export function sliceByWidthFromEnd(text: string, maxWidth: number): string {
983
+ return (_scopedMeasurer ?? getDefaultMeasurer()).sliceByWidthFromEnd(text, maxWidth)
984
+ }
985
+
986
+ // ============================================================================
987
+ // Buffer Writing
988
+ // ============================================================================
989
+
990
+ /**
991
+ * Write styled text to a terminal buffer.
992
+ *
993
+ * Handles:
994
+ * - Multi-byte graphemes (emoji, combining characters)
995
+ * - Wide characters (CJK) that take 2 cells
996
+ * - Zero-width characters (appended to previous cell)
997
+ *
998
+ * @param buffer - The buffer to write to
999
+ * @param x - Starting column
1000
+ * @param y - Row
1001
+ * @param text - Text to write
1002
+ * @param style - Style to apply
1003
+ * @returns The ending column (x + display_width)
1004
+ */
1005
+ export function writeTextToBuffer(
1006
+ buffer: TerminalBuffer,
1007
+ x: number,
1008
+ y: number,
1009
+ text: string,
1010
+ style: Style = { fg: null, bg: null, attrs: {} },
1011
+ ): number {
1012
+ const graphemes = splitGraphemes(text)
1013
+ let col = x
1014
+ let combineCell: Cell | null = null
1015
+
1016
+ for (const grapheme of graphemes) {
1017
+ const width = graphemeWidth(grapheme)
1018
+
1019
+ if (width === 0) {
1020
+ // Zero-width character: combine with previous cell.
1021
+ // Use readCellInto to avoid allocating a fresh Cell on each combine.
1022
+ if (col > 0 && buffer.inBounds(col - 1, y)) {
1023
+ // Lazy-init reusable cell (zero-width combining is uncommon)
1024
+ combineCell ??= createMutableCell()
1025
+ buffer.readCellInto(col - 1, y, combineCell)
1026
+ combineCell.char = combineCell.char + grapheme
1027
+ buffer.setCell(col - 1, y, combineCell)
1028
+ }
1029
+ } else if (width === 1) {
1030
+ // Normal single-width character
1031
+ if (buffer.inBounds(col, y)) {
1032
+ buffer.setCell(col, y, {
1033
+ char: grapheme,
1034
+ fg: style.fg,
1035
+ bg: style.bg,
1036
+ attrs: style.attrs,
1037
+ wide: false,
1038
+ continuation: false,
1039
+ })
1040
+ }
1041
+ col++
1042
+ } else if (width === 2) {
1043
+ // Wide character: takes 2 cells
1044
+ // For text-presentation emoji, add VS16 so terminals render at 2 columns
1045
+ const outputChar = ensureEmojiPresentation(grapheme)
1046
+ if (buffer.inBounds(col, y)) {
1047
+ buffer.setCell(col, y, {
1048
+ char: outputChar,
1049
+ fg: style.fg,
1050
+ bg: style.bg,
1051
+ attrs: style.attrs,
1052
+ wide: true,
1053
+ continuation: false,
1054
+ })
1055
+ }
1056
+ if (buffer.inBounds(col + 1, y)) {
1057
+ buffer.setCell(col + 1, y, {
1058
+ char: "",
1059
+ fg: style.fg,
1060
+ bg: style.bg,
1061
+ attrs: style.attrs,
1062
+ wide: false,
1063
+ continuation: true,
1064
+ })
1065
+ }
1066
+ col += 2
1067
+ }
1068
+
1069
+ // Stop if we've gone past the buffer edge
1070
+ if (col >= buffer.width) {
1071
+ break
1072
+ }
1073
+ }
1074
+
1075
+ return col
1076
+ }
1077
+
1078
+ /**
1079
+ * Write styled text to a buffer with automatic truncation.
1080
+ *
1081
+ * @param buffer - The buffer to write to
1082
+ * @param x - Starting column
1083
+ * @param y - Row
1084
+ * @param text - Text to write
1085
+ * @param maxWidth - Maximum width (truncate if exceeded)
1086
+ * @param style - Style to apply
1087
+ * @param ellipsis - Ellipsis for truncated text
1088
+ */
1089
+ export function writeTextTruncated(
1090
+ buffer: TerminalBuffer,
1091
+ x: number,
1092
+ y: number,
1093
+ text: string,
1094
+ maxWidth: number,
1095
+ style: Style = { fg: null, bg: null, attrs: {} },
1096
+ ellipsis = "\u2026",
1097
+ ): void {
1098
+ const textWidth = displayWidth(text)
1099
+
1100
+ if (textWidth <= maxWidth) {
1101
+ writeTextToBuffer(buffer, x, y, text, style)
1102
+ } else {
1103
+ const truncated = truncateText(text, maxWidth, ellipsis)
1104
+ writeTextToBuffer(buffer, x, y, truncated, style)
1105
+ }
1106
+ }
1107
+
1108
+ /**
1109
+ * Write multiple lines of styled text to a buffer.
1110
+ *
1111
+ * @param buffer - The buffer to write to
1112
+ * @param x - Starting column
1113
+ * @param y - Starting row
1114
+ * @param lines - Lines to write
1115
+ * @param style - Style to apply
1116
+ */
1117
+ export function writeLinesToBuffer(
1118
+ buffer: TerminalBuffer,
1119
+ x: number,
1120
+ y: number,
1121
+ lines: string[],
1122
+ style: Style = { fg: null, bg: null, attrs: {} },
1123
+ ): void {
1124
+ for (let i = 0; i < lines.length; i++) {
1125
+ if (y + i >= buffer.height) break
1126
+ writeTextToBuffer(buffer, x, y + i, lines[i]!, style)
1127
+ }
1128
+ }
1129
+
1130
+ // ============================================================================
1131
+ // ANSI-Aware Operations
1132
+ // ============================================================================
1133
+
1134
+ /**
1135
+ * Strip all ANSI escape codes from a string.
1136
+ *
1137
+ * Handles:
1138
+ * - CSI sequences (cursor movement, colors, SGR, etc.)
1139
+ * - OSC sequences (window titles, hyperlinks)
1140
+ * - Single-character escape sequences
1141
+ * - Character set selection
1142
+ */
1143
+ export function stripAnsi(text: string): string {
1144
+ return text
1145
+ .replace(/\x1b\[[0-9;:?]*[A-Za-z]/g, "") // ESC CSI sequences (including SGR with colons)
1146
+ .replace(/\x9b[0-9;:?]*[A-Za-z]/g, "") // C1 CSI sequences
1147
+ .replace(/\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g, "") // ESC OSC sequences
1148
+ .replace(/\x9d[^\x07\x1b\x9c]*(?:\x07|\x1b\\|\x9c)/g, "") // C1 OSC sequences
1149
+ .replace(/\x1b[DME78]/g, "") // Single-char sequences
1150
+ .replace(/\x1b\(B/g, "") // Character set selection
1151
+ }
1152
+
1153
+ /**
1154
+ * Get display width of text with ANSI sequences.
1155
+ * ANSI sequences don't contribute to display width.
1156
+ */
1157
+ export function displayWidthAnsi(text: string): number {
1158
+ return displayWidth(stripAnsi(text))
1159
+ }
1160
+
1161
+ /**
1162
+ * Truncate text that may contain ANSI sequences.
1163
+ * Preserves ANSI codes while truncating visible characters.
1164
+ *
1165
+ * Note: This is a simplified implementation that strips ANSI before
1166
+ * truncation. For proper ANSI-aware truncation, consider using
1167
+ * slice-ansi or similar library.
1168
+ */
1169
+ export function truncateAnsi(text: string, maxWidth: number, ellipsis = "\u2026"): string {
1170
+ // Simple approach: if text has ANSI, strip and truncate
1171
+ // A more sophisticated approach would preserve styles
1172
+ const stripped = stripAnsi(text)
1173
+ return truncateText(stripped, maxWidth, ellipsis)
1174
+ }
1175
+
1176
+ // ============================================================================
1177
+ // ANSI Parsing
1178
+ // ============================================================================
1179
+
1180
+ // BG_OVERRIDE_CODE is imported from ansi and re-exported at top of file
1181
+
1182
+ /** Styled text segment with associated ANSI colors/attributes */
1183
+ export interface StyledSegment {
1184
+ text: string
1185
+ fg?: number | null // SGR color code (30-37, 90-97, or 38;5;N / 38;2;r;g;b)
1186
+ bg?: number | null // SGR color code (40-47, 100-107, or 48;5;N / 48;2;r;g;b)
1187
+ /**
1188
+ * Underline color (SGR 58).
1189
+ * Same format as fg/bg: packed RGB with 0x1000000 marker, or 256-color index.
1190
+ */
1191
+ underlineColor?: number | null
1192
+ bold?: boolean
1193
+ dim?: boolean
1194
+ italic?: boolean
1195
+ underline?: boolean
1196
+ /**
1197
+ * Underline style variant (SGR 4:x).
1198
+ * Uses UnderlineStyle from buffer.ts.
1199
+ */
1200
+ underlineStyle?: UnderlineStyle
1201
+ inverse?: boolean
1202
+ bgOverride?: boolean // Set when BG_OVERRIDE_CODE (9999) is present
1203
+ /**
1204
+ * OSC 8 hyperlink URL.
1205
+ * Set when the segment is inside an OSC 8 hyperlink sequence.
1206
+ */
1207
+ hyperlink?: string
1208
+ /**
1209
+ * True when the foreground color was specified using colon-separated SGR
1210
+ * (e.g., `38:2::255:100:0m` instead of `38;2;255;100;0m`).
1211
+ * Used to preserve the original format in round-trip output.
1212
+ */
1213
+ colonFg?: boolean
1214
+ /**
1215
+ * True when the background color was specified using colon-separated SGR.
1216
+ */
1217
+ colonBg?: boolean
1218
+ }
1219
+
1220
+ /**
1221
+ * Map SGR 4:x subparameter to underline style.
1222
+ * 0=none, 1=single, 2=double, 3=curly, 4=dotted, 5=dashed
1223
+ */
1224
+ function parseUnderlineStyle(subparam: number): UnderlineStyle {
1225
+ switch (subparam) {
1226
+ case 0:
1227
+ return false
1228
+ case 1:
1229
+ return "single"
1230
+ case 2:
1231
+ return "double"
1232
+ case 3:
1233
+ return "curly"
1234
+ case 4:
1235
+ return "dotted"
1236
+ case 5:
1237
+ return "dashed"
1238
+ default:
1239
+ return "single" // Unknown, default to single
1240
+ }
1241
+ }
1242
+
1243
+ /**
1244
+ * Parse text with ANSI escape sequences into styled segments.
1245
+ * Handles basic SGR (Select Graphic Rendition) codes including:
1246
+ * - Standard colors (30-37, 40-47, 90-97, 100-107)
1247
+ * - Extended colors (38;5;N, 48;5;N for 256-color, 38;2;r;g;b, 48;2;r;g;b for RGB)
1248
+ * - Underline styles (4:x where x = 0-5)
1249
+ * - Underline color (58;5;N for 256-color, 58;2;r;g;b for RGB)
1250
+ */
1251
+ export function parseAnsiText(text: string): StyledSegment[] {
1252
+ const segments: StyledSegment[] = []
1253
+
1254
+ // Step 1: Strip non-SGR CSI sequences (cursor movement, erase, etc.) that would
1255
+ // otherwise leak through as literal text. SGR sequences end in 'm'; all
1256
+ // other CSI sequences (ending in A-L, H, J, K, S, T, etc.) are stripped.
1257
+ // Handles both ESC-based CSI (\x1b[) and C1 CSI (\x9b).
1258
+ // This must happen BEFORE OSC 8 processing so hyperlink position tracking
1259
+ // is based on the cleaned text (no position drift from stripped sequences).
1260
+ const sanitizedText = text.replace(/\x1b\[[0-9;:]*[A-LN-Za-ln-z@`]/g, "").replace(/\x9b[0-9;:]*[A-LN-Za-ln-z@`]/g, "")
1261
+
1262
+ // Step 2: Strip OSC 8 hyperlink sequences and build a position-to-URL map.
1263
+ // OSC 8 format: \x1b]8;;URL(\x1b\\ | \x07) for open, \x1b]8;;(\x1b\\ | \x07) for close.
1264
+ // Also handles C1 OSC (\x9d) form: \x9d8;;URL(\x1b\\ | \x07 | \x9c)
1265
+ // We strip these from the text before SGR parsing and track which character
1266
+ // positions map to which hyperlink URL.
1267
+ //
1268
+ // Hyperlink format metadata is encoded in the URL using control char prefixes:
1269
+ // \x01c1b\x02 = C1 OSC intro + BEL terminator
1270
+ // \x01c1s\x02 = C1 OSC intro + ST terminator
1271
+ // \x01e7b\x02 = ESC OSC intro + BEL terminator
1272
+ // (no prefix) = ESC OSC intro + ST terminator (default)
1273
+ // bufferToStyledText reads these prefixes to emit the original format.
1274
+ const oscPattern = /(?:\x1b\]|\x9d)8;;([^\x07\x1b\x9c]*)(?:\x07|\x1b\\|\x9c)/g
1275
+ let currentHyperlink: string | undefined
1276
+ // Map from character index in cleaned text to hyperlink URL
1277
+ const hyperlinkRanges: Array<{ start: number; end: number; url: string }> = []
1278
+ let rangeStart = -1
1279
+ let cleaned = ""
1280
+ let oscMatch: RegExpExecArray | null
1281
+ let oscLastIndex = 0
1282
+
1283
+ while ((oscMatch = oscPattern.exec(sanitizedText)) !== null) {
1284
+ // Append text between last OSC and this one (preserving SGR codes)
1285
+ cleaned += sanitizedText.slice(oscLastIndex, oscMatch.index)
1286
+ const url = oscMatch[1]!
1287
+
1288
+ // Detect format: C1 vs ESC intro, BEL vs ST terminator
1289
+ const matchStr = oscMatch[0]
1290
+ const isC1 = matchStr.charCodeAt(0) === 0x9d
1291
+ const lastChar = matchStr.charCodeAt(matchStr.length - 1)
1292
+ const isBel = lastChar === 0x07
1293
+ const isSt9c = lastChar === 0x9c
1294
+
1295
+ if (url === "") {
1296
+ // Close hyperlink — format prefix matches the open sequence
1297
+ if (currentHyperlink && rangeStart >= 0) {
1298
+ hyperlinkRanges.push({ start: rangeStart, end: cleaned.length, url: currentHyperlink })
1299
+ }
1300
+ currentHyperlink = undefined
1301
+ rangeStart = -1
1302
+ } else {
1303
+ // Open hyperlink — encode format metadata in URL prefix
1304
+ if (currentHyperlink && rangeStart >= 0) {
1305
+ // Close previous unclosed hyperlink
1306
+ hyperlinkRanges.push({ start: rangeStart, end: cleaned.length, url: currentHyperlink })
1307
+ }
1308
+ // Encode hyperlink format as a prefix on the URL:
1309
+ // \x01c1b\x02 = C1 intro + BEL terminator
1310
+ // \x01c1s\x02 = C1 intro + ST terminator (ESC \ or C1 ST \x9c)
1311
+ // \x01e7b\x02 = ESC intro + BEL terminator
1312
+ // no prefix = ESC intro + ST terminator (default)
1313
+ let encodedUrl = url
1314
+ if (isC1 && (isBel || isSt9c)) {
1315
+ encodedUrl = `\x01c1b\x02${url}`
1316
+ } else if (isC1) {
1317
+ encodedUrl = `\x01c1s\x02${url}`
1318
+ } else if (isBel) {
1319
+ encodedUrl = `\x01e7b\x02${url}`
1320
+ }
1321
+ // else: ESC + ST = default, no prefix needed
1322
+ currentHyperlink = encodedUrl
1323
+ rangeStart = cleaned.length
1324
+ }
1325
+
1326
+ oscLastIndex = oscMatch.index + oscMatch[0].length
1327
+ }
1328
+ // Append remaining text after last OSC
1329
+ cleaned += sanitizedText.slice(oscLastIndex)
1330
+ // Close any still-open hyperlink
1331
+ if (currentHyperlink && rangeStart >= 0) {
1332
+ hyperlinkRanges.push({ start: rangeStart, end: cleaned.length, url: currentHyperlink })
1333
+ }
1334
+
1335
+ // If no OSC 8 sequences found, use sanitized text for efficiency
1336
+ const processText = hyperlinkRanges.length > 0 ? cleaned : sanitizedText
1337
+
1338
+ // Extended pattern: matches SGR with semicolons AND colons (for 4:x, 58:2::r:g:b)
1339
+ // Handles both ESC-based CSI (\x1b[) and C1 CSI (\x9b)
1340
+ const ansiPattern = /(?:\x1b\[|\x9b)([0-9;:]*)m/g
1341
+
1342
+ let currentStyle: Omit<StyledSegment, "text"> = {}
1343
+ let lastIndex = 0
1344
+ let match: RegExpExecArray | null
1345
+
1346
+ // Helper to find hyperlink URL for a position in the cleaned text.
1347
+ // Positions in cleaned text map directly to hyperlinkRanges since OSC 8
1348
+ // sequences were stripped but SGR sequences remain at the same indices.
1349
+ function getHyperlinkAt(pos: number): string | undefined {
1350
+ for (const range of hyperlinkRanges) {
1351
+ if (pos >= range.start && pos < range.end) return range.url
1352
+ }
1353
+ return undefined
1354
+ }
1355
+
1356
+ while ((match = ansiPattern.exec(processText)) !== null) {
1357
+ // Add text before this escape sequence
1358
+ if (match.index > lastIndex) {
1359
+ const content = processText.slice(lastIndex, match.index)
1360
+ if (content.length > 0) {
1361
+ if (hyperlinkRanges.length > 0) {
1362
+ // Split content into runs by hyperlink URL.
1363
+ // lastIndex is the position of content[0] in processText/cleaned.
1364
+ let segStart = 0
1365
+ for (let ci = 0; ci < content.length; ci++) {
1366
+ const hl = getHyperlinkAt(lastIndex + ci)
1367
+ const prevHl = ci > 0 ? getHyperlinkAt(lastIndex + ci - 1) : undefined
1368
+ if (ci > 0 && hl !== prevHl) {
1369
+ const sub = content.slice(segStart, ci)
1370
+ if (sub.length > 0) {
1371
+ const seg: StyledSegment = { text: sub, ...currentStyle }
1372
+ if (prevHl) seg.hyperlink = prevHl
1373
+ segments.push(seg)
1374
+ }
1375
+ segStart = ci
1376
+ }
1377
+ }
1378
+ // Push remaining
1379
+ const sub = content.slice(segStart)
1380
+ if (sub.length > 0) {
1381
+ const hl = getHyperlinkAt(lastIndex + segStart)
1382
+ const seg: StyledSegment = { text: sub, ...currentStyle }
1383
+ if (hl) seg.hyperlink = hl
1384
+ segments.push(seg)
1385
+ }
1386
+ } else {
1387
+ segments.push({ text: content, ...currentStyle })
1388
+ }
1389
+ }
1390
+ }
1391
+
1392
+ // Parse SGR codes - split by semicolon first, then handle colon subparams
1393
+ const rawParams = match[1]!
1394
+
1395
+ // Handle colon-separated sequences (like 4:3 for curly underline, 58:2::r:g:b)
1396
+ // Split by semicolon first to get top-level params
1397
+ const params = rawParams.split(";")
1398
+
1399
+ for (let i = 0; i < params.length; i++) {
1400
+ const param = params[i]!
1401
+
1402
+ // Check if this param has colon subparameters (e.g., "4:3", "58:2::255:0:0")
1403
+ if (param.includes(":")) {
1404
+ const subparts = param.split(":").map((s) => (s === "" ? 0 : Number(s)))
1405
+ const mainCode = subparts[0]!
1406
+
1407
+ if (mainCode === 4) {
1408
+ // SGR 4:x - underline style
1409
+ const styleCode = subparts[1] ?? 1
1410
+ currentStyle.underlineStyle = parseUnderlineStyle(styleCode)
1411
+ currentStyle.underline = currentStyle.underlineStyle !== false
1412
+ } else if (mainCode === 58) {
1413
+ // SGR 58 - underline color
1414
+ // Format: 58:5:N (256-color) or 58:2::r:g:b (RGB, note double colon)
1415
+ if (subparts[1] === 5 && subparts[2] !== undefined) {
1416
+ currentStyle.underlineColor = subparts[2]
1417
+ } else if (subparts[1] === 2) {
1418
+ // RGB: 58:2::r:g:b (indices 3,4,5 after the empty slot)
1419
+ // or 58:2:r:g:b (indices 2,3,4)
1420
+ // Handle both formats by looking for valid RGB values
1421
+ const r = subparts[3] ?? subparts[2] ?? 0
1422
+ const g = subparts[4] ?? subparts[3] ?? 0
1423
+ const b = subparts[5] ?? subparts[4] ?? 0
1424
+ currentStyle.underlineColor = 0x1000000 | ((r & 0xff) << 16) | ((g & 0xff) << 8) | (b & 0xff)
1425
+ }
1426
+ } else if (mainCode === 38) {
1427
+ // SGR 38:2::r:g:b or 38:5:N format (colon-separated)
1428
+ if (subparts[1] === 5 && subparts[2] !== undefined) {
1429
+ currentStyle.fg = subparts[2]
1430
+ currentStyle.colonFg = true
1431
+ } else if (subparts[1] === 2) {
1432
+ const r = subparts[3] ?? subparts[2] ?? 0
1433
+ const g = subparts[4] ?? subparts[3] ?? 0
1434
+ const b = subparts[5] ?? subparts[4] ?? 0
1435
+ currentStyle.fg = 0x1000000 | ((r & 0xff) << 16) | ((g & 0xff) << 8) | (b & 0xff)
1436
+ currentStyle.colonFg = true
1437
+ }
1438
+ } else if (mainCode === 48) {
1439
+ // SGR 48:2::r:g:b or 48:5:N format (colon-separated)
1440
+ if (subparts[1] === 5 && subparts[2] !== undefined) {
1441
+ currentStyle.bg = subparts[2]
1442
+ currentStyle.colonBg = true
1443
+ } else if (subparts[1] === 2) {
1444
+ const r = subparts[3] ?? subparts[2] ?? 0
1445
+ const g = subparts[4] ?? subparts[3] ?? 0
1446
+ const b = subparts[5] ?? subparts[4] ?? 0
1447
+ currentStyle.bg = 0x1000000 | ((r & 0xff) << 16) | ((g & 0xff) << 8) | (b & 0xff)
1448
+ currentStyle.colonBg = true
1449
+ }
1450
+ }
1451
+ continue
1452
+ }
1453
+
1454
+ // Standard semicolon-separated params
1455
+ const code = Number(param)
1456
+ switch (code) {
1457
+ case 0:
1458
+ // Reset
1459
+ currentStyle = {}
1460
+ break
1461
+ case 1:
1462
+ currentStyle.bold = true
1463
+ break
1464
+ case 2:
1465
+ currentStyle.dim = true
1466
+ break
1467
+ case 3:
1468
+ currentStyle.italic = true
1469
+ break
1470
+ case 4:
1471
+ // Plain SGR 4 - simple underline (no subparam)
1472
+ currentStyle.underline = true
1473
+ currentStyle.underlineStyle = "single"
1474
+ break
1475
+ case 7:
1476
+ currentStyle.inverse = true
1477
+ break
1478
+ case 22:
1479
+ currentStyle.bold = false
1480
+ currentStyle.dim = false
1481
+ break
1482
+ case 23:
1483
+ currentStyle.italic = false
1484
+ break
1485
+ case 24:
1486
+ // SGR 24 - underline off
1487
+ currentStyle.underline = false
1488
+ currentStyle.underlineStyle = false
1489
+ break
1490
+ case 27:
1491
+ currentStyle.inverse = false
1492
+ break
1493
+ case 30:
1494
+ case 31:
1495
+ case 32:
1496
+ case 33:
1497
+ case 34:
1498
+ case 35:
1499
+ case 36:
1500
+ case 37:
1501
+ currentStyle.fg = code
1502
+ break
1503
+ case 38: {
1504
+ // Extended color: 38;5;N (256 color) or 38;2;r;g;b (true color)
1505
+ // Semicolon-separated — clear colonFg flag
1506
+ const nextParams = params.slice(i + 1).map(Number)
1507
+ currentStyle.colonFg = undefined
1508
+ if (nextParams[0] === 5 && nextParams[1] !== undefined) {
1509
+ currentStyle.fg = nextParams[1]
1510
+ i += 2
1511
+ } else if (nextParams[0] === 2 && nextParams[3] !== undefined) {
1512
+ // True color - store as RGB values packed
1513
+ currentStyle.fg =
1514
+ 0x1000000 | ((nextParams[1]! & 0xff) << 16) | ((nextParams[2]! & 0xff) << 8) | (nextParams[3]! & 0xff)
1515
+ i += 4
1516
+ }
1517
+ break
1518
+ }
1519
+ case 39:
1520
+ currentStyle.fg = null // Default foreground
1521
+ break
1522
+ case 40:
1523
+ case 41:
1524
+ case 42:
1525
+ case 43:
1526
+ case 44:
1527
+ case 45:
1528
+ case 46:
1529
+ case 47:
1530
+ currentStyle.bg = code
1531
+ break
1532
+ case 48: {
1533
+ // Extended color: 48;5;N (256 color) or 48;2;r;g;b (true color)
1534
+ // Semicolon-separated — clear colonBg flag
1535
+ const nextParams = params.slice(i + 1).map(Number)
1536
+ currentStyle.colonBg = undefined
1537
+ if (nextParams[0] === 5 && nextParams[1] !== undefined) {
1538
+ currentStyle.bg = nextParams[1]
1539
+ i += 2
1540
+ } else if (nextParams[0] === 2 && nextParams[3] !== undefined) {
1541
+ // True color - store as RGB values packed
1542
+ currentStyle.bg =
1543
+ 0x1000000 | ((nextParams[1]! & 0xff) << 16) | ((nextParams[2]! & 0xff) << 8) | (nextParams[3]! & 0xff)
1544
+ i += 4
1545
+ }
1546
+ break
1547
+ }
1548
+ case 49:
1549
+ currentStyle.bg = null // Default background
1550
+ break
1551
+ case 58: {
1552
+ // Underline color: 58;5;N (256 color) or 58;2;r;g;b (true color)
1553
+ const nextParams = params.slice(i + 1).map(Number)
1554
+ if (nextParams[0] === 5 && nextParams[1] !== undefined) {
1555
+ currentStyle.underlineColor = nextParams[1]
1556
+ i += 2
1557
+ } else if (nextParams[0] === 2 && nextParams[3] !== undefined) {
1558
+ // True color - store as RGB values packed
1559
+ currentStyle.underlineColor =
1560
+ 0x1000000 | ((nextParams[1]! & 0xff) << 16) | ((nextParams[2]! & 0xff) << 8) | (nextParams[3]! & 0xff)
1561
+ i += 4
1562
+ }
1563
+ break
1564
+ }
1565
+ case 59:
1566
+ currentStyle.underlineColor = null // Default underline color
1567
+ break
1568
+ case 90:
1569
+ case 91:
1570
+ case 92:
1571
+ case 93:
1572
+ case 94:
1573
+ case 95:
1574
+ case 96:
1575
+ case 97:
1576
+ currentStyle.fg = code // Bright foreground colors
1577
+ break
1578
+ case 100:
1579
+ case 101:
1580
+ case 102:
1581
+ case 103:
1582
+ case 104:
1583
+ case 105:
1584
+ case 106:
1585
+ case 107:
1586
+ currentStyle.bg = code // Bright background colors
1587
+ break
1588
+ case BG_OVERRIDE_CODE:
1589
+ // Private code: signals intentional bg override, skip conflict detection
1590
+ currentStyle.bgOverride = true
1591
+ break
1592
+ }
1593
+ }
1594
+
1595
+ lastIndex = match.index + match[0].length
1596
+ }
1597
+
1598
+ // Add remaining text
1599
+ if (lastIndex < processText.length) {
1600
+ const content = processText.slice(lastIndex)
1601
+ if (content.length > 0) {
1602
+ if (hyperlinkRanges.length > 0) {
1603
+ // Split remaining content by hyperlink URL
1604
+ let segStart = 0
1605
+ for (let ci = 0; ci < content.length; ci++) {
1606
+ const hl = getHyperlinkAt(lastIndex + ci)
1607
+ const prevHl = ci > 0 ? getHyperlinkAt(lastIndex + ci - 1) : undefined
1608
+ if (ci > 0 && hl !== prevHl) {
1609
+ const sub = content.slice(segStart, ci)
1610
+ if (sub.length > 0) {
1611
+ const seg: StyledSegment = { text: sub, ...currentStyle }
1612
+ if (prevHl) seg.hyperlink = prevHl
1613
+ segments.push(seg)
1614
+ }
1615
+ segStart = ci
1616
+ }
1617
+ }
1618
+ const sub = content.slice(segStart)
1619
+ if (sub.length > 0) {
1620
+ const hl = getHyperlinkAt(lastIndex + segStart)
1621
+ const seg: StyledSegment = { text: sub, ...currentStyle }
1622
+ if (hl) seg.hyperlink = hl
1623
+ segments.push(seg)
1624
+ }
1625
+ } else {
1626
+ segments.push({ text: content, ...currentStyle })
1627
+ }
1628
+ }
1629
+ }
1630
+
1631
+ return segments
1632
+ }
1633
+
1634
+ const ANSI_TEST_REGEX = /\x1b(?:\[[0-9;:]*[A-Za-z]|\])|\x9b[\x30-\x3f]*[\x40-\x7e]|\x9d/
1635
+
1636
+ /**
1637
+ * Check if text contains ANSI escape sequences (SGR or OSC).
1638
+ * Detects both ESC-based (7-bit) and C1 (8-bit) forms:
1639
+ * - ESC [ params final (CSI)
1640
+ * - ESC ] (OSC)
1641
+ * - U+009B params final (C1 CSI)
1642
+ * - U+009D (C1 OSC)
1643
+ */
1644
+ export function hasAnsi(text: string): boolean {
1645
+ // Use a non-global regex for testing to avoid lastIndex issues
1646
+ return ANSI_TEST_REGEX.test(text)
1647
+ }
1648
+
1649
+ // ============================================================================
1650
+ // Measurement Utilities
1651
+ // ============================================================================
1652
+
1653
+ /**
1654
+ * Measure the dimensions of multi-line text.
1655
+ *
1656
+ * @param text - Text to measure (may contain newlines)
1657
+ * @returns { width, height } in display columns and rows
1658
+ */
1659
+ export function measureText(text: string): { width: number; height: number } {
1660
+ const lines = text.split("\n")
1661
+ let maxWidth = 0
1662
+
1663
+ for (const line of lines) {
1664
+ const lineWidth = displayWidth(line)
1665
+ if (lineWidth > maxWidth) {
1666
+ maxWidth = lineWidth
1667
+ }
1668
+ }
1669
+
1670
+ return {
1671
+ width: maxWidth,
1672
+ height: lines.length,
1673
+ }
1674
+ }
1675
+
1676
+ /**
1677
+ * Check if a string contains any wide characters.
1678
+ */
1679
+ export function hasWideCharacters(text: string): boolean {
1680
+ const graphemes = splitGraphemes(text)
1681
+ return graphemes.some(isWideGrapheme)
1682
+ }
1683
+
1684
+ /**
1685
+ * Check if a string contains any combining/zero-width characters.
1686
+ */
1687
+ export function hasZeroWidthCharacters(text: string): boolean {
1688
+ const graphemes = splitGraphemes(text)
1689
+ return graphemes.some(isZeroWidthGrapheme)
1690
+ }
1691
+
1692
+ /**
1693
+ * Normalize string for consistent handling.
1694
+ * Applies Unicode NFC normalization.
1695
+ */
1696
+ export function normalizeText(text: string): string {
1697
+ return text.normalize("NFC")
1698
+ }
1699
+
1700
+ // ============================================================================
1701
+ // Character Detection
1702
+ // ============================================================================
1703
+
1704
+ /**
1705
+ * Common character ranges for quick checks.
1706
+ */
1707
+ const CHAR_RANGES = {
1708
+ // Basic Latin (ASCII)
1709
+ isBasicLatin: (cp: number) => cp >= 0x0020 && cp <= 0x007f,
1710
+
1711
+ // CJK Unified Ideographs
1712
+ isCJK: (cp: number) =>
1713
+ (cp >= 0x4e00 && cp <= 0x9fff) || // CJK Unified Ideographs
1714
+ (cp >= 0x3400 && cp <= 0x4dbf) || // CJK Unified Ideographs Extension A
1715
+ (cp >= 0x20000 && cp <= 0x2a6df) || // CJK Unified Ideographs Extension B
1716
+ (cp >= 0xf900 && cp <= 0xfaff) || // CJK Compatibility Ideographs
1717
+ (cp >= 0x2f800 && cp <= 0x2fa1f), // CJK Compatibility Ideographs Supplement
1718
+
1719
+ // Japanese Hiragana/Katakana
1720
+ isJapaneseKana: (cp: number) =>
1721
+ (cp >= 0x3040 && cp <= 0x309f) || // Hiragana
1722
+ (cp >= 0x30a0 && cp <= 0x30ff), // Katakana
1723
+
1724
+ // Korean Hangul
1725
+ isHangul: (cp: number) =>
1726
+ (cp >= 0xac00 && cp <= 0xd7a3) || // Hangul Syllables
1727
+ (cp >= 0x1100 && cp <= 0x11ff), // Hangul Jamo
1728
+
1729
+ // Emoji ranges (simplified)
1730
+ isEmoji: (cp: number) =>
1731
+ (cp >= 0x1f600 && cp <= 0x1f64f) || // Emoticons
1732
+ (cp >= 0x1f300 && cp <= 0x1f5ff) || // Misc Symbols and Pictographs
1733
+ (cp >= 0x1f680 && cp <= 0x1f6ff) || // Transport and Map
1734
+ (cp >= 0x1f700 && cp <= 0x1f77f) || // Alchemical Symbols
1735
+ (cp >= 0x1f900 && cp <= 0x1f9ff) || // Supplemental Symbols and Pictographs
1736
+ (cp >= 0x2600 && cp <= 0x26ff) || // Misc symbols
1737
+ (cp >= 0x2700 && cp <= 0x27bf), // Dingbats
1738
+ } as const
1739
+
1740
+ /**
1741
+ * Get the first code point of a string.
1742
+ */
1743
+ export function getFirstCodePoint(str: string): number {
1744
+ const cp = str.codePointAt(0)
1745
+ return cp ?? 0
1746
+ }
1747
+
1748
+ /**
1749
+ * Check if a grapheme is likely an emoji.
1750
+ * Note: This is a heuristic, not comprehensive.
1751
+ */
1752
+ export function isLikelyEmoji(grapheme: string): boolean {
1753
+ const cp = getFirstCodePoint(grapheme)
1754
+ return CHAR_RANGES.isEmoji(cp) || grapheme.includes("\u200d") // Contains ZWJ
1755
+ }
1756
+
1757
+ /**
1758
+ * Check if a grapheme is a CJK character.
1759
+ */
1760
+ export function isCJK(grapheme: string): boolean {
1761
+ const cp = getFirstCodePoint(grapheme)
1762
+ return CHAR_RANGES.isCJK(cp) || CHAR_RANGES.isJapaneseKana(cp) || CHAR_RANGES.isHangul(cp)
1763
+ }