lonny-agent 0.1.2 → 0.1.8

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 (216) hide show
  1. package/.kilo/plans/1780064105789-mighty-pixel.md +171 -0
  2. package/.lonny/fix-new-command-session-cleanup.md +65 -0
  3. package/.lonny/tui-autocomplete-above.md +62 -0
  4. package/dist/agent/__tests__/compaction.test.js +6 -4
  5. package/dist/agent/__tests__/compaction.test.js.map +1 -1
  6. package/dist/agent/llm.d.ts +1 -1
  7. package/dist/agent/llm.d.ts.map +1 -1
  8. package/dist/agent/prompt-builder.d.ts.map +1 -1
  9. package/dist/agent/prompt-builder.js +26 -8
  10. package/dist/agent/prompt-builder.js.map +1 -1
  11. package/dist/agent/providers/anthropic.d.ts +1 -1
  12. package/dist/agent/providers/anthropic.d.ts.map +1 -1
  13. package/dist/agent/providers/anthropic.js +2 -2
  14. package/dist/agent/providers/anthropic.js.map +1 -1
  15. package/dist/agent/providers/google.d.ts +1 -1
  16. package/dist/agent/providers/google.d.ts.map +1 -1
  17. package/dist/agent/providers/google.js +2 -1
  18. package/dist/agent/providers/google.js.map +1 -1
  19. package/dist/agent/providers/ollama.d.ts +1 -1
  20. package/dist/agent/providers/ollama.d.ts.map +1 -1
  21. package/dist/agent/providers/ollama.js +2 -1
  22. package/dist/agent/providers/ollama.js.map +1 -1
  23. package/dist/agent/providers/openai.d.ts +1 -1
  24. package/dist/agent/providers/openai.d.ts.map +1 -1
  25. package/dist/agent/providers/openai.js +2 -2
  26. package/dist/agent/providers/openai.js.map +1 -1
  27. package/dist/agent/session.d.ts +1 -0
  28. package/dist/agent/session.d.ts.map +1 -1
  29. package/dist/agent/session.js +8 -2
  30. package/dist/agent/session.js.map +1 -1
  31. package/dist/pi-tui/autocomplete.d.ts +54 -0
  32. package/dist/pi-tui/autocomplete.d.ts.map +1 -0
  33. package/dist/pi-tui/autocomplete.js +636 -0
  34. package/dist/pi-tui/autocomplete.js.map +1 -0
  35. package/dist/pi-tui/components/box.d.ts +22 -0
  36. package/dist/pi-tui/components/box.d.ts.map +1 -0
  37. package/dist/pi-tui/components/box.js +104 -0
  38. package/dist/pi-tui/components/box.js.map +1 -0
  39. package/dist/pi-tui/components/cancellable-loader.d.ts +22 -0
  40. package/dist/pi-tui/components/cancellable-loader.d.ts.map +1 -0
  41. package/dist/pi-tui/components/cancellable-loader.js +35 -0
  42. package/dist/pi-tui/components/cancellable-loader.js.map +1 -0
  43. package/dist/pi-tui/components/editor.d.ts +249 -0
  44. package/dist/pi-tui/components/editor.d.ts.map +1 -0
  45. package/dist/pi-tui/components/editor.js +1883 -0
  46. package/dist/pi-tui/components/editor.js.map +1 -0
  47. package/dist/pi-tui/components/image.d.ts +28 -0
  48. package/dist/pi-tui/components/image.d.ts.map +1 -0
  49. package/dist/pi-tui/components/image.js +90 -0
  50. package/dist/pi-tui/components/image.js.map +1 -0
  51. package/dist/pi-tui/components/input.d.ts +37 -0
  52. package/dist/pi-tui/components/input.d.ts.map +1 -0
  53. package/dist/pi-tui/components/input.js +382 -0
  54. package/dist/pi-tui/components/input.js.map +1 -0
  55. package/dist/pi-tui/components/loader.d.ts +31 -0
  56. package/dist/pi-tui/components/loader.d.ts.map +1 -0
  57. package/dist/pi-tui/components/loader.js +70 -0
  58. package/dist/pi-tui/components/loader.js.map +1 -0
  59. package/dist/pi-tui/components/markdown.d.ts +96 -0
  60. package/dist/pi-tui/components/markdown.d.ts.map +1 -0
  61. package/dist/pi-tui/components/markdown.js +647 -0
  62. package/dist/pi-tui/components/markdown.js.map +1 -0
  63. package/dist/pi-tui/components/select-list.d.ts +50 -0
  64. package/dist/pi-tui/components/select-list.d.ts.map +1 -0
  65. package/dist/pi-tui/components/select-list.js +167 -0
  66. package/dist/pi-tui/components/select-list.js.map +1 -0
  67. package/dist/pi-tui/components/settings-list.d.ts +50 -0
  68. package/dist/pi-tui/components/settings-list.d.ts.map +1 -0
  69. package/dist/pi-tui/components/settings-list.js +189 -0
  70. package/dist/pi-tui/components/settings-list.js.map +1 -0
  71. package/dist/pi-tui/components/spacer.d.ts +12 -0
  72. package/dist/pi-tui/components/spacer.d.ts.map +1 -0
  73. package/dist/pi-tui/components/spacer.js +23 -0
  74. package/dist/pi-tui/components/spacer.js.map +1 -0
  75. package/dist/pi-tui/components/text.d.ts +19 -0
  76. package/dist/pi-tui/components/text.d.ts.map +1 -0
  77. package/dist/pi-tui/components/text.js +91 -0
  78. package/dist/pi-tui/components/text.js.map +1 -0
  79. package/dist/pi-tui/components/truncated-text.d.ts +13 -0
  80. package/dist/pi-tui/components/truncated-text.d.ts.map +1 -0
  81. package/dist/pi-tui/components/truncated-text.js +51 -0
  82. package/dist/pi-tui/components/truncated-text.js.map +1 -0
  83. package/dist/pi-tui/editor-component.d.ts +39 -0
  84. package/dist/pi-tui/editor-component.d.ts.map +1 -0
  85. package/dist/pi-tui/editor-component.js +2 -0
  86. package/dist/pi-tui/editor-component.js.map +1 -0
  87. package/dist/pi-tui/fuzzy.d.ts +16 -0
  88. package/dist/pi-tui/fuzzy.d.ts.map +1 -0
  89. package/dist/pi-tui/fuzzy.js +110 -0
  90. package/dist/pi-tui/fuzzy.js.map +1 -0
  91. package/dist/pi-tui/index.d.ts +23 -0
  92. package/dist/pi-tui/index.d.ts.map +1 -0
  93. package/dist/pi-tui/index.js +32 -0
  94. package/dist/pi-tui/index.js.map +1 -0
  95. package/dist/pi-tui/keybindings.d.ts +193 -0
  96. package/dist/pi-tui/keybindings.d.ts.map +1 -0
  97. package/dist/pi-tui/keybindings.js +174 -0
  98. package/dist/pi-tui/keybindings.js.map +1 -0
  99. package/dist/pi-tui/keys.d.ts +184 -0
  100. package/dist/pi-tui/keys.d.ts.map +1 -0
  101. package/dist/pi-tui/keys.js +1182 -0
  102. package/dist/pi-tui/keys.js.map +1 -0
  103. package/dist/pi-tui/kill-ring.d.ts +28 -0
  104. package/dist/pi-tui/kill-ring.d.ts.map +1 -0
  105. package/dist/pi-tui/kill-ring.js +44 -0
  106. package/dist/pi-tui/kill-ring.js.map +1 -0
  107. package/dist/pi-tui/native/darwin/prebuilds/darwin-arm64/darwin-modifiers.node +0 -0
  108. package/dist/pi-tui/native/darwin/prebuilds/darwin-x64/darwin-modifiers.node +0 -0
  109. package/dist/pi-tui/native/win32/prebuilds/win32-arm64/win32-console-mode.node +0 -0
  110. package/dist/pi-tui/native/win32/prebuilds/win32-x64/win32-console-mode.node +0 -0
  111. package/dist/pi-tui/native-modifiers.d.ts +3 -0
  112. package/dist/pi-tui/native-modifiers.d.ts.map +1 -0
  113. package/dist/pi-tui/native-modifiers.js +53 -0
  114. package/dist/pi-tui/native-modifiers.js.map +1 -0
  115. package/dist/pi-tui/stdin-buffer.d.ts +50 -0
  116. package/dist/pi-tui/stdin-buffer.d.ts.map +1 -0
  117. package/dist/pi-tui/stdin-buffer.js +361 -0
  118. package/dist/pi-tui/stdin-buffer.js.map +1 -0
  119. package/dist/pi-tui/terminal-image.d.ts +90 -0
  120. package/dist/pi-tui/terminal-image.d.ts.map +1 -0
  121. package/dist/pi-tui/terminal-image.js +343 -0
  122. package/dist/pi-tui/terminal-image.js.map +1 -0
  123. package/dist/pi-tui/terminal.d.ts +113 -0
  124. package/dist/pi-tui/terminal.d.ts.map +1 -0
  125. package/dist/pi-tui/terminal.js +478 -0
  126. package/dist/pi-tui/terminal.js.map +1 -0
  127. package/dist/pi-tui/tui.d.ts +227 -0
  128. package/dist/pi-tui/tui.d.ts.map +1 -0
  129. package/dist/pi-tui/tui.js +1091 -0
  130. package/dist/pi-tui/tui.js.map +1 -0
  131. package/dist/pi-tui/undo-stack.d.ts +17 -0
  132. package/dist/pi-tui/undo-stack.d.ts.map +1 -0
  133. package/dist/pi-tui/undo-stack.js +25 -0
  134. package/dist/pi-tui/undo-stack.js.map +1 -0
  135. package/dist/pi-tui/utils.d.ts +84 -0
  136. package/dist/pi-tui/utils.d.ts.map +1 -0
  137. package/dist/pi-tui/utils.js +1024 -0
  138. package/dist/pi-tui/utils.js.map +1 -0
  139. package/dist/pi-tui/word-navigation.d.ts +25 -0
  140. package/dist/pi-tui/word-navigation.d.ts.map +1 -0
  141. package/dist/pi-tui/word-navigation.js +98 -0
  142. package/dist/pi-tui/word-navigation.js.map +1 -0
  143. package/dist/tools/__tests__/edit.test.js +85 -0
  144. package/dist/tools/__tests__/edit.test.js.map +1 -1
  145. package/dist/tools/edit.d.ts.map +1 -1
  146. package/dist/tools/edit.js +23 -1
  147. package/dist/tools/edit.js.map +1 -1
  148. package/dist/tui/components.d.ts +1 -1
  149. package/dist/tui/components.d.ts.map +1 -1
  150. package/dist/tui/components.js +1 -1
  151. package/dist/tui/components.js.map +1 -1
  152. package/dist/tui/editor-patch.d.ts +13 -0
  153. package/dist/tui/editor-patch.d.ts.map +1 -0
  154. package/dist/tui/editor-patch.js +41 -0
  155. package/dist/tui/editor-patch.js.map +1 -0
  156. package/dist/tui/index.d.ts.map +1 -1
  157. package/dist/tui/index.js +15 -3
  158. package/dist/tui/index.js.map +1 -1
  159. package/dist/web/index.d.ts.map +1 -1
  160. package/dist/web/index.js +9 -2
  161. package/dist/web/index.js.map +1 -1
  162. package/dist/web/public/app.js +65 -4
  163. package/dist/web/public/style.css +49 -0
  164. package/dist/web/session-bridge.d.ts +0 -4
  165. package/dist/web/session-bridge.d.ts.map +1 -1
  166. package/dist/web/session-bridge.js +16 -2
  167. package/dist/web/session-bridge.js.map +1 -1
  168. package/package.json +5 -3
  169. package/src/agent/__tests__/compaction.test.ts +6 -4
  170. package/src/agent/llm.ts +5 -1
  171. package/src/agent/prompt-builder.ts +27 -8
  172. package/src/agent/providers/anthropic.ts +15 -8
  173. package/src/agent/providers/google.ts +6 -1
  174. package/src/agent/providers/ollama.ts +6 -1
  175. package/src/agent/providers/openai.ts +21 -12
  176. package/src/agent/session.ts +12 -2
  177. package/src/pi-tui/autocomplete.ts +808 -0
  178. package/src/pi-tui/components/box.ts +137 -0
  179. package/src/pi-tui/components/cancellable-loader.ts +40 -0
  180. package/src/pi-tui/components/editor.ts +2321 -0
  181. package/src/pi-tui/components/image.ts +130 -0
  182. package/src/pi-tui/components/input.ts +456 -0
  183. package/src/pi-tui/components/loader.ts +93 -0
  184. package/src/pi-tui/components/markdown.ts +839 -0
  185. package/src/pi-tui/components/select-list.ts +263 -0
  186. package/src/pi-tui/components/settings-list.ts +264 -0
  187. package/src/pi-tui/components/spacer.ts +28 -0
  188. package/src/pi-tui/components/text.ts +113 -0
  189. package/src/pi-tui/components/truncated-text.ts +65 -0
  190. package/src/pi-tui/editor-component.ts +74 -0
  191. package/src/pi-tui/fuzzy.ts +137 -0
  192. package/src/pi-tui/index.ts +115 -0
  193. package/src/pi-tui/keybindings.ts +245 -0
  194. package/src/pi-tui/keys.ts +1440 -0
  195. package/src/pi-tui/kill-ring.ts +46 -0
  196. package/src/pi-tui/native/darwin/prebuilds/darwin-arm64/darwin-modifiers.node +0 -0
  197. package/src/pi-tui/native/darwin/prebuilds/darwin-x64/darwin-modifiers.node +0 -0
  198. package/src/pi-tui/native/win32/prebuilds/win32-arm64/win32-console-mode.node +0 -0
  199. package/src/pi-tui/native/win32/prebuilds/win32-x64/win32-console-mode.node +0 -0
  200. package/src/pi-tui/native-modifiers.ts +65 -0
  201. package/src/pi-tui/stdin-buffer.ts +434 -0
  202. package/src/pi-tui/terminal-image.ts +469 -0
  203. package/src/pi-tui/terminal.ts +594 -0
  204. package/src/pi-tui/tui.ts +1376 -0
  205. package/src/pi-tui/undo-stack.ts +28 -0
  206. package/src/pi-tui/utils.ts +1176 -0
  207. package/src/pi-tui/word-navigation.ts +127 -0
  208. package/src/tools/__tests__/edit.test.ts +91 -0
  209. package/src/tools/edit.ts +27 -1
  210. package/src/tui/components.ts +2 -2
  211. package/src/tui/index.ts +24 -10
  212. package/src/web/index.ts +9 -2
  213. package/src/web/public/app.js +65 -4
  214. package/src/web/public/style.css +49 -0
  215. package/src/web/session-bridge.ts +13 -2
  216. package/tsconfig.json +2 -2
