@dungle-scrubs/tallow 0.8.21 → 0.8.23

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 (217) hide show
  1. package/dist/cli.js +35 -4
  2. package/dist/cli.js.map +1 -1
  3. package/dist/config.d.ts +1 -1
  4. package/dist/config.js +1 -1
  5. package/dist/interactive-mode-patch.d.ts +2 -0
  6. package/dist/interactive-mode-patch.d.ts.map +1 -1
  7. package/dist/interactive-mode-patch.js +82 -0
  8. package/dist/interactive-mode-patch.js.map +1 -1
  9. package/dist/sdk.d.ts +17 -0
  10. package/dist/sdk.d.ts.map +1 -1
  11. package/dist/sdk.js +68 -1
  12. package/dist/sdk.js.map +1 -1
  13. package/dist/workspace-transition-relay.d.ts +40 -7
  14. package/dist/workspace-transition-relay.d.ts.map +1 -1
  15. package/dist/workspace-transition-relay.js +81 -16
  16. package/dist/workspace-transition-relay.js.map +1 -1
  17. package/extensions/__integration__/background-task-widget-ownership.test.ts +216 -0
  18. package/extensions/__integration__/claude-hooks-compat.test.ts +156 -0
  19. package/extensions/__integration__/slash-command-bridge.test.ts +169 -23
  20. package/extensions/_shared/atomic-write.ts +1 -1
  21. package/extensions/_shared/bordered-box.ts +102 -0
  22. package/extensions/_shared/interop-events.ts +5 -0
  23. package/extensions/_shared/pid-registry.ts +1 -1
  24. package/extensions/agent-commands-tool/index.ts +4 -1
  25. package/extensions/background-task-tool/__tests__/lifecycle.test.ts +50 -25
  26. package/extensions/background-task-tool/index.ts +139 -221
  27. package/extensions/bash-tool-enhanced/index.ts +1 -75
  28. package/extensions/cd-tool/index.ts +2 -2
  29. package/extensions/context-fork/spawn.ts +4 -1
  30. package/extensions/health/index.ts +6 -6
  31. package/extensions/hooks/__tests__/claude-compat.test.ts +35 -0
  32. package/extensions/hooks/__tests__/subprocess-hardening.test.ts +73 -0
  33. package/extensions/hooks/index.ts +27 -4
  34. package/extensions/loop/__tests__/loop.test.ts +168 -4
  35. package/extensions/loop/extension.json +6 -5
  36. package/extensions/loop/index.ts +242 -31
  37. package/extensions/plan-mode-tool/__tests__/agent-end-execution.test.ts +373 -0
  38. package/extensions/plan-mode-tool/index.ts +103 -41
  39. package/extensions/prompt-suggestions/__tests__/editor-compatibility.test.ts +42 -0
  40. package/extensions/prompt-suggestions/index.ts +41 -6
  41. package/extensions/slash-command-bridge/__tests__/slash-command-bridge.test.ts +267 -671
  42. package/extensions/slash-command-bridge/extension.json +1 -1
  43. package/extensions/slash-command-bridge/index.ts +230 -116
  44. package/extensions/subagent-tool/index.ts +2 -2
  45. package/extensions/subagent-tool/process.ts +4 -5
  46. package/extensions/tasks/commands/register-tasks-extension.ts +41 -0
  47. package/extensions/teams-tool/__tests__/peer-messaging.test.ts +29 -24
  48. package/extensions/teams-tool/dashboard.ts +3 -5
  49. package/extensions/teams-tool/dispatch/auto-dispatch.ts +18 -1
  50. package/extensions/teams-tool/tools/teammate-tools.ts +9 -6
  51. package/extensions/wezterm-pane-control/__tests__/index.test.ts +88 -4
  52. package/extensions/wezterm-pane-control/index.ts +113 -8
  53. package/package.json +6 -4
  54. package/packages/tallow-tui/README.md +51 -0
  55. package/packages/tallow-tui/dist/autocomplete.d.ts +48 -0
  56. package/packages/tallow-tui/dist/autocomplete.d.ts.map +1 -0
  57. package/packages/tallow-tui/dist/autocomplete.js +564 -0
  58. package/packages/tallow-tui/dist/autocomplete.js.map +1 -0
  59. package/packages/tallow-tui/dist/border-styles.d.ts +32 -0
  60. package/packages/tallow-tui/dist/border-styles.d.ts.map +1 -0
  61. package/packages/tallow-tui/dist/border-styles.js +46 -0
  62. package/packages/tallow-tui/dist/border-styles.js.map +1 -0
  63. package/packages/tallow-tui/dist/components/bordered-box.d.ts +52 -0
  64. package/packages/tallow-tui/dist/components/bordered-box.d.ts.map +1 -0
  65. package/packages/tallow-tui/dist/components/bordered-box.js +89 -0
  66. package/packages/tallow-tui/dist/components/bordered-box.js.map +1 -0
  67. package/packages/tallow-tui/dist/components/box.d.ts +22 -0
  68. package/packages/tallow-tui/dist/components/box.d.ts.map +1 -0
  69. package/packages/tallow-tui/dist/components/box.js +104 -0
  70. package/packages/tallow-tui/dist/components/box.js.map +1 -0
  71. package/packages/tallow-tui/dist/components/cancellable-loader.d.ts +22 -0
  72. package/packages/tallow-tui/dist/components/cancellable-loader.d.ts.map +1 -0
  73. package/packages/tallow-tui/dist/components/cancellable-loader.js +35 -0
  74. package/packages/tallow-tui/dist/components/cancellable-loader.js.map +1 -0
  75. package/packages/tallow-tui/dist/components/editor.d.ts +240 -0
  76. package/packages/tallow-tui/dist/components/editor.d.ts.map +1 -0
  77. package/packages/tallow-tui/dist/components/editor.js +1766 -0
  78. package/packages/tallow-tui/dist/components/editor.js.map +1 -0
  79. package/packages/tallow-tui/dist/components/image.d.ts +126 -0
  80. package/packages/tallow-tui/dist/components/image.d.ts.map +1 -0
  81. package/packages/tallow-tui/dist/components/image.js +245 -0
  82. package/packages/tallow-tui/dist/components/image.js.map +1 -0
  83. package/packages/tallow-tui/dist/components/input.d.ts +37 -0
  84. package/packages/tallow-tui/dist/components/input.d.ts.map +1 -0
  85. package/packages/tallow-tui/dist/components/input.js +439 -0
  86. package/packages/tallow-tui/dist/components/input.js.map +1 -0
  87. package/packages/tallow-tui/dist/components/loader.d.ts +88 -0
  88. package/packages/tallow-tui/dist/components/loader.d.ts.map +1 -0
  89. package/packages/tallow-tui/dist/components/loader.js +146 -0
  90. package/packages/tallow-tui/dist/components/loader.js.map +1 -0
  91. package/packages/tallow-tui/dist/components/markdown.d.ts +95 -0
  92. package/packages/tallow-tui/dist/components/markdown.d.ts.map +1 -0
  93. package/packages/tallow-tui/dist/components/markdown.js +633 -0
  94. package/packages/tallow-tui/dist/components/markdown.js.map +1 -0
  95. package/packages/tallow-tui/dist/components/select-list.d.ts +32 -0
  96. package/packages/tallow-tui/dist/components/select-list.d.ts.map +1 -0
  97. package/packages/tallow-tui/dist/components/select-list.js +156 -0
  98. package/packages/tallow-tui/dist/components/select-list.js.map +1 -0
  99. package/packages/tallow-tui/dist/components/settings-list.d.ts +50 -0
  100. package/packages/tallow-tui/dist/components/settings-list.d.ts.map +1 -0
  101. package/packages/tallow-tui/dist/components/settings-list.js +189 -0
  102. package/packages/tallow-tui/dist/components/settings-list.js.map +1 -0
  103. package/packages/tallow-tui/dist/components/spacer.d.ts +12 -0
  104. package/packages/tallow-tui/dist/components/spacer.d.ts.map +1 -0
  105. package/packages/tallow-tui/dist/components/spacer.js +23 -0
  106. package/packages/tallow-tui/dist/components/spacer.js.map +1 -0
  107. package/packages/tallow-tui/dist/components/text.d.ts +19 -0
  108. package/packages/tallow-tui/dist/components/text.d.ts.map +1 -0
  109. package/packages/tallow-tui/dist/components/text.js +91 -0
  110. package/packages/tallow-tui/dist/components/text.js.map +1 -0
  111. package/packages/tallow-tui/dist/components/truncated-text.d.ts +13 -0
  112. package/packages/tallow-tui/dist/components/truncated-text.d.ts.map +1 -0
  113. package/packages/tallow-tui/dist/components/truncated-text.js +51 -0
  114. package/packages/tallow-tui/dist/components/truncated-text.js.map +1 -0
  115. package/packages/tallow-tui/dist/editor-component.d.ts +50 -0
  116. package/packages/tallow-tui/dist/editor-component.d.ts.map +1 -0
  117. package/packages/tallow-tui/dist/editor-component.js +2 -0
  118. package/packages/tallow-tui/dist/editor-component.js.map +1 -0
  119. package/packages/tallow-tui/dist/fuzzy.d.ts +16 -0
  120. package/packages/tallow-tui/dist/fuzzy.d.ts.map +1 -0
  121. package/packages/tallow-tui/dist/fuzzy.js +107 -0
  122. package/packages/tallow-tui/dist/fuzzy.js.map +1 -0
  123. package/packages/tallow-tui/dist/index.d.ts +25 -0
  124. package/packages/tallow-tui/dist/index.d.ts.map +1 -0
  125. package/packages/tallow-tui/dist/index.js +35 -0
  126. package/packages/tallow-tui/dist/index.js.map +1 -0
  127. package/packages/tallow-tui/dist/keybindings.d.ts +39 -0
  128. package/packages/tallow-tui/dist/keybindings.d.ts.map +1 -0
  129. package/packages/tallow-tui/dist/keybindings.js +114 -0
  130. package/packages/tallow-tui/dist/keybindings.js.map +1 -0
  131. package/packages/tallow-tui/dist/keys.d.ts +168 -0
  132. package/packages/tallow-tui/dist/keys.d.ts.map +1 -0
  133. package/packages/tallow-tui/dist/keys.js +971 -0
  134. package/packages/tallow-tui/dist/keys.js.map +1 -0
  135. package/packages/tallow-tui/dist/kill-ring.d.ts +28 -0
  136. package/packages/tallow-tui/dist/kill-ring.d.ts.map +1 -0
  137. package/packages/tallow-tui/dist/kill-ring.js +44 -0
  138. package/packages/tallow-tui/dist/kill-ring.js.map +1 -0
  139. package/packages/tallow-tui/dist/stdin-buffer.d.ts +48 -0
  140. package/packages/tallow-tui/dist/stdin-buffer.d.ts.map +1 -0
  141. package/packages/tallow-tui/dist/stdin-buffer.js +317 -0
  142. package/packages/tallow-tui/dist/stdin-buffer.js.map +1 -0
  143. package/packages/tallow-tui/dist/terminal-image.d.ts +161 -0
  144. package/packages/tallow-tui/dist/terminal-image.d.ts.map +1 -0
  145. package/packages/tallow-tui/dist/terminal-image.js +460 -0
  146. package/packages/tallow-tui/dist/terminal-image.js.map +1 -0
  147. package/packages/tallow-tui/dist/terminal.d.ts +102 -0
  148. package/packages/tallow-tui/dist/terminal.d.ts.map +1 -0
  149. package/packages/tallow-tui/dist/terminal.js +263 -0
  150. package/packages/tallow-tui/dist/terminal.js.map +1 -0
  151. package/packages/tallow-tui/dist/test-utils/capability-env.d.ts +14 -0
  152. package/packages/tallow-tui/dist/test-utils/capability-env.d.ts.map +1 -0
  153. package/packages/tallow-tui/dist/test-utils/capability-env.js +55 -0
  154. package/packages/tallow-tui/dist/test-utils/capability-env.js.map +1 -0
  155. package/packages/tallow-tui/dist/tui.d.ts +239 -0
  156. package/packages/tallow-tui/dist/tui.d.ts.map +1 -0
  157. package/packages/tallow-tui/dist/tui.js +1058 -0
  158. package/packages/tallow-tui/dist/tui.js.map +1 -0
  159. package/packages/tallow-tui/dist/undo-stack.d.ts +17 -0
  160. package/packages/tallow-tui/dist/undo-stack.d.ts.map +1 -0
  161. package/packages/tallow-tui/dist/undo-stack.js +25 -0
  162. package/packages/tallow-tui/dist/undo-stack.js.map +1 -0
  163. package/packages/tallow-tui/dist/utils.d.ts +96 -0
  164. package/packages/tallow-tui/dist/utils.d.ts.map +1 -0
  165. package/packages/tallow-tui/dist/utils.js +843 -0
  166. package/packages/tallow-tui/dist/utils.js.map +1 -0
  167. package/packages/tallow-tui/package.json +24 -0
  168. package/packages/tallow-tui/src/__tests__/__snapshots__/render.test.ts.snap +121 -0
  169. package/packages/tallow-tui/src/__tests__/editor-border.test.ts +72 -0
  170. package/packages/tallow-tui/src/__tests__/editor-change-listener.test.ts +121 -0
  171. package/packages/tallow-tui/src/__tests__/editor-ghost-text.test.ts +112 -0
  172. package/packages/tallow-tui/src/__tests__/fuzzy.test.ts +91 -0
  173. package/packages/tallow-tui/src/__tests__/image-component.test.ts +113 -0
  174. package/packages/tallow-tui/src/__tests__/keys.test.ts +141 -0
  175. package/packages/tallow-tui/src/__tests__/render.test.ts +179 -0
  176. package/packages/tallow-tui/src/__tests__/stdin-buffer.test.ts +82 -0
  177. package/packages/tallow-tui/src/__tests__/terminal-image.test.ts +363 -0
  178. package/packages/tallow-tui/src/__tests__/tui-diff-regression.test.ts +454 -0
  179. package/packages/tallow-tui/src/__tests__/tui-render-scheduling.test.ts +256 -0
  180. package/packages/tallow-tui/src/__tests__/utils.test.ts +259 -0
  181. package/packages/tallow-tui/src/autocomplete.ts +716 -0
  182. package/packages/tallow-tui/src/border-styles.ts +60 -0
  183. package/packages/tallow-tui/src/components/bordered-box.ts +113 -0
  184. package/packages/tallow-tui/src/components/box.ts +137 -0
  185. package/packages/tallow-tui/src/components/cancellable-loader.ts +40 -0
  186. package/packages/tallow-tui/src/components/editor.ts +2143 -0
  187. package/packages/tallow-tui/src/components/image.ts +315 -0
  188. package/packages/tallow-tui/src/components/input.ts +522 -0
  189. package/packages/tallow-tui/src/components/loader.ts +187 -0
  190. package/packages/tallow-tui/src/components/markdown.ts +780 -0
  191. package/packages/tallow-tui/src/components/select-list.ts +197 -0
  192. package/packages/tallow-tui/src/components/settings-list.ts +264 -0
  193. package/packages/tallow-tui/src/components/spacer.ts +28 -0
  194. package/packages/tallow-tui/src/components/text.ts +113 -0
  195. package/packages/tallow-tui/src/components/truncated-text.ts +65 -0
  196. package/packages/tallow-tui/src/editor-component.ts +92 -0
  197. package/packages/tallow-tui/src/fuzzy.ts +133 -0
  198. package/packages/tallow-tui/src/index.ts +118 -0
  199. package/packages/tallow-tui/src/keybindings.ts +183 -0
  200. package/packages/tallow-tui/src/keys.ts +1189 -0
  201. package/packages/tallow-tui/src/kill-ring.ts +46 -0
  202. package/packages/tallow-tui/src/stdin-buffer.ts +386 -0
  203. package/packages/tallow-tui/src/terminal-image.ts +619 -0
  204. package/packages/tallow-tui/src/terminal.ts +350 -0
  205. package/packages/tallow-tui/src/test-utils/capability-env.ts +56 -0
  206. package/packages/tallow-tui/src/tui.ts +1336 -0
  207. package/packages/tallow-tui/src/undo-stack.ts +28 -0
  208. package/packages/tallow-tui/src/utils.ts +948 -0
  209. package/packages/tallow-tui/tsconfig.build.json +21 -0
  210. package/runtime/agent-runner.ts +20 -0
  211. package/runtime/atomic-write.ts +8 -0
  212. package/runtime/otel.ts +12 -0
  213. package/runtime/resolve-module.ts +23 -0
  214. package/runtime/runtime-path-provider.ts +12 -0
  215. package/runtime/runtime-provenance.ts +17 -0
  216. package/runtime/workspace-transition-relay.ts +21 -0
  217. package/runtime/workspace-transition.ts +29 -0