@@ -0,0 +1,1176 @@
1
+ import { eastAsianWidth } from 'get-east-asian-width'
2
+
3
+ // segmenters (shared instance)
4
+ const graphemeSegmenter = new Intl.Segmenter(undefined, { granularity: 'grapheme' })
5
+ const wordSegmenter = new Intl.Segmenter(undefined, { granularity: 'word' })
6
+
7
+ /**
8
+ * Get the shared grapheme segmenter instance.
9
+ */
10
+ export function getGraphemeSegmenter(): Intl.Segmenter {
11
+ return graphemeSegmenter
12
+ }
13
+
14
+ /**
15
+ * Get the shared word segmenter instance.
16
+ */
17
+ export function getWordSegmenter(): Intl.Segmenter {
18
+ return wordSegmenter
19
+ }
20
+
21
+ /**
22
+ * Check if a grapheme cluster (after segmentation) could possibly be an RGI emoji.
23
+ * This is a fast heuristic to avoid the expensive rgiEmojiRegex test.
24
+ * The tested Unicode blocks are deliberately broad to account for future
25
+ * Unicode additions.
26
+ */
27
+ function couldBeEmoji(segment: string): boolean {
28
+ const cp = segment.codePointAt(0)!
29
+ return (
30
+ (cp >= 0x1f000 && cp <= 0x1fbff) || // Emoji and Pictograph
31
+ (cp >= 0x2300 && cp <= 0x23ff) || // Misc technical
32
+ (cp >= 0x2600 && cp <= 0x27bf) || // Misc symbols, dingbats
33
+ (cp >= 0x2b50 && cp <= 0x2b55) || // Specific stars/circles
34
+ segment.includes('\uFE0F') || // Contains VS16 (emoji presentation selector)
35
+ segment.length > 2 // Multi-codepoint sequences (ZWJ, skin tones, etc.)
36
+ )
37
+ }
38
+
39
+ // Regexes for character classification (same as string-width library)
40
+ const zeroWidthRegex = /^(?:\p{Default_Ignorable_Code_Point}|\p{Control}|\p{Mark}|\p{Surrogate})+$/v
41
+ const leadingNonPrintingRegex =
42
+ /^[\p{Default_Ignorable_Code_Point}\p{Control}\p{Format}\p{Mark}\p{Surrogate}]+/v
43
+ const rgiEmojiRegex = /^\p{RGI_Emoji}$/v
44
+
45
+ // Cache for non-ASCII strings
46
+ const WIDTH_CACHE_SIZE = 512
47
+ const widthCache = new Map<string, number>()
48
+
49
+ function isPrintableAscii(str: string): boolean {
50
+ for (let i = 0; i < str.length; i++) {
51
+ const code = str.charCodeAt(i)
52
+ if (code < 0x20 || code > 0x7e) {
53
+ return false
54
+ }
55
+ }
56
+ return true
57
+ }
58
+
59
+ function truncateFragmentToWidth(text: string, maxWidth: number): { text: string; width: number } {
60
+ if (maxWidth <= 0 || text.length === 0) {
61
+ return { text: '', width: 0 }
62
+ }
63
+
64
+ if (isPrintableAscii(text)) {
65
+ const clipped = text.slice(0, maxWidth)
66
+ return { text: clipped, width: clipped.length }
67
+ }
68
+
69
+ const hasAnsi = text.includes('\x1b')
70
+ const hasTabs = text.includes('\t')
71
+ if (!hasAnsi && !hasTabs) {
72
+ let result = ''
73
+ let width = 0
74
+ for (const { segment } of graphemeSegmenter.segment(text)) {
75
+ const w = graphemeWidth(segment)
76
+ if (width + w > maxWidth) {
77
+ break
78
+ }
79
+ result += segment
80
+ width += w
81
+ }
82
+ return { text: result, width }
83
+ }
84
+
85
+ let result = ''
86
+ let width = 0
87
+ let i = 0
88
+ let pendingAnsi = ''
89
+
90
+ while (i < text.length) {
91
+ const ansi = extractAnsiCode(text, i)
92
+ if (ansi) {
93
+ pendingAnsi += ansi.code
94
+ i += ansi.length
95
+ continue
96
+ }
97
+
98
+ if (text[i] === '\t') {
99
+ if (width + 3 > maxWidth) {
100
+ break
101
+ }
102
+ if (pendingAnsi) {
103
+ result += pendingAnsi
104
+ pendingAnsi = ''
105
+ }
106
+ result += '\t'
107
+ width += 3
108
+ i++
109
+ continue
110
+ }
111
+
112
+ let end = i
113
+ while (end < text.length && text[end] !== '\t') {
114
+ const nextAnsi = extractAnsiCode(text, end)
115
+ if (nextAnsi) {
116
+ break
117
+ }
118
+ end++
119
+ }
120
+
121
+ for (const { segment } of graphemeSegmenter.segment(text.slice(i, end))) {
122
+ const w = graphemeWidth(segment)
123
+ if (width + w > maxWidth) {
124
+ return { text: result, width }
125
+ }
126
+ if (pendingAnsi) {
127
+ result += pendingAnsi
128
+ pendingAnsi = ''
129
+ }
130
+ result += segment
131
+ width += w
132
+ }
133
+ i = end
134
+ }
135
+
136
+ return { text: result, width }
137
+ }
138
+
139
+ function finalizeTruncatedResult(
140
+ prefix: string,
141
+ prefixWidth: number,
142
+ ellipsis: string,
143
+ ellipsisWidth: number,
144
+ maxWidth: number,
145
+ pad: boolean,
146
+ ): string {
147
+ const reset = '\x1b[0m'
148
+ const visibleWidth = prefixWidth + ellipsisWidth
149
+ let result: string
150
+
151
+ if (ellipsis.length > 0) {
152
+ result = `${prefix}${reset}${ellipsis}${reset}`
153
+ } else {
154
+ result = `${prefix}${reset}`
155
+ }
156
+
157
+ return pad ? result + ' '.repeat(Math.max(0, maxWidth - visibleWidth)) : result
158
+ }
159
+
160
+ /**
161
+ * Calculate the terminal width of a single grapheme cluster.
162
+ * Based on code from the string-width library, but includes a possible-emoji
163
+ * check to avoid running the RGI_Emoji regex unnecessarily.
164
+ */
165
+ function graphemeWidth(segment: string): number {
166
+ // Zero-width clusters
167
+ if (zeroWidthRegex.test(segment)) {
168
+ return 0
169
+ }
170
+
171
+ // Emoji check with pre-filter
172
+ if (couldBeEmoji(segment) && rgiEmojiRegex.test(segment)) {
173
+ return 2
174
+ }
175
+
176
+ // Get base visible codepoint
177
+ const base = segment.replace(leadingNonPrintingRegex, '')
178
+ const cp = base.codePointAt(0)
179
+ if (cp === undefined) {
180
+ return 0
181
+ }
182
+
183
+ // Regional indicator symbols (U+1F1E6..U+1F1FF) are often rendered as
184
+ // full-width emoji in terminals, even when isolated during streaming.
185
+ // Keep width conservative (2) to avoid terminal auto-wrap drift artifacts.
186
+ if (cp >= 0x1f1e6 && cp <= 0x1f1ff) {
187
+ return 2
188
+ }
189
+
190
+ let width = eastAsianWidth(cp)
191
+
192
+ // Trailing halfwidth/fullwidth forms and AM vowels that segment with a base.
193
+ if (segment.length > 1) {
194
+ for (const char of segment.slice(1)) {
195
+ const c = char.codePointAt(0)!
196
+ if (c >= 0xff00 && c <= 0xffef) {
197
+ width += eastAsianWidth(c)
198
+ } else if (c === 0x0e33 || c === 0x0eb3) {
199
+ width += 1
200
+ }
201
+ }
202
+ }
203
+
204
+ return width
205
+ }
206
+
207
+ /**
208
+ * Calculate the visible width of a string in terminal columns.
209
+ */
210
+ export function visibleWidth(str: string): number {
211
+ if (str.length === 0) {
212
+ return 0
213
+ }
214
+
215
+ // Fast path: pure ASCII printable
216
+ if (isPrintableAscii(str)) {
217
+ return str.length
218
+ }
219
+
220
+ // Check cache
221
+ const cached = widthCache.get(str)
222
+ if (cached !== undefined) {
223
+ return cached
224
+ }
225
+
226
+ // Normalize: tabs to 3 spaces, strip ANSI escape codes
227
+ let clean = str
228
+ if (str.includes('\t')) {
229
+ clean = clean.replace(/\t/g, ' ')
230
+ }
231
+ if (clean.includes('\x1b')) {
232
+ // Strip supported ANSI/OSC/APC escape sequences in one pass.
233
+ // This covers CSI styling/cursor codes, OSC hyperlinks and prompt markers,
234
+ // and APC sequences like CURSOR_MARKER.
235
+ let stripped = ''
236
+ let i = 0
237
+ while (i < clean.length) {
238
+ const ansi = extractAnsiCode(clean, i)
239
+ if (ansi) {
240
+ i += ansi.length
241
+ continue
242
+ }
243
+ stripped += clean[i]
244
+ i++
245
+ }
246
+ clean = stripped
247
+ }
248
+
249
+ // Calculate width
250
+ let width = 0
251
+ for (const { segment } of graphemeSegmenter.segment(clean)) {
252
+ width += graphemeWidth(segment)
253
+ }
254
+
255
+ // Cache result
256
+ if (widthCache.size >= WIDTH_CACHE_SIZE) {
257
+ const firstKey = widthCache.keys().next().value
258
+ if (firstKey !== undefined) {
259
+ widthCache.delete(firstKey)
260
+ }
261
+ }
262
+ widthCache.set(str, width)
263
+
264
+ return width
265
+ }
266
+
267
+ /**
268
+ * Normalize text for terminal output without changing logical editor content.
269
+ * Some terminals render precomposed Thai/Lao AM vowels inconsistently during
270
+ * differential repaint. Their compatibility decompositions have the same cell
271
+ * width but avoid stale-cell artifacts in terminal renderers.
272
+ */
273
+ const THAI_LAO_AM_REGEX = /[\u0e33\u0eb3]/
274
+ const THAI_LAO_AM_GLOBAL_REGEX = /[\u0e33\u0eb3]/g
275
+
276
+ export function normalizeTerminalOutput(str: string): string {
277
+ if (!THAI_LAO_AM_REGEX.test(str)) return str
278
+ return str.replace(THAI_LAO_AM_GLOBAL_REGEX, char =>
279
+ char === '\u0e33' ? '\u0e4d\u0e32' : '\u0ecd\u0eb2',
280
+ )
281
+ }
282
+
283
+ /**
284
+ * Extract ANSI escape sequences from a string at the given position.
285
+ */
286
+ export function extractAnsiCode(str: string, pos: number): { code: string; length: number } | null {
287
+ if (pos >= str.length || str[pos] !== '\x1b') return null
288
+
289
+ const next = str[pos + 1]
290
+
291
+ // CSI sequence: ESC [ ... m/G/K/H/J
292
+ if (next === '[') {
293
+ let j = pos + 2
294
+ while (j < str.length && !/[mGKHJ]/.test(str[j]!)) j++
295
+ if (j < str.length) return { code: str.substring(pos, j + 1), length: j + 1 - pos }
296
+ return null
297
+ }
298
+
299
+ // OSC sequence: ESC ] ... BEL or ESC ] ... ST (ESC \)
300
+ // Used for hyperlinks (OSC 8), window titles, etc.
301
+ if (next === ']') {
302
+ let j = pos + 2
303
+ while (j < str.length) {
304
+ if (str[j] === '\x07') return { code: str.substring(pos, j + 1), length: j + 1 - pos }
305
+ if (str[j] === '\x1b' && str[j + 1] === '\\')
306
+ return { code: str.substring(pos, j + 2), length: j + 2 - pos }
307
+ j++
308
+ }
309
+ return null
310
+ }
311
+
312
+ // APC sequence: ESC _ ... BEL or ESC _ ... ST (ESC \)
313
+ // Used for cursor marker and application-specific commands
314
+ if (next === '_') {
315
+ let j = pos + 2
316
+ while (j < str.length) {
317
+ if (str[j] === '\x07') return { code: str.substring(pos, j + 1), length: j + 1 - pos }
318
+ if (str[j] === '\x1b' && str[j + 1] === '\\')
319
+ return { code: str.substring(pos, j + 2), length: j + 2 - pos }
320
+ j++
321
+ }
322
+ return null
323
+ }
324
+
325
+ return null
326
+ }
327
+
328
+ type Osc8Terminator = '\x07' | '\x1b\\'
329
+
330
+ interface ActiveHyperlink {
331
+ params: string
332
+ url: string
333
+ terminator: Osc8Terminator
334
+ }
335
+
336
+ function parseOsc8Hyperlink(ansiCode: string): ActiveHyperlink | null | undefined {
337
+ if (!ansiCode.startsWith('\x1b]8;')) {
338
+ return undefined
339
+ }
340
+
341
+ const terminator: Osc8Terminator = ansiCode.endsWith('\x07') ? '\x07' : '\x1b\\'
342
+ const body = ansiCode.slice(4, terminator === '\x07' ? -1 : -2)
343
+ const separatorIndex = body.indexOf(';')
344
+ if (separatorIndex === -1) {
345
+ return undefined
346
+ }
347
+
348
+ const params = body.slice(0, separatorIndex)
349
+ const url = body.slice(separatorIndex + 1)
350
+ if (!url) {
351
+ return null
352
+ }
353
+ return { params, url, terminator }
354
+ }
355
+
356
+ function formatOsc8Hyperlink(hyperlink: ActiveHyperlink): string {
357
+ return `\x1b]8;${hyperlink.params};${hyperlink.url}${hyperlink.terminator}`
358
+ }
359
+
360
+ function formatOsc8Close(terminator: Osc8Terminator): string {
361
+ return `\x1b]8;;${terminator}`
362
+ }
363
+
364
+ /**
365
+ * Track active ANSI SGR codes to preserve styling across line breaks.
366
+ */
367
+ class AnsiCodeTracker {
368
+ // Track individual attributes separately so we can reset them specifically
369
+ private bold = false
370
+ private dim = false
371
+ private italic = false
372
+ private underline = false
373
+ private blink = false
374
+ private inverse = false
375
+ private hidden = false
376
+ private strikethrough = false
377
+ private fgColor: string | null = null // Stores the full code like "31" or "38;5;240"
378
+ private bgColor: string | null = null // Stores the full code like "41" or "48;5;240"
379
+ private activeHyperlink: ActiveHyperlink | null = null
380
+
381
+ process(ansiCode: string): void {
382
+ // OSC 8 hyperlink: \x1b]8;;<url>\x1b\\ (open) or \x1b]8;;\x1b\\ (close).
383
+ // Preserve the original terminator because some terminals only make BEL-terminated
384
+ // links clickable. OAuth login URLs use BEL, so reopening wrapped lines with ST
385
+ // made only the first physical line clickable in those terminals.
386
+ const hyperlink = parseOsc8Hyperlink(ansiCode)
387
+ if (hyperlink !== undefined) {
388
+ this.activeHyperlink = hyperlink
389
+ return
390
+ }
391
+
392
+ if (!ansiCode.endsWith('m')) {
393
+ return
394
+ }
395
+
396
+ // Extract the parameters between \x1b[ and m
397
+ const match = ansiCode.match(/\x1b\[([\d;]*)m/)
398
+ if (!match) return
399
+
400
+ const params = match[1]
401
+ if (params === '' || params === '0') {
402
+ // Full reset
403
+ this.reset()
404
+ return
405
+ }
406
+
407
+ // Parse parameters (can be semicolon-separated)
408
+ const parts = params.split(';')
409
+ let i = 0
410
+ while (i < parts.length) {
411
+ const code = Number.parseInt(parts[i], 10)
412
+
413
+ // Handle 256-color and RGB codes which consume multiple parameters
414
+ if (code === 38 || code === 48) {
415
+ // 38;5;N (256 color fg) or 38;2;R;G;B (RGB fg)
416
+ // 48;5;N (256 color bg) or 48;2;R;G;B (RGB bg)
417
+ if (parts[i + 1] === '5' && parts[i + 2] !== undefined) {
418
+ // 256 color: 38;5;N or 48;5;N
419
+ const colorCode = `${parts[i]};${parts[i + 1]};${parts[i + 2]}`
420
+ if (code === 38) {
421
+ this.fgColor = colorCode
422
+ } else {
423
+ this.bgColor = colorCode
424
+ }
425
+ i += 3
426
+ continue
427
+ } else if (parts[i + 1] === '2' && parts[i + 4] !== undefined) {
428
+ // RGB color: 38;2;R;G;B or 48;2;R;G;B
429
+ const colorCode = `${parts[i]};${parts[i + 1]};${parts[i + 2]};${parts[i + 3]};${parts[i + 4]}`
430
+ if (code === 38) {
431
+ this.fgColor = colorCode
432
+ } else {
433
+ this.bgColor = colorCode
434
+ }
435
+ i += 5
436
+ continue
437
+ }
438
+ }
439
+
440
+ // Standard SGR codes
441
+ switch (code) {
442
+ case 0:
443
+ this.reset()
444
+ break
445
+ case 1:
446
+ this.bold = true
447
+ break
448
+ case 2:
449
+ this.dim = true
450
+ break
451
+ case 3:
452
+ this.italic = true
453
+ break
454
+ case 4:
455
+ this.underline = true
456
+ break
457
+ case 5:
458
+ this.blink = true
459
+ break
460
+ case 7:
461
+ this.inverse = true
462
+ break
463
+ case 8:
464
+ this.hidden = true
465
+ break
466
+ case 9:
467
+ this.strikethrough = true
468
+ break
469
+ case 21:
470
+ this.bold = false
471
+ break // Some terminals
472
+ case 22:
473
+ this.bold = false
474
+ this.dim = false
475
+ break
476
+ case 23:
477
+ this.italic = false
478
+ break
479
+ case 24:
480
+ this.underline = false
481
+ break
482
+ case 25:
483
+ this.blink = false
484
+ break
485
+ case 27:
486
+ this.inverse = false
487
+ break
488
+ case 28:
489
+ this.hidden = false
490
+ break
491
+ case 29:
492
+ this.strikethrough = false
493
+ break
494
+ case 39:
495
+ this.fgColor = null
496
+ break // Default fg
497
+ case 49:
498
+ this.bgColor = null
499
+ break // Default bg
500
+ default:
501
+ // Standard foreground colors 30-37, 90-97
502
+ if ((code >= 30 && code <= 37) || (code >= 90 && code <= 97)) {
503
+ this.fgColor = String(code)
504
+ }
505
+ // Standard background colors 40-47, 100-107
506
+ else if ((code >= 40 && code <= 47) || (code >= 100 && code <= 107)) {
507
+ this.bgColor = String(code)
508
+ }
509
+ break
510
+ }
511
+ i++
512
+ }
513
+ }
514
+
515
+ private reset(): void {
516
+ this.bold = false
517
+ this.dim = false
518
+ this.italic = false
519
+ this.underline = false
520
+ this.blink = false
521
+ this.inverse = false
522
+ this.hidden = false
523
+ this.strikethrough = false
524
+ this.fgColor = null
525
+ this.bgColor = null
526
+ // SGR reset does not affect OSC 8 hyperlink state
527
+ }
528
+
529
+ /** Clear all state for reuse. */
530
+ clear(): void {
531
+ this.reset()
532
+ this.activeHyperlink = null
533
+ }
534
+
535
+ getActiveCodes(): string {
536
+ const codes: string[] = []
537
+ if (this.bold) codes.push('1')
538
+ if (this.dim) codes.push('2')
539
+ if (this.italic) codes.push('3')
540
+ if (this.underline) codes.push('4')
541
+ if (this.blink) codes.push('5')
542
+ if (this.inverse) codes.push('7')
543
+ if (this.hidden) codes.push('8')
544
+ if (this.strikethrough) codes.push('9')
545
+ if (this.fgColor) codes.push(this.fgColor)
546
+ if (this.bgColor) codes.push(this.bgColor)
547
+
548
+ let result = codes.length > 0 ? `\x1b[${codes.join(';')}m` : ''
549
+ if (this.activeHyperlink) {
550
+ result += formatOsc8Hyperlink(this.activeHyperlink)
551
+ }
552
+ return result
553
+ }
554
+
555
+ hasActiveCodes(): boolean {
556
+ return (
557
+ this.bold ||
558
+ this.dim ||
559
+ this.italic ||
560
+ this.underline ||
561
+ this.blink ||
562
+ this.inverse ||
563
+ this.hidden ||
564
+ this.strikethrough ||
565
+ this.fgColor !== null ||
566
+ this.bgColor !== null ||
567
+ this.activeHyperlink !== null
568
+ )
569
+ }
570
+
571
+ /**
572
+ * Get reset codes for attributes that need to be turned off at line end.
573
+ * Underline must be closed to prevent bleeding into padding.
574
+ * Active OSC 8 hyperlinks must be closed and re-opened on the next line.
575
+ * Returns empty string if no attributes need closing.
576
+ */
577
+ getLineEndReset(): string {
578
+ let result = ''
579
+ if (this.underline) {
580
+ result += '\x1b[24m' // Underline off only
581
+ }
582
+ if (this.activeHyperlink) {
583
+ result += formatOsc8Close(this.activeHyperlink.terminator) // Re-opened at line start via getActiveCodes()
584
+ }
585
+ return result
586
+ }
587
+ }
588
+
589
+ function updateTrackerFromText(text: string, tracker: AnsiCodeTracker): void {
590
+ let i = 0
591
+ while (i < text.length) {
592
+ const ansiResult = extractAnsiCode(text, i)
593
+ if (ansiResult) {
594
+ tracker.process(ansiResult.code)
595
+ i += ansiResult.length
596
+ } else {
597
+ i++
598
+ }
599
+ }
600
+ }
601
+
602
+ /**
603
+ * Split text into words while keeping ANSI codes attached.
604
+ */
605
+ function splitIntoTokensWithAnsi(text: string): string[] {
606
+ const tokens: string[] = []
607
+ let current = ''
608
+ let pendingAnsi = '' // ANSI codes waiting to be attached to next visible content
609
+ let inWhitespace = false
610
+ let i = 0
611
+
612
+ while (i < text.length) {
613
+ const ansiResult = extractAnsiCode(text, i)
614
+ if (ansiResult) {
615
+ // Hold ANSI codes separately - they'll be attached to the next visible char
616
+ pendingAnsi += ansiResult.code
617
+ i += ansiResult.length
618
+ continue
619
+ }
620
+
621
+ const char = text[i]
622
+ const charIsSpace = char === ' '
623
+
624
+ if (charIsSpace !== inWhitespace && current) {
625
+ // Switching between whitespace and non-whitespace, push current token
626
+ tokens.push(current)
627
+ current = ''
628
+ }
629
+
630
+ // Attach any pending ANSI codes to this visible character
631
+ if (pendingAnsi) {
632
+ current += pendingAnsi
633
+ pendingAnsi = ''
634
+ }
635
+
636
+ inWhitespace = charIsSpace
637
+ current += char
638
+ i++
639
+ }
640
+
641
+ // Handle any remaining pending ANSI codes (attach to last token)
642
+ if (pendingAnsi) {
643
+ current += pendingAnsi
644
+ }
645
+
646
+ if (current) {
647
+ tokens.push(current)
648
+ }
649
+
650
+ return tokens
651
+ }
652
+
653
+ /**
654
+ * Wrap text with ANSI codes preserved.
655
+ *
656
+ * ONLY does word wrapping - NO padding, NO background colors.
657
+ * Returns lines where each line is <= width visible chars.
658
+ * Active ANSI codes are preserved across line breaks.
659
+ *
660
+ * @param text - Text to wrap (may contain ANSI codes and newlines)
661
+ * @param width - Maximum visible width per line
662
+ * @returns Array of wrapped lines (NOT padded to width)
663
+ */
664
+ export function wrapTextWithAnsi(text: string, width: number): string[] {
665
+ if (!text) {
666
+ return ['']
667
+ }
668
+
669
+ // Handle newlines by processing each line separately
670
+ // Track ANSI state across lines so styles carry over after literal newlines
671
+ const inputLines = text.split('\n')
672
+ const result: string[] = []
673
+ const tracker = new AnsiCodeTracker()
674
+
675
+ for (const inputLine of inputLines) {
676
+ // Prepend active ANSI codes from previous lines (except for first line)
677
+ const prefix = result.length > 0 ? tracker.getActiveCodes() : ''
678
+ result.push(...wrapSingleLine(prefix + inputLine, width))
679
+ // Update tracker with codes from this line for next iteration
680
+ updateTrackerFromText(inputLine, tracker)
681
+ }
682
+
683
+ return result.length > 0 ? result : ['']
684
+ }
685
+
686
+ function wrapSingleLine(line: string, width: number): string[] {
687
+ if (!line) {
688
+ return ['']
689
+ }
690
+
691
+ const visibleLength = visibleWidth(line)
692
+ if (visibleLength <= width) {
693
+ return [line]
694
+ }
695
+
696
+ const wrapped: string[] = []
697
+ const tracker = new AnsiCodeTracker()
698
+ const tokens = splitIntoTokensWithAnsi(line)
699
+
700
+ let currentLine = ''
701
+ let currentVisibleLength = 0
702
+
703
+ for (const token of tokens) {
704
+ const tokenVisibleLength = visibleWidth(token)
705
+ const isWhitespace = token.trim() === ''
706
+
707
+ // Token itself is too long - break it character by character
708
+ if (tokenVisibleLength > width && !isWhitespace) {
709
+ if (currentLine) {
710
+ // Add specific reset for underline only (preserves background)
711
+ const lineEndReset = tracker.getLineEndReset()
712
+ if (lineEndReset) {
713
+ currentLine += lineEndReset
714
+ }
715
+ wrapped.push(currentLine)
716
+ currentLine = ''
717
+ currentVisibleLength = 0
718
+ }
719
+
720
+ // Break long token - breakLongWord handles its own resets
721
+ const broken = breakLongWord(token, width, tracker)
722
+ wrapped.push(...broken.slice(0, -1))
723
+ currentLine = broken[broken.length - 1]
724
+ currentVisibleLength = visibleWidth(currentLine)
725
+ continue
726
+ }
727
+
728
+ // Check if adding this token would exceed width
729
+ const totalNeeded = currentVisibleLength + tokenVisibleLength
730
+
731
+ if (totalNeeded > width && currentVisibleLength > 0) {
732
+ // Trim trailing whitespace, then add underline reset (not full reset, to preserve background)
733
+ let lineToWrap = currentLine.trimEnd()
734
+ const lineEndReset = tracker.getLineEndReset()
735
+ if (lineEndReset) {
736
+ lineToWrap += lineEndReset
737
+ }
738
+ wrapped.push(lineToWrap)
739
+ if (isWhitespace) {
740
+ // Don't start new line with whitespace
741
+ currentLine = tracker.getActiveCodes()
742
+ currentVisibleLength = 0
743
+ } else {
744
+ currentLine = tracker.getActiveCodes() + token
745
+ currentVisibleLength = tokenVisibleLength
746
+ }
747
+ } else {
748
+ // Add to current line
749
+ currentLine += token
750
+ currentVisibleLength += tokenVisibleLength
751
+ }
752
+
753
+ updateTrackerFromText(token, tracker)
754
+ }
755
+
756
+ if (currentLine) {
757
+ // No reset at end of final line - let caller handle it
758
+ wrapped.push(currentLine)
759
+ }
760
+
761
+ // Trailing whitespace can cause lines to exceed the requested width
762
+ return wrapped.length > 0 ? wrapped.map(line => line.trimEnd()) : ['']
763
+ }
764
+
765
+ export const PUNCTUATION_REGEX = /[(){}[\]<>.,;:'"!?+\-=*/\\|&%^$#@~`]/
766
+
767
+ /**
768
+ * Check if a character is whitespace.
769
+ */
770
+ export function isWhitespaceChar(char: string): boolean {
771
+ return /\s/.test(char)
772
+ }
773
+
774
+ /**
775
+ * Check if a character is punctuation.
776
+ */
777
+ export function isPunctuationChar(char: string): boolean {
778
+ return PUNCTUATION_REGEX.test(char)
779
+ }
780
+
781
+ function breakLongWord(word: string, width: number, tracker: AnsiCodeTracker): string[] {
782
+ const lines: string[] = []
783
+ let currentLine = tracker.getActiveCodes()
784
+ let currentWidth = 0
785
+
786
+ // First, separate ANSI codes from visible content
787
+ // We need to handle ANSI codes specially since they're not graphemes
788
+ let i = 0
789
+ const segments: Array<{ type: 'ansi' | 'grapheme'; value: string }> = []
790
+
791
+ while (i < word.length) {
792
+ const ansiResult = extractAnsiCode(word, i)
793
+ if (ansiResult) {
794
+ segments.push({ type: 'ansi', value: ansiResult.code })
795
+ i += ansiResult.length
796
+ } else {
797
+ // Find the next ANSI code or end of string
798
+ let end = i
799
+ while (end < word.length) {
800
+ const nextAnsi = extractAnsiCode(word, end)
801
+ if (nextAnsi) break
802
+ end++
803
+ }
804
+ // Segment this non-ANSI portion into graphemes
805
+ const textPortion = word.slice(i, end)
806
+ for (const seg of graphemeSegmenter.segment(textPortion)) {
807
+ segments.push({ type: 'grapheme', value: seg.segment })
808
+ }
809
+ i = end
810
+ }
811
+ }
812
+
813
+ // Now process segments
814
+ for (const seg of segments) {
815
+ if (seg.type === 'ansi') {
816
+ currentLine += seg.value
817
+ tracker.process(seg.value)
818
+ continue
819
+ }
820
+
821
+ const grapheme = seg.value
822
+ // Skip empty graphemes to avoid issues with string-width calculation
823
+ if (!grapheme) continue
824
+
825
+ const graphemeWidth = visibleWidth(grapheme)
826
+
827
+ if (currentWidth + graphemeWidth > width) {
828
+ // Add specific reset for underline only (preserves background)
829
+ const lineEndReset = tracker.getLineEndReset()
830
+ if (lineEndReset) {
831
+ currentLine += lineEndReset
832
+ }
833
+ lines.push(currentLine)
834
+ currentLine = tracker.getActiveCodes()
835
+ currentWidth = 0
836
+ }
837
+
838
+ currentLine += grapheme
839
+ currentWidth += graphemeWidth
840
+ }
841
+
842
+ if (currentLine) {
843
+ // No reset at end of final segment - caller handles continuation
844
+ lines.push(currentLine)
845
+ }
846
+
847
+ return lines.length > 0 ? lines : ['']
848
+ }
849
+
850
+ /**
851
+ * Apply background color to a line, padding to full width.
852
+ *
853
+ * @param line - Line of text (may contain ANSI codes)
854
+ * @param width - Total width to pad to
855
+ * @param bgFn - Background color function
856
+ * @returns Line with background applied and padded to width
857
+ */
858
+ export function applyBackgroundToLine(
859
+ line: string,
860
+ width: number,
861
+ bgFn: (text: string) => string,
862
+ ): string {
863
+ // Calculate padding needed
864
+ const visibleLen = visibleWidth(line)
865
+ const paddingNeeded = Math.max(0, width - visibleLen)
866
+ const padding = ' '.repeat(paddingNeeded)
867
+
868
+ // Apply background to content + padding
869
+ const withPadding = line + padding
870
+ return bgFn(withPadding)
871
+ }
872
+
873
+ /**
874
+ * Truncate text to fit within a maximum visible width, adding ellipsis if needed.
875
+ * Optionally pad with spaces to reach exactly maxWidth.
876
+ * Properly handles ANSI escape codes (they don't count toward width).
877
+ *
878
+ * @param text - Text to truncate (may contain ANSI codes)
879
+ * @param maxWidth - Maximum visible width
880
+ * @param ellipsis - Ellipsis string to append when truncating (default: "...")
881
+ * @param pad - If true, pad result with spaces to exactly maxWidth (default: false)
882
+ * @returns Truncated text, optionally padded to exactly maxWidth
883
+ */
884
+ export function truncateToWidth(
885
+ text: string,
886
+ maxWidth: number,
887
+ ellipsis: string = '...',
888
+ pad: boolean = false,
889
+ ): string {
890
+ if (maxWidth <= 0) {
891
+ return ''
892
+ }
893
+
894
+ if (text.length === 0) {
895
+ return pad ? ' '.repeat(maxWidth) : ''
896
+ }
897
+
898
+ const ellipsisWidth = visibleWidth(ellipsis)
899
+ if (ellipsisWidth >= maxWidth) {
900
+ const textWidth = visibleWidth(text)
901
+ if (textWidth <= maxWidth) {
902
+ return pad ? text + ' '.repeat(maxWidth - textWidth) : text
903
+ }
904
+
905
+ const clippedEllipsis = truncateFragmentToWidth(ellipsis, maxWidth)
906
+ if (clippedEllipsis.width === 0) {
907
+ return pad ? ' '.repeat(maxWidth) : ''
908
+ }
909
+ return finalizeTruncatedResult(
910
+ '',
911
+ 0,
912
+ clippedEllipsis.text,
913
+ clippedEllipsis.width,
914
+ maxWidth,
915
+ pad,
916
+ )
917
+ }
918
+
919
+ if (isPrintableAscii(text)) {
920
+ if (text.length <= maxWidth) {
921
+ return pad ? text + ' '.repeat(maxWidth - text.length) : text
922
+ }
923
+ const targetWidth = maxWidth - ellipsisWidth
924
+ return finalizeTruncatedResult(
925
+ text.slice(0, targetWidth),
926
+ targetWidth,
927
+ ellipsis,
928
+ ellipsisWidth,
929
+ maxWidth,
930
+ pad,
931
+ )
932
+ }
933
+
934
+ const targetWidth = maxWidth - ellipsisWidth
935
+ let result = ''
936
+ let pendingAnsi = ''
937
+ let visibleSoFar = 0
938
+ let keptWidth = 0
939
+ let keepContiguousPrefix = true
940
+ let overflowed = false
941
+ let exhaustedInput = false
942
+ const hasAnsi = text.includes('\x1b')
943
+ const hasTabs = text.includes('\t')
944
+
945
+ if (!hasAnsi && !hasTabs) {
946
+ for (const { segment } of graphemeSegmenter.segment(text)) {
947
+ const width = graphemeWidth(segment)
948
+ if (keepContiguousPrefix && keptWidth + width <= targetWidth) {
949
+ result += segment
950
+ keptWidth += width
951
+ } else {
952
+ keepContiguousPrefix = false
953
+ }
954
+ visibleSoFar += width
955
+ if (visibleSoFar > maxWidth) {
956
+ overflowed = true
957
+ break
958
+ }
959
+ }
960
+ exhaustedInput = !overflowed
961
+ } else {
962
+ let i = 0
963
+ while (i < text.length) {
964
+ const ansi = extractAnsiCode(text, i)
965
+ if (ansi) {
966
+ pendingAnsi += ansi.code
967
+ i += ansi.length
968
+ continue
969
+ }
970
+
971
+ if (text[i] === '\t') {
972
+ if (keepContiguousPrefix && keptWidth + 3 <= targetWidth) {
973
+ if (pendingAnsi) {
974
+ result += pendingAnsi
975
+ pendingAnsi = ''
976
+ }
977
+ result += '\t'
978
+ keptWidth += 3
979
+ } else {
980
+ keepContiguousPrefix = false
981
+ pendingAnsi = ''
982
+ }
983
+ visibleSoFar += 3
984
+ if (visibleSoFar > maxWidth) {
985
+ overflowed = true
986
+ break
987
+ }
988
+ i++
989
+ continue
990
+ }
991
+
992
+ let end = i
993
+ while (end < text.length && text[end] !== '\t') {
994
+ const nextAnsi = extractAnsiCode(text, end)
995
+ if (nextAnsi) {
996
+ break
997
+ }
998
+ end++
999
+ }
1000
+
1001
+ for (const { segment } of graphemeSegmenter.segment(text.slice(i, end))) {
1002
+ const width = graphemeWidth(segment)
1003
+ if (keepContiguousPrefix && keptWidth + width <= targetWidth) {
1004
+ if (pendingAnsi) {
1005
+ result += pendingAnsi
1006
+ pendingAnsi = ''
1007
+ }
1008
+ result += segment
1009
+ keptWidth += width
1010
+ } else {
1011
+ keepContiguousPrefix = false
1012
+ pendingAnsi = ''
1013
+ }
1014
+
1015
+ visibleSoFar += width
1016
+ if (visibleSoFar > maxWidth) {
1017
+ overflowed = true
1018
+ break
1019
+ }
1020
+ }
1021
+ if (overflowed) {
1022
+ break
1023
+ }
1024
+ i = end
1025
+ }
1026
+ exhaustedInput = i >= text.length
1027
+ }
1028
+
1029
+ if (!overflowed && exhaustedInput) {
1030
+ return pad ? text + ' '.repeat(Math.max(0, maxWidth - visibleSoFar)) : text
1031
+ }
1032
+
1033
+ return finalizeTruncatedResult(result, keptWidth, ellipsis, ellipsisWidth, maxWidth, pad)
1034
+ }
1035
+
1036
+ /**
1037
+ * Extract a range of visible columns from a line. Handles ANSI codes and wide chars.
1038
+ * @param strict - If true, exclude wide chars at boundary that would extend past the range
1039
+ */
1040
+ export function sliceByColumn(
1041
+ line: string,
1042
+ startCol: number,
1043
+ length: number,
1044
+ strict = false,
1045
+ ): string {
1046
+ return sliceWithWidth(line, startCol, length, strict).text
1047
+ }
1048
+
1049
+ /** Like sliceByColumn but also returns the actual visible width of the result. */
1050
+ export function sliceWithWidth(
1051
+ line: string,
1052
+ startCol: number,
1053
+ length: number,
1054
+ strict = false,
1055
+ ): { text: string; width: number } {
1056
+ if (length <= 0) return { text: '', width: 0 }
1057
+ const endCol = startCol + length
1058
+ let result = '',
1059
+ resultWidth = 0,
1060
+ currentCol = 0,
1061
+ i = 0,
1062
+ pendingAnsi = ''
1063
+
1064
+ while (i < line.length) {
1065
+ const ansi = extractAnsiCode(line, i)
1066
+ if (ansi) {
1067
+ if (currentCol >= startCol && currentCol < endCol) result += ansi.code
1068
+ else if (currentCol < startCol) pendingAnsi += ansi.code
1069
+ i += ansi.length
1070
+ continue
1071
+ }
1072
+
1073
+ let textEnd = i
1074
+ while (textEnd < line.length && !extractAnsiCode(line, textEnd)) textEnd++
1075
+
1076
+ for (const { segment } of graphemeSegmenter.segment(line.slice(i, textEnd))) {
1077
+ const w = graphemeWidth(segment)
1078
+ const inRange = currentCol >= startCol && currentCol < endCol
1079
+ const fits = !strict || currentCol + w <= endCol
1080
+ if (inRange && fits) {
1081
+ if (pendingAnsi) {
1082
+ result += pendingAnsi
1083
+ pendingAnsi = ''
1084
+ }
1085
+ result += segment
1086
+ resultWidth += w
1087
+ }
1088
+ currentCol += w
1089
+ if (currentCol >= endCol) break
1090
+ }
1091
+ i = textEnd
1092
+ if (currentCol >= endCol) break
1093
+ }
1094
+ return { text: result, width: resultWidth }
1095
+ }
1096
+
1097
+ // Pooled tracker instance for extractSegments (avoids allocation per call)
1098
+ const pooledStyleTracker = new AnsiCodeTracker()
1099
+
1100
+ /**
1101
+ * Extract "before" and "after" segments from a line in a single pass.
1102
+ * Used for overlay compositing where we need content before and after the overlay region.
1103
+ * Preserves styling from before the overlay that should affect content after it.
1104
+ */
1105
+ export function extractSegments(
1106
+ line: string,
1107
+ beforeEnd: number,
1108
+ afterStart: number,
1109
+ afterLen: number,
1110
+ strictAfter = false,
1111
+ ): { before: string; beforeWidth: number; after: string; afterWidth: number } {
1112
+ let before = '',
1113
+ beforeWidth = 0,
1114
+ after = '',
1115
+ afterWidth = 0
1116
+ let currentCol = 0,
1117
+ i = 0
1118
+ let pendingAnsiBefore = ''
1119
+ let afterStarted = false
1120
+ const afterEnd = afterStart + afterLen
1121
+
1122
+ // Track styling state so "after" inherits styling from before the overlay
1123
+ pooledStyleTracker.clear()
1124
+
1125
+ while (i < line.length) {
1126
+ const ansi = extractAnsiCode(line, i)
1127
+ if (ansi) {
1128
+ // Track all SGR codes to know styling state at afterStart
1129
+ pooledStyleTracker.process(ansi.code)
1130
+ // Include ANSI codes in their respective segments
1131
+ if (currentCol < beforeEnd) {
1132
+ pendingAnsiBefore += ansi.code
1133
+ } else if (currentCol >= afterStart && currentCol < afterEnd && afterStarted) {
1134
+ // Only include after we've started "after" (styling already prepended)
1135
+ after += ansi.code
1136
+ }
1137
+ i += ansi.length
1138
+ continue
1139
+ }
1140
+
1141
+ let textEnd = i
1142
+ while (textEnd < line.length && !extractAnsiCode(line, textEnd)) textEnd++
1143
+
1144
+ for (const { segment } of graphemeSegmenter.segment(line.slice(i, textEnd))) {
1145
+ const w = graphemeWidth(segment)
1146
+
1147
+ if (currentCol < beforeEnd) {
1148
+ if (pendingAnsiBefore) {
1149
+ before += pendingAnsiBefore
1150
+ pendingAnsiBefore = ''
1151
+ }
1152
+ before += segment
1153
+ beforeWidth += w
1154
+ } else if (currentCol >= afterStart && currentCol < afterEnd) {
1155
+ const fits = !strictAfter || currentCol + w <= afterEnd
1156
+ if (fits) {
1157
+ // On first "after" grapheme, prepend inherited styling from before overlay
1158
+ if (!afterStarted) {
1159
+ after += pooledStyleTracker.getActiveCodes()
1160
+ afterStarted = true
1161
+ }
1162
+ after += segment
1163
+ afterWidth += w
1164
+ }
1165
+ }
1166
+
1167
+ currentCol += w
1168
+ // Early exit: done with "before" only, or done with both segments
1169
+ if (afterLen <= 0 ? currentCol >= beforeEnd : currentCol >= afterEnd) break
1170
+ }
1171
+ i = textEnd
1172
+ if (afterLen <= 0 ? currentCol >= beforeEnd : currentCol >= afterEnd) break
1173
+ }
1174
+
1175
+ return { before, beforeWidth, after, afterWidth }
1176
+ }