@@ -0,0 +1,948 @@
1
+ import { eastAsianWidth } from "get-east-asian-width";
2
+
3
+ // Grapheme segmenter (shared instance)
4
+ const segmenter = new Intl.Segmenter(undefined, { granularity: "grapheme" });
5
+
6
+ /**
7
+ * Get the shared grapheme segmenter instance.
8
+ */
9
+ export function getSegmenter(): Intl.Segmenter {
10
+ return segmenter;
11
+ }
12
+
13
+ /**
14
+ * Check if a grapheme cluster (after segmentation) could possibly be an RGI emoji.
15
+ * This is a fast heuristic to avoid the expensive rgiEmojiRegex test.
16
+ * The tested Unicode blocks are deliberately broad to account for future
17
+ * Unicode additions.
18
+ */
19
+ function couldBeEmoji(segment: string): boolean {
20
+ const cp = segment.codePointAt(0)!;
21
+ return (
22
+ (cp >= 0x1f000 && cp <= 0x1fbff) || // Emoji and Pictograph
23
+ (cp >= 0x2300 && cp <= 0x23ff) || // Misc technical
24
+ (cp >= 0x2600 && cp <= 0x27bf) || // Misc symbols, dingbats
25
+ (cp >= 0x2b50 && cp <= 0x2b55) || // Specific stars/circles
26
+ segment.includes("\uFE0F") || // Contains VS16 (emoji presentation selector)
27
+ segment.length > 2 // Multi-codepoint sequences (ZWJ, skin tones, etc.)
28
+ );
29
+ }
30
+
31
+ // Regexes for character classification (same as string-width library)
32
+ const zeroWidthRegex =
33
+ /^(?:\p{Default_Ignorable_Code_Point}|\p{Control}|\p{Mark}|\p{Surrogate})+$/v;
34
+ const leadingNonPrintingRegex =
35
+ /^[\p{Default_Ignorable_Code_Point}\p{Control}\p{Format}\p{Mark}\p{Surrogate}]+/v;
36
+ const rgiEmojiRegex = /^\p{RGI_Emoji}$/v;
37
+
38
+ // Cache for non-ASCII strings
39
+ const WIDTH_CACHE_SIZE = 512;
40
+ const widthCache = new Map<string, number>();
41
+
42
+ /**
43
+ * Calculate the terminal width of a single grapheme cluster.
44
+ * Based on code from the string-width library, but includes a possible-emoji
45
+ * check to avoid running the RGI_Emoji regex unnecessarily.
46
+ */
47
+ function graphemeWidth(segment: string): number {
48
+ // Zero-width clusters
49
+ if (zeroWidthRegex.test(segment)) {
50
+ return 0;
51
+ }
52
+
53
+ // Emoji check with pre-filter
54
+ if (couldBeEmoji(segment) && rgiEmojiRegex.test(segment)) {
55
+ return 2;
56
+ }
57
+
58
+ // Get base visible codepoint
59
+ const base = segment.replace(leadingNonPrintingRegex, "");
60
+ const cp = base.codePointAt(0);
61
+ if (cp === undefined) {
62
+ return 0;
63
+ }
64
+
65
+ let width = eastAsianWidth(cp);
66
+
67
+ // Trailing halfwidth/fullwidth forms
68
+ if (segment.length > 1) {
69
+ for (const char of segment.slice(1)) {
70
+ const c = char.codePointAt(0)!;
71
+ if (c >= 0xff00 && c <= 0xffef) {
72
+ width += eastAsianWidth(c);
73
+ }
74
+ }
75
+ }
76
+
77
+ return width;
78
+ }
79
+
80
+ /**
81
+ * Calculate the visible width of a string in terminal columns.
82
+ */
83
+ export function visibleWidth(str: string): number {
84
+ if (str.length === 0) {
85
+ return 0;
86
+ }
87
+
88
+ // Fast path: pure ASCII printable
89
+ let isPureAscii = true;
90
+ for (let i = 0; i < str.length; i++) {
91
+ const code = str.charCodeAt(i);
92
+ if (code < 0x20 || code > 0x7e) {
93
+ isPureAscii = false;
94
+ break;
95
+ }
96
+ }
97
+ if (isPureAscii) {
98
+ return str.length;
99
+ }
100
+
101
+ // Check cache
102
+ const cached = widthCache.get(str);
103
+ if (cached !== undefined) {
104
+ return cached;
105
+ }
106
+
107
+ // Normalize: tabs to 3 spaces, strip ANSI escape codes
108
+ let clean = str;
109
+ if (str.includes("\t")) {
110
+ clean = clean.replace(/\t/g, " ");
111
+ }
112
+ if (clean.includes("\x1b")) {
113
+ // Order matters: strip OSC/APC BEFORE CSI. Unterminated OSC sequences
114
+ // use [^\x07\x1b]* which stops at the next ESC byte. If CSI sequences
115
+ // (like \x1b[39m) are stripped first, the ESC boundary disappears and
116
+ // the unterminated OSC consumes visible text that followed the CSI.
117
+ //
118
+ // Strip all OSC sequences: \x1b]...\x07 or \x1b]...\x1b\\
119
+ // Also handles unterminated OSC sequences (no BEL/ST) that run to
120
+ // the next ESC or end-of-string. Programs like bun test emit bare
121
+ // \x1b]1337;SetUserVar=... without a terminator.
122
+ clean = clean.replace(/\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)?/g, "");
123
+ // Strip APC sequences: \x1b_...\x07 or \x1b_...\x1b\\
124
+ // Also handles unterminated APC sequences.
125
+ clean = clean.replace(/\x1b_[^\x07\x1b]*(?:\x07|\x1b\\)?/g, "");
126
+ // Strip all CSI sequences: ESC [ (optional ?/!/>) params final-byte (0x40-0x7E)
127
+ // Covers SGR (m), cursor movement (A-H), erase (J/K), DEC private mode (?25l/h), etc.
128
+ clean = clean.replace(/\x1b\[[?!>]?[0-9;]*[\x40-\x7e]/g, "");
129
+ }
130
+
131
+ // Calculate width
132
+ let width = 0;
133
+ for (const { segment } of segmenter.segment(clean)) {
134
+ width += graphemeWidth(segment);
135
+ }
136
+
137
+ // Cache result
138
+ if (widthCache.size >= WIDTH_CACHE_SIZE) {
139
+ const firstKey = widthCache.keys().next().value;
140
+ if (firstKey !== undefined) {
141
+ widthCache.delete(firstKey);
142
+ }
143
+ }
144
+ widthCache.set(str, width);
145
+
146
+ return width;
147
+ }
148
+
149
+ /**
150
+ * Extract ANSI escape sequences from a string at the given position.
151
+ */
152
+ export function extractAnsiCode(str: string, pos: number): { code: string; length: number } | null {
153
+ if (pos >= str.length || str[pos] !== "\x1b") return null;
154
+
155
+ const next = str[pos + 1];
156
+
157
+ // CSI sequence: ESC [ (optional ?/!/>) digits/semicolons then final byte (0x40-0x7E)
158
+ // Covers SGR, cursor movement, erase, DEC private mode, etc.
159
+ if (next === "[") {
160
+ let j = pos + 2;
161
+ // Skip optional DEC private mode prefix (?, !, >)
162
+ if (j < str.length && (str[j] === "?" || str[j] === "!" || str[j] === ">")) j++;
163
+ // Skip parameter bytes (digits and semicolons)
164
+ while (j < str.length && /[0-9;]/.test(str[j]!)) j++;
165
+ // Must end with a final byte in the 0x40-0x7E range (@ through ~)
166
+ const code = str.charCodeAt(j);
167
+ if (j < str.length && code >= 0x40 && code <= 0x7e) {
168
+ return { code: str.substring(pos, j + 1), length: j + 1 - pos };
169
+ }
170
+ return null;
171
+ }
172
+
173
+ // OSC sequence: ESC ] ... BEL or ESC ] ... ST (ESC \)
174
+ // Used for hyperlinks (OSC 8), window titles, etc.
175
+ if (next === "]") {
176
+ let j = pos + 2;
177
+ while (j < str.length) {
178
+ if (str[j] === "\x07") return { code: str.substring(pos, j + 1), length: j + 1 - pos };
179
+ if (str[j] === "\x1b" && str[j + 1] === "\\")
180
+ return { code: str.substring(pos, j + 2), length: j + 2 - pos };
181
+ j++;
182
+ }
183
+ return null;
184
+ }
185
+
186
+ // APC sequence: ESC _ ... BEL or ESC _ ... ST (ESC \)
187
+ // Used for cursor marker and application-specific commands
188
+ if (next === "_") {
189
+ let j = pos + 2;
190
+ while (j < str.length) {
191
+ if (str[j] === "\x07") return { code: str.substring(pos, j + 1), length: j + 1 - pos };
192
+ if (str[j] === "\x1b" && str[j + 1] === "\\")
193
+ return { code: str.substring(pos, j + 2), length: j + 2 - pos };
194
+ j++;
195
+ }
196
+ return null;
197
+ }
198
+
199
+ return null;
200
+ }
201
+
202
+ /**
203
+ * Track active ANSI SGR codes to preserve styling across line breaks.
204
+ */
205
+ class AnsiCodeTracker {
206
+ // Track individual attributes separately so we can reset them specifically
207
+ private bold = false;
208
+ private dim = false;
209
+ private italic = false;
210
+ private underline = false;
211
+ private blink = false;
212
+ private inverse = false;
213
+ private hidden = false;
214
+ private strikethrough = false;
215
+ private fgColor: string | null = null; // Stores the full code like "31" or "38;5;240"
216
+ private bgColor: string | null = null; // Stores the full code like "41" or "48;5;240"
217
+
218
+ process(ansiCode: string): void {
219
+ if (!ansiCode.endsWith("m")) {
220
+ return;
221
+ }
222
+
223
+ // Extract the parameters between \x1b[ and m
224
+ const match = ansiCode.match(/\x1b\[([\d;]*)m/);
225
+ if (!match) return;
226
+
227
+ const params = match[1];
228
+ if (params === "" || params === "0") {
229
+ // Full reset
230
+ this.reset();
231
+ return;
232
+ }
233
+
234
+ // Parse parameters (can be semicolon-separated)
235
+ const parts = params.split(";");
236
+ let i = 0;
237
+ while (i < parts.length) {
238
+ const code = Number.parseInt(parts[i], 10);
239
+
240
+ // Handle 256-color and RGB codes which consume multiple parameters
241
+ if (code === 38 || code === 48) {
242
+ // 38;5;N (256 color fg) or 38;2;R;G;B (RGB fg)
243
+ // 48;5;N (256 color bg) or 48;2;R;G;B (RGB bg)
244
+ if (parts[i + 1] === "5" && parts[i + 2] !== undefined) {
245
+ // 256 color: 38;5;N or 48;5;N
246
+ const colorCode = `${parts[i]};${parts[i + 1]};${parts[i + 2]}`;
247
+ if (code === 38) {
248
+ this.fgColor = colorCode;
249
+ } else {
250
+ this.bgColor = colorCode;
251
+ }
252
+ i += 3;
253
+ continue;
254
+ } else if (parts[i + 1] === "2" && parts[i + 4] !== undefined) {
255
+ // RGB color: 38;2;R;G;B or 48;2;R;G;B
256
+ const colorCode = `${parts[i]};${parts[i + 1]};${parts[i + 2]};${parts[i + 3]};${parts[i + 4]}`;
257
+ if (code === 38) {
258
+ this.fgColor = colorCode;
259
+ } else {
260
+ this.bgColor = colorCode;
261
+ }
262
+ i += 5;
263
+ continue;
264
+ }
265
+ }
266
+
267
+ // Standard SGR codes
268
+ switch (code) {
269
+ case 0:
270
+ this.reset();
271
+ break;
272
+ case 1:
273
+ this.bold = true;
274
+ break;
275
+ case 2:
276
+ this.dim = true;
277
+ break;
278
+ case 3:
279
+ this.italic = true;
280
+ break;
281
+ case 4:
282
+ this.underline = true;
283
+ break;
284
+ case 5:
285
+ this.blink = true;
286
+ break;
287
+ case 7:
288
+ this.inverse = true;
289
+ break;
290
+ case 8:
291
+ this.hidden = true;
292
+ break;
293
+ case 9:
294
+ this.strikethrough = true;
295
+ break;
296
+ case 21:
297
+ this.bold = false;
298
+ break; // Some terminals
299
+ case 22:
300
+ this.bold = false;
301
+ this.dim = false;
302
+ break;
303
+ case 23:
304
+ this.italic = false;
305
+ break;
306
+ case 24:
307
+ this.underline = false;
308
+ break;
309
+ case 25:
310
+ this.blink = false;
311
+ break;
312
+ case 27:
313
+ this.inverse = false;
314
+ break;
315
+ case 28:
316
+ this.hidden = false;
317
+ break;
318
+ case 29:
319
+ this.strikethrough = false;
320
+ break;
321
+ case 39:
322
+ this.fgColor = null;
323
+ break; // Default fg
324
+ case 49:
325
+ this.bgColor = null;
326
+ break; // Default bg
327
+ default:
328
+ // Standard foreground colors 30-37, 90-97
329
+ if ((code >= 30 && code <= 37) || (code >= 90 && code <= 97)) {
330
+ this.fgColor = String(code);
331
+ }
332
+ // Standard background colors 40-47, 100-107
333
+ else if ((code >= 40 && code <= 47) || (code >= 100 && code <= 107)) {
334
+ this.bgColor = String(code);
335
+ }
336
+ break;
337
+ }
338
+ i++;
339
+ }
340
+ }
341
+
342
+ private reset(): void {
343
+ this.bold = false;
344
+ this.dim = false;
345
+ this.italic = false;
346
+ this.underline = false;
347
+ this.blink = false;
348
+ this.inverse = false;
349
+ this.hidden = false;
350
+ this.strikethrough = false;
351
+ this.fgColor = null;
352
+ this.bgColor = null;
353
+ }
354
+
355
+ /** Clear all state for reuse. */
356
+ clear(): void {
357
+ this.reset();
358
+ }
359
+
360
+ getActiveCodes(): string {
361
+ const codes: string[] = [];
362
+ if (this.bold) codes.push("1");
363
+ if (this.dim) codes.push("2");
364
+ if (this.italic) codes.push("3");
365
+ if (this.underline) codes.push("4");
366
+ if (this.blink) codes.push("5");
367
+ if (this.inverse) codes.push("7");
368
+ if (this.hidden) codes.push("8");
369
+ if (this.strikethrough) codes.push("9");
370
+ if (this.fgColor) codes.push(this.fgColor);
371
+ if (this.bgColor) codes.push(this.bgColor);
372
+
373
+ if (codes.length === 0) return "";
374
+ return `\x1b[${codes.join(";")}m`;
375
+ }
376
+
377
+ hasActiveCodes(): boolean {
378
+ return (
379
+ this.bold ||
380
+ this.dim ||
381
+ this.italic ||
382
+ this.underline ||
383
+ this.blink ||
384
+ this.inverse ||
385
+ this.hidden ||
386
+ this.strikethrough ||
387
+ this.fgColor !== null ||
388
+ this.bgColor !== null
389
+ );
390
+ }
391
+
392
+ /**
393
+ * Get reset codes for attributes that need to be turned off at line end,
394
+ * specifically underline which bleeds into padding.
395
+ * Returns empty string if no problematic attributes are active.
396
+ */
397
+ getLineEndReset(): string {
398
+ // Only underline causes visual bleeding into padding
399
+ // Other attributes like colors don't visually bleed to padding
400
+ if (this.underline) {
401
+ return "\x1b[24m"; // Underline off only
402
+ }
403
+ return "";
404
+ }
405
+ }
406
+
407
+ function updateTrackerFromText(text: string, tracker: AnsiCodeTracker): void {
408
+ let i = 0;
409
+ while (i < text.length) {
410
+ const ansiResult = extractAnsiCode(text, i);
411
+ if (ansiResult) {
412
+ tracker.process(ansiResult.code);
413
+ i += ansiResult.length;
414
+ } else {
415
+ i++;
416
+ }
417
+ }
418
+ }
419
+
420
+ /**
421
+ * Split text into words while keeping ANSI codes attached.
422
+ */
423
+ function splitIntoTokensWithAnsi(text: string): string[] {
424
+ const tokens: string[] = [];
425
+ let current = "";
426
+ let pendingAnsi = ""; // ANSI codes waiting to be attached to next visible content
427
+ let inWhitespace = false;
428
+ let i = 0;
429
+
430
+ while (i < text.length) {
431
+ const ansiResult = extractAnsiCode(text, i);
432
+ if (ansiResult) {
433
+ // Hold ANSI codes separately - they'll be attached to the next visible char
434
+ pendingAnsi += ansiResult.code;
435
+ i += ansiResult.length;
436
+ continue;
437
+ }
438
+
439
+ const char = text[i];
440
+ const charIsSpace = char === " ";
441
+
442
+ if (charIsSpace !== inWhitespace && current) {
443
+ // Switching between whitespace and non-whitespace, push current token
444
+ tokens.push(current);
445
+ current = "";
446
+ }
447
+
448
+ // Attach any pending ANSI codes to this visible character
449
+ if (pendingAnsi) {
450
+ current += pendingAnsi;
451
+ pendingAnsi = "";
452
+ }
453
+
454
+ inWhitespace = charIsSpace;
455
+ current += char;
456
+ i++;
457
+ }
458
+
459
+ // Handle any remaining pending ANSI codes (attach to last token)
460
+ if (pendingAnsi) {
461
+ current += pendingAnsi;
462
+ }
463
+
464
+ if (current) {
465
+ tokens.push(current);
466
+ }
467
+
468
+ return tokens;
469
+ }
470
+
471
+ /**
472
+ * Wrap text with ANSI codes preserved.
473
+ *
474
+ * ONLY does word wrapping - NO padding, NO background colors.
475
+ * Returns lines where each line is <= width visible chars.
476
+ * Active ANSI codes are preserved across line breaks.
477
+ *
478
+ * @param text - Text to wrap (may contain ANSI codes and newlines)
479
+ * @param width - Maximum visible width per line
480
+ * @returns Array of wrapped lines (NOT padded to width)
481
+ */
482
+ export function wrapTextWithAnsi(text: string, width: number): string[] {
483
+ if (!text) {
484
+ return [""];
485
+ }
486
+
487
+ // Handle newlines by processing each line separately
488
+ // Track ANSI state across lines so styles carry over after literal newlines
489
+ const inputLines = text.split("\n");
490
+ const result: string[] = [];
491
+ const tracker = new AnsiCodeTracker();
492
+
493
+ for (const inputLine of inputLines) {
494
+ // Prepend active ANSI codes from previous lines (except for first line)
495
+ const prefix = result.length > 0 ? tracker.getActiveCodes() : "";
496
+ result.push(...wrapSingleLine(prefix + inputLine, width));
497
+ // Update tracker with codes from this line for next iteration
498
+ updateTrackerFromText(inputLine, tracker);
499
+ }
500
+
501
+ return result.length > 0 ? result : [""];
502
+ }
503
+
504
+ function wrapSingleLine(line: string, width: number): string[] {
505
+ if (!line) {
506
+ return [""];
507
+ }
508
+
509
+ const visibleLength = visibleWidth(line);
510
+ if (visibleLength <= width) {
511
+ return [line];
512
+ }
513
+
514
+ const wrapped: string[] = [];
515
+ const tracker = new AnsiCodeTracker();
516
+ const tokens = splitIntoTokensWithAnsi(line);
517
+
518
+ let currentLine = "";
519
+ let currentVisibleLength = 0;
520
+
521
+ for (const token of tokens) {
522
+ const tokenVisibleLength = visibleWidth(token);
523
+ const isWhitespace = token.trim() === "";
524
+
525
+ // Token itself is too long - break it character by character
526
+ if (tokenVisibleLength > width && !isWhitespace) {
527
+ if (currentLine) {
528
+ // Add specific reset for underline only (preserves background)
529
+ const lineEndReset = tracker.getLineEndReset();
530
+ if (lineEndReset) {
531
+ currentLine += lineEndReset;
532
+ }
533
+ wrapped.push(currentLine);
534
+ currentLine = "";
535
+ currentVisibleLength = 0;
536
+ }
537
+
538
+ // Break long token - breakLongWord handles its own resets
539
+ const broken = breakLongWord(token, width, tracker);
540
+ wrapped.push(...broken.slice(0, -1));
541
+ currentLine = broken[broken.length - 1];
542
+ currentVisibleLength = visibleWidth(currentLine);
543
+ continue;
544
+ }
545
+
546
+ // Check if adding this token would exceed width
547
+ const totalNeeded = currentVisibleLength + tokenVisibleLength;
548
+
549
+ if (totalNeeded > width && currentVisibleLength > 0) {
550
+ // Trim trailing whitespace, then add underline reset (not full reset, to preserve background)
551
+ let lineToWrap = currentLine.trimEnd();
552
+ const lineEndReset = tracker.getLineEndReset();
553
+ if (lineEndReset) {
554
+ lineToWrap += lineEndReset;
555
+ }
556
+ wrapped.push(lineToWrap);
557
+ if (isWhitespace) {
558
+ // Don't start new line with whitespace
559
+ currentLine = tracker.getActiveCodes();
560
+ currentVisibleLength = 0;
561
+ } else {
562
+ currentLine = tracker.getActiveCodes() + token;
563
+ currentVisibleLength = tokenVisibleLength;
564
+ }
565
+ } else {
566
+ // Add to current line
567
+ currentLine += token;
568
+ currentVisibleLength += tokenVisibleLength;
569
+ }
570
+
571
+ updateTrackerFromText(token, tracker);
572
+ }
573
+
574
+ if (currentLine) {
575
+ // No reset at end of final line - let caller handle it
576
+ wrapped.push(currentLine);
577
+ }
578
+
579
+ // Trailing whitespace can cause lines to exceed the requested width
580
+ return wrapped.length > 0 ? wrapped.map((line) => line.trimEnd()) : [""];
581
+ }
582
+
583
+ const PUNCTUATION_REGEX = /[(){}[\]<>.,;:'"!?+\-=*/\\|&%^$#@~`]/;
584
+
585
+ /**
586
+ * Check if a character is whitespace.
587
+ */
588
+ export function isWhitespaceChar(char: string): boolean {
589
+ return /\s/.test(char);
590
+ }
591
+
592
+ /**
593
+ * Check if a character is punctuation.
594
+ */
595
+ export function isPunctuationChar(char: string): boolean {
596
+ return PUNCTUATION_REGEX.test(char);
597
+ }
598
+
599
+ function breakLongWord(word: string, width: number, tracker: AnsiCodeTracker): string[] {
600
+ const lines: string[] = [];
601
+ let currentLine = tracker.getActiveCodes();
602
+ let currentWidth = 0;
603
+
604
+ // First, separate ANSI codes from visible content
605
+ // We need to handle ANSI codes specially since they're not graphemes
606
+ let i = 0;
607
+ const segments: Array<{ type: "ansi" | "grapheme"; value: string }> = [];
608
+
609
+ while (i < word.length) {
610
+ const ansiResult = extractAnsiCode(word, i);
611
+ if (ansiResult) {
612
+ segments.push({ type: "ansi", value: ansiResult.code });
613
+ i += ansiResult.length;
614
+ } else {
615
+ // Find the next ANSI code or end of string
616
+ let end = i;
617
+ while (end < word.length) {
618
+ const nextAnsi = extractAnsiCode(word, end);
619
+ if (nextAnsi) break;
620
+ end++;
621
+ }
622
+ // Segment this non-ANSI portion into graphemes
623
+ const textPortion = word.slice(i, end);
624
+ for (const seg of segmenter.segment(textPortion)) {
625
+ segments.push({ type: "grapheme", value: seg.segment });
626
+ }
627
+ i = end;
628
+ }
629
+ }
630
+
631
+ // Now process segments
632
+ for (const seg of segments) {
633
+ if (seg.type === "ansi") {
634
+ currentLine += seg.value;
635
+ tracker.process(seg.value);
636
+ continue;
637
+ }
638
+
639
+ const grapheme = seg.value;
640
+ // Skip empty graphemes to avoid issues with string-width calculation
641
+ if (!grapheme) continue;
642
+
643
+ const graphemeWidth = visibleWidth(grapheme);
644
+
645
+ if (currentWidth + graphemeWidth > width) {
646
+ // Add specific reset for underline only (preserves background)
647
+ const lineEndReset = tracker.getLineEndReset();
648
+ if (lineEndReset) {
649
+ currentLine += lineEndReset;
650
+ }
651
+ lines.push(currentLine);
652
+ currentLine = tracker.getActiveCodes();
653
+ currentWidth = 0;
654
+ }
655
+
656
+ currentLine += grapheme;
657
+ currentWidth += graphemeWidth;
658
+ }
659
+
660
+ if (currentLine) {
661
+ // No reset at end of final segment - caller handles continuation
662
+ lines.push(currentLine);
663
+ }
664
+
665
+ return lines.length > 0 ? lines : [""];
666
+ }
667
+
668
+ /**
669
+ * Apply background color to a line, padding to full width.
670
+ *
671
+ * @param line - Line of text (may contain ANSI codes)
672
+ * @param width - Total width to pad to
673
+ * @param bgFn - Background color function
674
+ * @returns Line with background applied and padded to width
675
+ */
676
+ export function applyBackgroundToLine(
677
+ line: string,
678
+ width: number,
679
+ bgFn: (text: string) => string
680
+ ): string {
681
+ // Calculate padding needed
682
+ const visibleLen = visibleWidth(line);
683
+ const paddingNeeded = Math.max(0, width - visibleLen);
684
+ const padding = " ".repeat(paddingNeeded);
685
+
686
+ // Apply background to content + padding
687
+ const withPadding = line + padding;
688
+ return bgFn(withPadding);
689
+ }
690
+
691
+ /**
692
+ * Truncate text to fit within a maximum visible width, adding ellipsis if needed.
693
+ * Optionally pad with spaces to reach exactly maxWidth.
694
+ * Properly handles ANSI escape codes (they don't count toward width).
695
+ *
696
+ * @param text - Text to truncate (may contain ANSI codes)
697
+ * @param maxWidth - Maximum visible width
698
+ * @param ellipsis - Ellipsis string to append when truncating (default: "...")
699
+ * @param pad - If true, pad result with spaces to exactly maxWidth (default: false)
700
+ * @returns Truncated text, optionally padded to exactly maxWidth
701
+ */
702
+ export function truncateToWidth(
703
+ text: string,
704
+ maxWidth: number,
705
+ ellipsis: string = "...",
706
+ pad: boolean = false
707
+ ): string {
708
+ const textVisibleWidth = visibleWidth(text);
709
+
710
+ if (textVisibleWidth <= maxWidth) {
711
+ return pad ? text + " ".repeat(maxWidth - textVisibleWidth) : text;
712
+ }
713
+
714
+ const ellipsisWidth = visibleWidth(ellipsis);
715
+ const targetWidth = maxWidth - ellipsisWidth;
716
+
717
+ if (targetWidth <= 0) {
718
+ return ellipsis.substring(0, maxWidth);
719
+ }
720
+
721
+ // Separate ANSI codes from visible content using grapheme segmentation
722
+ let i = 0;
723
+ const segments: Array<{ type: "ansi" | "grapheme"; value: string }> = [];
724
+
725
+ while (i < text.length) {
726
+ const ansiResult = extractAnsiCode(text, i);
727
+ if (ansiResult) {
728
+ segments.push({ type: "ansi", value: ansiResult.code });
729
+ i += ansiResult.length;
730
+ } else {
731
+ // Find the next ANSI code or end of string
732
+ let end = i;
733
+ while (end < text.length) {
734
+ const nextAnsi = extractAnsiCode(text, end);
735
+ if (nextAnsi) break;
736
+ end++;
737
+ }
738
+ // Segment this non-ANSI portion into graphemes
739
+ const textPortion = text.slice(i, end);
740
+ for (const seg of segmenter.segment(textPortion)) {
741
+ segments.push({ type: "grapheme", value: seg.segment });
742
+ }
743
+ i = end;
744
+ }
745
+ }
746
+
747
+ // Build truncated string from segments
748
+ let result = "";
749
+ let currentWidth = 0;
750
+
751
+ for (const seg of segments) {
752
+ if (seg.type === "ansi") {
753
+ result += seg.value;
754
+ continue;
755
+ }
756
+
757
+ const grapheme = seg.value;
758
+ // Skip empty graphemes to avoid issues with string-width calculation
759
+ if (!grapheme) continue;
760
+
761
+ const graphemeWidth = visibleWidth(grapheme);
762
+
763
+ if (currentWidth + graphemeWidth > targetWidth) {
764
+ break;
765
+ }
766
+
767
+ result += grapheme;
768
+ currentWidth += graphemeWidth;
769
+ }
770
+
771
+ // Reset AFTER ellipsis so it inherits the line's current ANSI state
772
+ // (e.g. red/green foreground on diff lines) but doesn't leak into subsequent content
773
+ const truncated = `${result}${ellipsis}\x1b[0m`;
774
+ if (pad) {
775
+ const truncatedWidth = visibleWidth(truncated);
776
+ return truncated + " ".repeat(Math.max(0, maxWidth - truncatedWidth));
777
+ }
778
+ return truncated;
779
+ }
780
+
781
+ /**
782
+ * Extract a range of visible columns from a line. Handles ANSI codes and wide chars.
783
+ * @param strict - If true, exclude wide chars at boundary that would extend past the range
784
+ */
785
+ export function sliceByColumn(
786
+ line: string,
787
+ startCol: number,
788
+ length: number,
789
+ strict = false
790
+ ): string {
791
+ return sliceWithWidth(line, startCol, length, strict).text;
792
+ }
793
+
794
+ /** Like sliceByColumn but also returns the actual visible width of the result. */
795
+ export function sliceWithWidth(
796
+ line: string,
797
+ startCol: number,
798
+ length: number,
799
+ strict = false
800
+ ): { text: string; width: number } {
801
+ if (length <= 0) return { text: "", width: 0 };
802
+ const endCol = startCol + length;
803
+ let result = "",
804
+ resultWidth = 0,
805
+ currentCol = 0,
806
+ i = 0,
807
+ pendingAnsi = "";
808
+
809
+ while (i < line.length) {
810
+ const ansi = extractAnsiCode(line, i);
811
+ if (ansi) {
812
+ if (currentCol >= startCol && currentCol < endCol) result += ansi.code;
813
+ else if (currentCol < startCol) pendingAnsi += ansi.code;
814
+ i += ansi.length;
815
+ continue;
816
+ }
817
+
818
+ let textEnd = i;
819
+ while (textEnd < line.length && !extractAnsiCode(line, textEnd)) textEnd++;
820
+
821
+ for (const { segment } of segmenter.segment(line.slice(i, textEnd))) {
822
+ const w = graphemeWidth(segment);
823
+ const inRange = currentCol >= startCol && currentCol < endCol;
824
+ const fits = !strict || currentCol + w <= endCol;
825
+ if (inRange && fits) {
826
+ if (pendingAnsi) {
827
+ result += pendingAnsi;
828
+ pendingAnsi = "";
829
+ }
830
+ result += segment;
831
+ resultWidth += w;
832
+ }
833
+ currentCol += w;
834
+ if (currentCol >= endCol) break;
835
+ }
836
+ i = textEnd;
837
+ if (currentCol >= endCol) break;
838
+ }
839
+ return { text: result, width: resultWidth };
840
+ }
841
+
842
+ // Pooled tracker instance for extractSegments (avoids allocation per call)
843
+ const pooledStyleTracker = new AnsiCodeTracker();
844
+
845
+ /**
846
+ * Extract "before" and "after" segments from a line in a single pass.
847
+ * Used for overlay compositing where we need content before and after the overlay region.
848
+ * Preserves styling from before the overlay that should affect content after it.
849
+ */
850
+ export function extractSegments(
851
+ line: string,
852
+ beforeEnd: number,
853
+ afterStart: number,
854
+ afterLen: number,
855
+ strictAfter = false
856
+ ): { before: string; beforeWidth: number; after: string; afterWidth: number } {
857
+ let before = "",
858
+ beforeWidth = 0,
859
+ after = "",
860
+ afterWidth = 0;
861
+ let currentCol = 0,
862
+ i = 0;
863
+ let pendingAnsiBefore = "";
864
+ let afterStarted = false;
865
+ const afterEnd = afterStart + afterLen;
866
+
867
+ // Track styling state so "after" inherits styling from before the overlay
868
+ pooledStyleTracker.clear();
869
+
870
+ while (i < line.length) {
871
+ const ansi = extractAnsiCode(line, i);
872
+ if (ansi) {
873
+ // Track all SGR codes to know styling state at afterStart
874
+ pooledStyleTracker.process(ansi.code);
875
+ // Include ANSI codes in their respective segments
876
+ if (currentCol < beforeEnd) {
877
+ pendingAnsiBefore += ansi.code;
878
+ } else if (currentCol >= afterStart && currentCol < afterEnd && afterStarted) {
879
+ // Only include after we've started "after" (styling already prepended)
880
+ after += ansi.code;
881
+ }
882
+ i += ansi.length;
883
+ continue;
884
+ }
885
+
886
+ let textEnd = i;
887
+ while (textEnd < line.length && !extractAnsiCode(line, textEnd)) textEnd++;
888
+
889
+ for (const { segment } of segmenter.segment(line.slice(i, textEnd))) {
890
+ const w = graphemeWidth(segment);
891
+
892
+ if (currentCol < beforeEnd) {
893
+ if (pendingAnsiBefore) {
894
+ before += pendingAnsiBefore;
895
+ pendingAnsiBefore = "";
896
+ }
897
+ before += segment;
898
+ beforeWidth += w;
899
+ } else if (currentCol >= afterStart && currentCol < afterEnd) {
900
+ const fits = !strictAfter || currentCol + w <= afterEnd;
901
+ if (fits) {
902
+ // On first "after" grapheme, prepend inherited styling from before overlay
903
+ if (!afterStarted) {
904
+ after += pooledStyleTracker.getActiveCodes();
905
+ afterStarted = true;
906
+ }
907
+ after += segment;
908
+ afterWidth += w;
909
+ }
910
+ }
911
+
912
+ currentCol += w;
913
+ // Early exit: done with "before" only, or done with both segments
914
+ if (afterLen <= 0 ? currentCol >= beforeEnd : currentCol >= afterEnd) break;
915
+ }
916
+ i = textEnd;
917
+ if (afterLen <= 0 ? currentCol >= beforeEnd : currentCol >= afterEnd) break;
918
+ }
919
+
920
+ return { before, beforeWidth, after, afterWidth };
921
+ }
922
+
923
+ // ─── OSC 8 Hyperlinks ────────────────────────────────────────────────────────
924
+
925
+ /**
926
+ * Wrap visible text in an OSC 8 terminal hyperlink.
927
+ * The text remains visible; clicking it opens the URL in the terminal's handler.
928
+ *
929
+ * @param url - Target URL (e.g. "file:///path/to/file", "https://...")
930
+ * @param text - Visible text to display
931
+ * @returns Text wrapped in OSC 8 escape sequences
932
+ */
933
+ export function hyperlink(url: string, text: string): string {
934
+ return `\x1b]8;;${url}\x07${text}\x1b]8;;\x07`;
935
+ }
936
+
937
+ /**
938
+ * Wrap a file path in an OSC 8 hyperlink using the file:// protocol.
939
+ * The display text is the path itself (or a custom label).
940
+ *
941
+ * @param filePath - Absolute file path
942
+ * @param displayText - Optional display text (defaults to filePath)
943
+ * @returns Path wrapped in a file:// OSC 8 hyperlink
944
+ */
945
+ export function fileLink(filePath: string, displayText?: string): string {
946
+ const url = `file://${encodeURI(filePath)}`;
947
+ return hyperlink(url, displayText ?? filePath);
948
+ }