@oh-my-pi/pi-tui 3.37.1 → 4.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oh-my-pi/pi-tui",
3
- "version": "3.37.1",
3
+ "version": "4.0.0",
4
4
  "description": "Terminal User Interface library with differential rendering for efficient text-based applications",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -1,3 +1,4 @@
1
+ import { getEditorKeybindings } from "../keybindings";
1
2
  import {
2
3
  isAltBackspace,
3
4
  isAltLeft,
@@ -13,7 +14,6 @@ import {
13
14
  isCtrlU,
14
15
  isCtrlW,
15
16
  isDelete,
16
- isEnter,
17
17
  } from "../keys";
18
18
  import type { Component } from "../tui";
19
19
  import { getSegmenter, isPunctuationChar, isWhitespaceChar, visibleWidth } from "../utils";
@@ -27,6 +27,7 @@ export class Input implements Component {
27
27
  private value: string = "";
28
28
  private cursor: number = 0; // Cursor position in the value
29
29
  public onSubmit?: (value: string) => void;
30
+ public onEscape?: () => void;
30
31
 
31
32
  // Bracketed paste mode buffering
32
33
  private pasteBuffer: string = "";
@@ -78,8 +79,14 @@ export class Input implements Component {
78
79
  }
79
80
  return;
80
81
  }
82
+ const kb = getEditorKeybindings();
83
+ if (kb.matches(data, "selectCancel")) {
84
+ this.onEscape?.();
85
+ return;
86
+ }
87
+
81
88
  // Handle special keys
82
- if (isEnter(data) || data === "\n") {
89
+ if (kb.matches(data, "submit") || data === "\n") {
83
90
  // Enter - submit
84
91
  if (this.onSubmit) {
85
92
  this.onSubmit(this.value);
@@ -0,0 +1,65 @@
1
+ import type { AutocompleteProvider } from "./autocomplete";
2
+ import type { Component } from "./tui";
3
+
4
+ /**
5
+ * Interface for custom editor components.
6
+ *
7
+ * This allows extensions to provide their own editor implementation
8
+ * (e.g., vim mode, emacs mode, custom keybindings) while maintaining
9
+ * compatibility with the core application.
10
+ */
11
+ export interface EditorComponent extends Component {
12
+ // =========================================================================
13
+ // Core text access (required)
14
+ // =========================================================================
15
+
16
+ /** Get the current text content */
17
+ getText(): string;
18
+
19
+ /** Set the text content */
20
+ setText(text: string): void;
21
+
22
+ // =========================================================================
23
+ // Callbacks (required)
24
+ // =========================================================================
25
+
26
+ /** Called when user submits (e.g., Enter key) */
27
+ onSubmit?: (text: string) => void;
28
+
29
+ /** Called when text changes */
30
+ onChange?: (text: string) => void;
31
+
32
+ // =========================================================================
33
+ // History support (optional)
34
+ // =========================================================================
35
+
36
+ /** Add text to history for up/down navigation */
37
+ addToHistory?(text: string): void;
38
+
39
+ // =========================================================================
40
+ // Advanced text manipulation (optional)
41
+ // =========================================================================
42
+
43
+ /** Insert text at current cursor position */
44
+ insertTextAtCursor?(text: string): void;
45
+
46
+ /**
47
+ * Get text with any markers expanded (e.g., paste markers).
48
+ * Falls back to getText() if not implemented.
49
+ */
50
+ getExpandedText?(): string;
51
+
52
+ // =========================================================================
53
+ // Autocomplete support (optional)
54
+ // =========================================================================
55
+
56
+ /** Set the autocomplete provider */
57
+ setAutocompleteProvider?(provider: AutocompleteProvider): void;
58
+
59
+ // =========================================================================
60
+ // Appearance (optional)
61
+ // =========================================================================
62
+
63
+ /** Border color function */
64
+ borderColor?: (str: string) => string;
65
+ }
package/src/index.ts CHANGED
@@ -21,6 +21,8 @@ export { Spacer } from "./components/spacer";
21
21
  export { type Tab, TabBar, type TabBarTheme } from "./components/tab-bar";
22
22
  export { Text } from "./components/text";
23
23
  export { TruncatedText } from "./components/truncated-text";
24
+ // Editor component interface (for custom editors)
25
+ export type { EditorComponent } from "./editor-component";
24
26
  // Keybindings
25
27
  export {
26
28
  DEFAULT_EDITOR_KEYBINDINGS,
@@ -64,6 +66,9 @@ export {
64
66
  isEnter,
65
67
  isEscape,
66
68
  isHome,
69
+ isKeyRelease,
70
+ isKeyRepeat,
71
+ isKittyProtocolActive,
67
72
  isShiftBackspace,
68
73
  isShiftCtrlD,
69
74
  isShiftCtrlO,
@@ -74,10 +79,14 @@ export {
74
79
  isShiftTab,
75
80
  isTab,
76
81
  Key,
82
+ type KeyEventType,
77
83
  type KeyId,
78
84
  matchesKey,
79
85
  parseKey,
86
+ setKittyProtocolActive,
80
87
  } from "./keys";
88
+ // Input buffering for batch splitting
89
+ export { StdinBuffer, type StdinBufferEventMap, type StdinBufferOptions } from "./stdin-buffer";
81
90
  export type { BoxSymbols, SymbolTheme } from "./symbols";
82
91
  // Terminal interface and implementations
83
92
  export { emergencyTerminalRestore, ProcessTerminal, type Terminal } from "./terminal";
package/src/keys.ts CHANGED
@@ -13,8 +13,31 @@
13
13
  * - matchesKey(data, keyId) - Check if input matches a key identifier
14
14
  * - parseKey(data) - Parse input and return the key identifier
15
15
  * - Key - Helper object for creating typed key identifiers
16
+ * - setKittyProtocolActive(active) - Set global Kitty protocol state
17
+ * - isKittyProtocolActive() - Query global Kitty protocol state
16
18
  */
17
19
 
20
+ // =============================================================================
21
+ // Global Kitty Protocol State
22
+ // =============================================================================
23
+
24
+ let kittyProtocolActive = false;
25
+
26
+ /**
27
+ * Set the global Kitty keyboard protocol state.
28
+ * Called by ProcessTerminal after detecting protocol support.
29
+ */
30
+ export function setKittyProtocolActive(active: boolean): void {
31
+ kittyProtocolActive = active;
32
+ }
33
+
34
+ /**
35
+ * Query whether Kitty keyboard protocol is currently active.
36
+ */
37
+ export function isKittyProtocolActive(): boolean {
38
+ return kittyProtocolActive;
39
+ }
40
+
18
41
  // =============================================================================
19
42
  // Type-Safe Key Identifiers
20
43
  // =============================================================================
@@ -271,33 +294,85 @@ const FUNCTIONAL_CODEPOINTS = {
271
294
  // Kitty Protocol Parsing
272
295
  // =============================================================================
273
296
 
297
+ /**
298
+ * Event types from Kitty keyboard protocol (flag 2)
299
+ * 1 = key press, 2 = key repeat, 3 = key release
300
+ */
301
+ export type KeyEventType = "press" | "repeat" | "release";
302
+
274
303
  interface ParsedKittySequence {
275
304
  codepoint: number;
276
305
  modifier: number;
306
+ eventType: KeyEventType;
307
+ }
308
+
309
+ /**
310
+ * Check if the last parsed key event was a key release.
311
+ * Only meaningful when Kitty keyboard protocol with flag 2 is active.
312
+ */
313
+ export function isKeyRelease(data: string): boolean {
314
+ return (
315
+ data.includes(":3u") ||
316
+ data.includes(":3~") ||
317
+ data.includes(":3A") ||
318
+ data.includes(":3B") ||
319
+ data.includes(":3C") ||
320
+ data.includes(":3D") ||
321
+ data.includes(":3H") ||
322
+ data.includes(":3F")
323
+ );
324
+ }
325
+
326
+ /**
327
+ * Check if the last parsed key event was a key repeat.
328
+ * Only meaningful when Kitty keyboard protocol with flag 2 is active.
329
+ */
330
+ export function isKeyRepeat(data: string): boolean {
331
+ return (
332
+ data.includes(":2u") ||
333
+ data.includes(":2~") ||
334
+ data.includes(":2A") ||
335
+ data.includes(":2B") ||
336
+ data.includes(":2C") ||
337
+ data.includes(":2D") ||
338
+ data.includes(":2H") ||
339
+ data.includes(":2F")
340
+ );
341
+ }
342
+
343
+ function parseEventType(eventTypeStr: string | undefined): KeyEventType {
344
+ if (!eventTypeStr) return "press";
345
+ const eventType = parseInt(eventTypeStr, 10);
346
+ if (eventType === 2) return "repeat";
347
+ if (eventType === 3) return "release";
348
+ return "press";
277
349
  }
278
350
 
279
351
  function parseKittySequence(data: string): ParsedKittySequence | null {
280
- // CSI u format: \x1b[<num>u or \x1b[<num>;<mod>u
281
- const csiUMatch = data.match(/^\x1b\[(\d+)(?:;(\d+))?u$/);
352
+ // CSI u format: \x1b[<num>u or \x1b[<num>;<mod>u or \x1b[<num>;<mod>:<event>u
353
+ const csiUMatch = data.match(/^\x1b\[(\d+)(?:;(\d+))?(?::(\d+))?u$/);
282
354
  if (csiUMatch) {
283
355
  const codepoint = parseInt(csiUMatch[1]!, 10);
284
356
  const modValue = csiUMatch[2] ? parseInt(csiUMatch[2], 10) : 1;
285
- return { codepoint, modifier: modValue - 1 };
357
+ const eventType = parseEventType(csiUMatch[3]);
358
+ return { codepoint, modifier: modValue - 1, eventType };
286
359
  }
287
360
 
288
- // Arrow keys with modifier: \x1b[1;<mod>A/B/C/D
289
- const arrowMatch = data.match(/^\x1b\[1;(\d+)([ABCD])$/);
361
+ // Arrow keys with modifier: \x1b[1;<mod>A/B/C/D or \x1b[1;<mod>:<event>A/B/C/D
362
+ const arrowMatch = data.match(/^\x1b\[1;(\d+)(?::(\d+))?([ABCD])$/);
290
363
  if (arrowMatch) {
291
364
  const modValue = parseInt(arrowMatch[1]!, 10);
365
+ const eventType = parseEventType(arrowMatch[2]);
292
366
  const arrowCodes: Record<string, number> = { A: -1, B: -2, C: -3, D: -4 };
293
- return { codepoint: arrowCodes[arrowMatch[2]!]!, modifier: modValue - 1 };
367
+ return { codepoint: arrowCodes[arrowMatch[3]!]!, modifier: modValue - 1, eventType };
294
368
  }
295
369
 
296
- // Functional keys: \x1b[<num>~ or \x1b[<num>;<mod>~
297
- const funcMatch = data.match(/^\x1b\[(\d+)(?:;(\d+))?~$/);
370
+ // Functional keys: \x1b[<num>~ or \x1b[<num>;<mod>~ or \x1b[<num>;<mod>:<event>~
371
+ const funcMatch = data.match(/^\x1b\[(\d+)(?:;(\d+))?(?::(\d+))?~$/);
298
372
  if (funcMatch) {
299
373
  const keyNum = parseInt(funcMatch[1]!, 10);
300
374
  const modValue = funcMatch[2] ? parseInt(funcMatch[2], 10) : 1;
375
+ const eventType = parseEventType(funcMatch[3]);
301
376
  const funcCodes: Record<number, number> = {
302
377
  2: FUNCTIONAL_CODEPOINTS.insert,
303
378
  3: FUNCTIONAL_CODEPOINTS.delete,
@@ -308,16 +383,17 @@ function parseKittySequence(data: string): ParsedKittySequence | null {
308
383
  };
309
384
  const codepoint = funcCodes[keyNum];
310
385
  if (codepoint !== undefined) {
311
- return { codepoint, modifier: modValue - 1 };
386
+ return { codepoint, modifier: modValue - 1, eventType };
312
387
  }
313
388
  }
314
389
 
315
- // Home/End with modifier: \x1b[1;<mod>H/F
316
- const homeEndMatch = data.match(/^\x1b\[1;(\d+)([HF])$/);
390
+ // Home/End with modifier: \x1b[1;<mod>H/F or \x1b[1;<mod>:<event>H/F
391
+ const homeEndMatch = data.match(/^\x1b\[1;(\d+)(?::(\d+))?([HF])$/);
317
392
  if (homeEndMatch) {
318
393
  const modValue = parseInt(homeEndMatch[1]!, 10);
319
- const codepoint = homeEndMatch[2] === "H" ? FUNCTIONAL_CODEPOINTS.home : FUNCTIONAL_CODEPOINTS.end;
320
- return { codepoint, modifier: modValue - 1 };
394
+ const eventType = parseEventType(homeEndMatch[2]);
395
+ const codepoint = homeEndMatch[3] === "H" ? FUNCTIONAL_CODEPOINTS.home : FUNCTIONAL_CODEPOINTS.end;
396
+ return { codepoint, modifier: modValue - 1, eventType };
321
397
  }
322
398
 
323
399
  return null;
@@ -402,17 +478,28 @@ export function matchesKey(data: string, keyId: KeyId): boolean {
402
478
  case "enter":
403
479
  case "return":
404
480
  if (shift && !ctrl && !alt) {
405
- return (
406
- data === "\x1b\r" || // Legacy: some terminals send ESC+CR for shift+enter
481
+ if (
407
482
  matchesKittySequence(data, CODEPOINTS.enter, MODIFIERS.shift) ||
408
483
  matchesKittySequence(data, CODEPOINTS.kpEnter, MODIFIERS.shift)
409
- );
484
+ ) {
485
+ return true;
486
+ }
487
+ if (kittyProtocolActive) {
488
+ return data === "\x1b\r" || data === "\n";
489
+ }
490
+ return false;
410
491
  }
411
492
  if (alt && !ctrl && !shift) {
412
- return (
493
+ if (
413
494
  matchesKittySequence(data, CODEPOINTS.enter, MODIFIERS.alt) ||
414
495
  matchesKittySequence(data, CODEPOINTS.kpEnter, MODIFIERS.alt)
415
- );
496
+ ) {
497
+ return true;
498
+ }
499
+ if (!kittyProtocolActive) {
500
+ return data === "\x1b\r";
501
+ }
502
+ return false;
416
503
  }
417
504
  if (modifier === 0) {
418
505
  return (
@@ -534,7 +621,7 @@ export function matchesKey(data: string, keyId: KeyId): boolean {
534
621
  return matchesKittySequence(data, codepoint, modifier);
535
622
  }
536
623
 
537
- return data === key;
624
+ return data === key || matchesKittySequence(data, codepoint, 0);
538
625
  }
539
626
 
540
627
  return false;
@@ -577,14 +664,22 @@ export function parseKey(data: string): string | undefined {
577
664
  }
578
665
  }
579
666
 
580
- // Legacy sequences
667
+ // Mode-aware legacy sequences
668
+ // When Kitty protocol is active, ambiguous sequences are interpreted as custom terminal mappings:
669
+ // - \x1b\r = shift+enter (Kitty mapping), not alt+enter
670
+ // - \n = shift+enter (Ghostty mapping)
671
+ if (kittyProtocolActive) {
672
+ if (data === "\x1b\r" || data === "\n") return "shift+enter";
673
+ }
674
+
675
+ // Legacy sequences (used when Kitty protocol is not active, or for unambiguous sequences)
581
676
  if (data === "\x1b") return "escape";
582
677
  if (data === "\t") return "tab";
583
678
  if (data === "\r" || data === "\x1bOM") return "enter";
584
679
  if (data === " ") return "space";
585
680
  if (data === "\x7f" || data === "\x08") return "backspace";
586
681
  if (data === "\x1b[Z") return "shift+tab";
587
- if (data === "\x1b\r") return "shift+enter"; // Legacy: ESC+CR for shift+enter
682
+ if (!kittyProtocolActive && data === "\x1b\r") return "alt+enter";
588
683
  if (data === "\x1b\x7f") return "alt+backspace";
589
684
  if (data === "\x1b[A") return "up";
590
685
  if (data === "\x1b[B") return "down";
@@ -0,0 +1,386 @@
1
+ /**
2
+ * StdinBuffer buffers input and emits complete sequences.
3
+ *
4
+ * This is necessary because stdin data events can arrive in partial chunks,
5
+ * especially for escape sequences like mouse events. Without buffering,
6
+ * partial sequences can be misinterpreted as regular keypresses.
7
+ *
8
+ * For example, the mouse SGR sequence `\x1b[<35;20;5m` might arrive as:
9
+ * - Event 1: `\x1b`
10
+ * - Event 2: `[<35`
11
+ * - Event 3: `;20;5m`
12
+ *
13
+ * The buffer accumulates these until a complete sequence is detected.
14
+ * Call the `process()` method to feed input data.
15
+ *
16
+ * Based on code from OpenTUI (https://github.com/anomalyco/opentui)
17
+ * MIT License - Copyright (c) 2025 opentui
18
+ */
19
+
20
+ import { EventEmitter } from "node:events";
21
+
22
+ const ESC = "\x1b";
23
+ const BRACKETED_PASTE_START = "\x1b[200~";
24
+ const BRACKETED_PASTE_END = "\x1b[201~";
25
+
26
+ /**
27
+ * Check if a string is a complete escape sequence or needs more data
28
+ */
29
+ function isCompleteSequence(data: string): "complete" | "incomplete" | "not-escape" {
30
+ if (!data.startsWith(ESC)) {
31
+ return "not-escape";
32
+ }
33
+
34
+ if (data.length === 1) {
35
+ return "incomplete";
36
+ }
37
+
38
+ const afterEsc = data.slice(1);
39
+
40
+ // CSI sequences: ESC [
41
+ if (afterEsc.startsWith("[")) {
42
+ // Check for old-style mouse sequence: ESC[M + 3 bytes
43
+ if (afterEsc.startsWith("[M")) {
44
+ // Old-style mouse needs ESC[M + 3 bytes = 6 total
45
+ return data.length >= 6 ? "complete" : "incomplete";
46
+ }
47
+ return isCompleteCsiSequence(data);
48
+ }
49
+
50
+ // OSC sequences: ESC ]
51
+ if (afterEsc.startsWith("]")) {
52
+ return isCompleteOscSequence(data);
53
+ }
54
+
55
+ // DCS sequences: ESC P ... ESC \ (includes XTVersion responses)
56
+ if (afterEsc.startsWith("P")) {
57
+ return isCompleteDcsSequence(data);
58
+ }
59
+
60
+ // APC sequences: ESC _ ... ESC \ (includes Kitty graphics responses)
61
+ if (afterEsc.startsWith("_")) {
62
+ return isCompleteApcSequence(data);
63
+ }
64
+
65
+ // SS3 sequences: ESC O
66
+ if (afterEsc.startsWith("O")) {
67
+ // ESC O followed by a single character
68
+ return afterEsc.length >= 2 ? "complete" : "incomplete";
69
+ }
70
+
71
+ // Meta key sequences: ESC followed by a single character
72
+ if (afterEsc.length === 1) {
73
+ return "complete";
74
+ }
75
+
76
+ // Unknown escape sequence - treat as complete
77
+ return "complete";
78
+ }
79
+
80
+ /**
81
+ * Check if CSI sequence is complete
82
+ * CSI sequences: ESC [ ... followed by a final byte (0x40-0x7E)
83
+ */
84
+ function isCompleteCsiSequence(data: string): "complete" | "incomplete" {
85
+ if (!data.startsWith(`${ESC}[`)) {
86
+ return "complete";
87
+ }
88
+
89
+ // Need at least ESC [ and one more character
90
+ if (data.length < 3) {
91
+ return "incomplete";
92
+ }
93
+
94
+ const payload = data.slice(2);
95
+
96
+ // CSI sequences end with a byte in the range 0x40-0x7E (@-~)
97
+ // This includes all letters and several special characters
98
+ const lastChar = payload[payload.length - 1];
99
+ const lastCharCode = lastChar.charCodeAt(0);
100
+
101
+ if (lastCharCode >= 0x40 && lastCharCode <= 0x7e) {
102
+ // Special handling for SGR mouse sequences
103
+ // Format: ESC[<B;X;Ym or ESC[<B;X;YM
104
+ if (payload.startsWith("<")) {
105
+ // Must have format: <digits;digits;digits[Mm]
106
+ const mouseMatch = /^<\d+;\d+;\d+[Mm]$/.test(payload);
107
+ if (mouseMatch) {
108
+ return "complete";
109
+ }
110
+ // If it ends with M or m but doesn't match the pattern, still incomplete
111
+ if (lastChar === "M" || lastChar === "m") {
112
+ // Check if we have the right structure
113
+ const parts = payload.slice(1, -1).split(";");
114
+ if (parts.length === 3 && parts.every((p) => /^\d+$/.test(p))) {
115
+ return "complete";
116
+ }
117
+ }
118
+
119
+ return "incomplete";
120
+ }
121
+
122
+ return "complete";
123
+ }
124
+
125
+ return "incomplete";
126
+ }
127
+
128
+ /**
129
+ * Check if OSC sequence is complete
130
+ * OSC sequences: ESC ] ... ST (where ST is ESC \ or BEL)
131
+ */
132
+ function isCompleteOscSequence(data: string): "complete" | "incomplete" {
133
+ if (!data.startsWith(`${ESC}]`)) {
134
+ return "complete";
135
+ }
136
+
137
+ // OSC sequences end with ST (ESC \) or BEL (\x07)
138
+ if (data.endsWith(`${ESC}\\`) || data.endsWith("\x07")) {
139
+ return "complete";
140
+ }
141
+
142
+ return "incomplete";
143
+ }
144
+
145
+ /**
146
+ * Check if DCS (Device Control String) sequence is complete
147
+ * DCS sequences: ESC P ... ST (where ST is ESC \)
148
+ * Used for XTVersion responses like ESC P >| ... ESC \
149
+ */
150
+ function isCompleteDcsSequence(data: string): "complete" | "incomplete" {
151
+ if (!data.startsWith(`${ESC}P`)) {
152
+ return "complete";
153
+ }
154
+
155
+ // DCS sequences end with ST (ESC \)
156
+ if (data.endsWith(`${ESC}\\`)) {
157
+ return "complete";
158
+ }
159
+
160
+ return "incomplete";
161
+ }
162
+
163
+ /**
164
+ * Check if APC (Application Program Command) sequence is complete
165
+ * APC sequences: ESC _ ... ST (where ST is ESC \)
166
+ * Used for Kitty graphics responses like ESC _ G ... ESC \
167
+ */
168
+ function isCompleteApcSequence(data: string): "complete" | "incomplete" {
169
+ if (!data.startsWith(`${ESC}_`)) {
170
+ return "complete";
171
+ }
172
+
173
+ // APC sequences end with ST (ESC \)
174
+ if (data.endsWith(`${ESC}\\`)) {
175
+ return "complete";
176
+ }
177
+
178
+ return "incomplete";
179
+ }
180
+
181
+ /**
182
+ * Split accumulated buffer into complete sequences
183
+ */
184
+ function extractCompleteSequences(buffer: string): { sequences: string[]; remainder: string } {
185
+ const sequences: string[] = [];
186
+ let pos = 0;
187
+
188
+ while (pos < buffer.length) {
189
+ const remaining = buffer.slice(pos);
190
+
191
+ // Try to extract a sequence starting at this position
192
+ if (remaining.startsWith(ESC)) {
193
+ // Find the end of this escape sequence
194
+ let seqEnd = 1;
195
+ while (seqEnd <= remaining.length) {
196
+ const candidate = remaining.slice(0, seqEnd);
197
+ const status = isCompleteSequence(candidate);
198
+
199
+ if (status === "complete") {
200
+ sequences.push(candidate);
201
+ pos += seqEnd;
202
+ break;
203
+ } else if (status === "incomplete") {
204
+ seqEnd++;
205
+ } else {
206
+ // Should not happen when starting with ESC
207
+ sequences.push(candidate);
208
+ pos += seqEnd;
209
+ break;
210
+ }
211
+ }
212
+
213
+ if (seqEnd > remaining.length) {
214
+ return { sequences, remainder: remaining };
215
+ }
216
+ } else {
217
+ // Not an escape sequence - take a single character
218
+ sequences.push(remaining[0]!);
219
+ pos++;
220
+ }
221
+ }
222
+
223
+ return { sequences, remainder: "" };
224
+ }
225
+
226
+ export type StdinBufferOptions = {
227
+ /**
228
+ * Maximum time to wait for sequence completion (default: 10ms)
229
+ * After this time, the buffer is flushed even if incomplete
230
+ */
231
+ timeout?: number;
232
+ };
233
+
234
+ export type StdinBufferEventMap = {
235
+ data: [string];
236
+ paste: [string];
237
+ };
238
+
239
+ /**
240
+ * Buffers stdin input and emits complete sequences via the 'data' event.
241
+ * Handles partial escape sequences that arrive across multiple chunks.
242
+ */
243
+ export class StdinBuffer extends EventEmitter<StdinBufferEventMap> {
244
+ private buffer: string = "";
245
+ private timeout: ReturnType<typeof setTimeout> | null = null;
246
+ private readonly timeoutMs: number;
247
+ private pasteMode: boolean = false;
248
+ private pasteBuffer: string = "";
249
+
250
+ constructor(options: StdinBufferOptions = {}) {
251
+ super();
252
+ this.timeoutMs = options.timeout ?? 10;
253
+ }
254
+
255
+ public process(data: string | Buffer): void {
256
+ // Clear any pending timeout
257
+ if (this.timeout) {
258
+ clearTimeout(this.timeout);
259
+ this.timeout = null;
260
+ }
261
+
262
+ // Handle high-byte conversion (for compatibility with parseKeypress)
263
+ // If buffer has single byte > 127, convert to ESC + (byte - 128)
264
+ let str: string;
265
+ if (Buffer.isBuffer(data)) {
266
+ if (data.length === 1 && data[0]! > 127) {
267
+ const byte = data[0]! - 128;
268
+ str = `\x1b${String.fromCharCode(byte)}`;
269
+ } else {
270
+ str = data.toString();
271
+ }
272
+ } else {
273
+ str = data;
274
+ }
275
+
276
+ if (str.length === 0 && this.buffer.length === 0) {
277
+ this.emit("data", "");
278
+ return;
279
+ }
280
+
281
+ this.buffer += str;
282
+
283
+ if (this.pasteMode) {
284
+ this.pasteBuffer += this.buffer;
285
+ this.buffer = "";
286
+
287
+ const endIndex = this.pasteBuffer.indexOf(BRACKETED_PASTE_END);
288
+ if (endIndex !== -1) {
289
+ const pastedContent = this.pasteBuffer.slice(0, endIndex);
290
+ const remaining = this.pasteBuffer.slice(endIndex + BRACKETED_PASTE_END.length);
291
+
292
+ this.pasteMode = false;
293
+ this.pasteBuffer = "";
294
+
295
+ this.emit("paste", pastedContent);
296
+
297
+ if (remaining.length > 0) {
298
+ this.process(remaining);
299
+ }
300
+ }
301
+ return;
302
+ }
303
+
304
+ const startIndex = this.buffer.indexOf(BRACKETED_PASTE_START);
305
+ if (startIndex !== -1) {
306
+ if (startIndex > 0) {
307
+ const beforePaste = this.buffer.slice(0, startIndex);
308
+ const result = extractCompleteSequences(beforePaste);
309
+ for (const sequence of result.sequences) {
310
+ this.emit("data", sequence);
311
+ }
312
+ }
313
+
314
+ this.buffer = this.buffer.slice(startIndex + BRACKETED_PASTE_START.length);
315
+ this.pasteMode = true;
316
+ this.pasteBuffer = this.buffer;
317
+ this.buffer = "";
318
+
319
+ const endIndex = this.pasteBuffer.indexOf(BRACKETED_PASTE_END);
320
+ if (endIndex !== -1) {
321
+ const pastedContent = this.pasteBuffer.slice(0, endIndex);
322
+ const remaining = this.pasteBuffer.slice(endIndex + BRACKETED_PASTE_END.length);
323
+
324
+ this.pasteMode = false;
325
+ this.pasteBuffer = "";
326
+
327
+ this.emit("paste", pastedContent);
328
+
329
+ if (remaining.length > 0) {
330
+ this.process(remaining);
331
+ }
332
+ }
333
+ return;
334
+ }
335
+
336
+ const result = extractCompleteSequences(this.buffer);
337
+ this.buffer = result.remainder;
338
+
339
+ for (const sequence of result.sequences) {
340
+ this.emit("data", sequence);
341
+ }
342
+
343
+ if (this.buffer.length > 0) {
344
+ this.timeout = setTimeout(() => {
345
+ const flushed = this.flush();
346
+
347
+ for (const sequence of flushed) {
348
+ this.emit("data", sequence);
349
+ }
350
+ }, this.timeoutMs);
351
+ }
352
+ }
353
+
354
+ flush(): string[] {
355
+ if (this.timeout) {
356
+ clearTimeout(this.timeout);
357
+ this.timeout = null;
358
+ }
359
+
360
+ if (this.buffer.length === 0) {
361
+ return [];
362
+ }
363
+
364
+ const sequences = [this.buffer];
365
+ this.buffer = "";
366
+ return sequences;
367
+ }
368
+
369
+ clear(): void {
370
+ if (this.timeout) {
371
+ clearTimeout(this.timeout);
372
+ this.timeout = null;
373
+ }
374
+ this.buffer = "";
375
+ this.pasteMode = false;
376
+ this.pasteBuffer = "";
377
+ }
378
+
379
+ getBuffer(): string {
380
+ return this.buffer;
381
+ }
382
+
383
+ destroy(): void {
384
+ this.clear();
385
+ }
386
+ }
package/src/terminal.ts CHANGED
@@ -1,3 +1,6 @@
1
+ import { setKittyProtocolActive } from "./keys";
2
+ import { StdinBuffer } from "./stdin-buffer";
3
+
1
4
  /**
2
5
  * Minimal terminal interface for TUI
3
6
  */
@@ -40,6 +43,9 @@ export interface Terminal {
40
43
  get columns(): number;
41
44
  get rows(): number;
42
45
 
46
+ // Whether Kitty keyboard protocol is active
47
+ get kittyProtocolActive(): boolean;
48
+
43
49
  // Cursor positioning (relative to current position)
44
50
  moveBy(lines: number): void; // Move cursor up (negative) or down (positive) by N lines
45
51
 
@@ -63,6 +69,13 @@ export class ProcessTerminal implements Terminal {
63
69
  private wasRaw = false;
64
70
  private inputHandler?: (data: string) => void;
65
71
  private resizeHandler?: () => void;
72
+ private _kittyProtocolActive = false;
73
+ private stdinBuffer?: StdinBuffer;
74
+ private stdinDataHandler?: (data: string) => void;
75
+
76
+ get kittyProtocolActive(): boolean {
77
+ return this._kittyProtocolActive;
78
+ }
66
79
 
67
80
  start(onInput: (data: string) => void, onResize: () => void): void {
68
81
  this.inputHandler = onInput;
@@ -82,15 +95,125 @@ export class ProcessTerminal implements Terminal {
82
95
  // Enable bracketed paste mode - terminal will wrap pastes in \x1b[200~ ... \x1b[201~
83
96
  process.stdout.write("\x1b[?2004h");
84
97
 
85
- // Enable Kitty keyboard protocol (disambiguate escape codes)
86
- // This makes terminals like Ghostty, Kitty, WezTerm send enhanced key sequences
87
- // e.g., Shift+Enter becomes \x1b[13;2u instead of just \r
98
+ // Set up resize handler immediately
99
+ process.stdout.on("resize", this.resizeHandler);
100
+
101
+ // Query and enable Kitty keyboard protocol
102
+ // The query handler intercepts input temporarily, then installs the user's handler
88
103
  // See: https://sw.kovidgoyal.net/kitty/keyboard-protocol/
89
- process.stdout.write("\x1b[>1u");
104
+ this.queryAndEnableKittyProtocol();
105
+ }
90
106
 
91
- // Set up event handlers
92
- process.stdin.on("data", this.inputHandler);
93
- process.stdout.on("resize", this.resizeHandler);
107
+ /**
108
+ * Set up StdinBuffer to split batched input into individual sequences.
109
+ * This ensures components receive single events, making matchesKey/isKeyRelease work correctly.
110
+ * Note: Does NOT register the stdin handler - that's done after the Kitty protocol query.
111
+ */
112
+ private setupStdinBuffer(): void {
113
+ this.stdinBuffer = new StdinBuffer({ timeout: 10 });
114
+
115
+ // Forward individual sequences to the input handler
116
+ this.stdinBuffer.on("data", (sequence: string) => {
117
+ if (this.inputHandler) {
118
+ this.inputHandler(sequence);
119
+ }
120
+ });
121
+
122
+ // Re-wrap paste content with bracketed paste markers for existing editor handling
123
+ this.stdinBuffer.on("paste", (content: string) => {
124
+ if (this.inputHandler) {
125
+ this.inputHandler(`\x1b[200~${content}\x1b[201~`);
126
+ }
127
+ });
128
+
129
+ // Handler that pipes stdin data through the buffer
130
+ // Registration happens after Kitty protocol query completes
131
+ this.stdinDataHandler = (data: string) => {
132
+ this.stdinBuffer!.process(data);
133
+ };
134
+ }
135
+
136
+ /**
137
+ * Query terminal for Kitty keyboard protocol support and enable if available.
138
+ *
139
+ * Sends CSI ? u to query current flags. If terminal responds with CSI ? <flags> u,
140
+ * it supports the protocol and we enable it with CSI > 1 u.
141
+ *
142
+ * Non-supporting terminals won't respond, so we use a timeout.
143
+ */
144
+ private queryAndEnableKittyProtocol(): void {
145
+ const QUERY_TIMEOUT_MS = 100;
146
+ let resolved = false;
147
+ let buffer = "";
148
+
149
+ // Kitty protocol response pattern: \x1b[?<flags>u
150
+ const kittyResponsePattern = /\x1b\[\?(\d+)u/;
151
+
152
+ const queryHandler = (data: string) => {
153
+ if (resolved) {
154
+ // Query phase done, forward to StdinBuffer
155
+ if (this.stdinBuffer) {
156
+ this.stdinBuffer.process(data);
157
+ }
158
+ return;
159
+ }
160
+
161
+ buffer += data;
162
+
163
+ // Check if we have a Kitty protocol response
164
+ const match = buffer.match(kittyResponsePattern);
165
+ if (match) {
166
+ resolved = true;
167
+ this._kittyProtocolActive = true;
168
+ setKittyProtocolActive(true);
169
+
170
+ // Enable Kitty keyboard protocol (push flags)
171
+ // Flag 1 = disambiguate escape codes
172
+ // Flag 2 = report event types (press/repeat/release)
173
+ process.stdout.write("\x1b[>3u");
174
+
175
+ // Remove the response from buffer, forward any remaining input through StdinBuffer
176
+ const remaining = buffer.replace(kittyResponsePattern, "");
177
+ if (remaining && this.stdinBuffer) {
178
+ this.stdinBuffer.process(remaining);
179
+ }
180
+
181
+ // Replace query handler with StdinBuffer handler
182
+ process.stdin.removeListener("data", queryHandler);
183
+ if (this.stdinDataHandler) {
184
+ process.stdin.on("data", this.stdinDataHandler);
185
+ }
186
+ }
187
+ };
188
+
189
+ // Set up StdinBuffer before query (it will receive input after query completes)
190
+ this.setupStdinBuffer();
191
+
192
+ // Temporarily intercept input for the query (before StdinBuffer)
193
+ process.stdin.on("data", queryHandler);
194
+
195
+ // Send query
196
+ process.stdout.write("\x1b[?u");
197
+
198
+ // Timeout: if no response, terminal doesn't support Kitty protocol
199
+ setTimeout(() => {
200
+ if (!resolved) {
201
+ resolved = true;
202
+ this._kittyProtocolActive = false;
203
+ setKittyProtocolActive(false);
204
+
205
+ // Forward any buffered input that wasn't a Kitty response through StdinBuffer
206
+ if (buffer && this.stdinBuffer) {
207
+ this.stdinBuffer.process(buffer);
208
+ }
209
+
210
+ // Replace query handler with StdinBuffer handler
211
+ process.stdin.removeListener("data", queryHandler);
212
+ if (this.stdinDataHandler) {
213
+ process.stdin.on("data", this.stdinDataHandler);
214
+ }
215
+ }
216
+ }, QUERY_TIMEOUT_MS);
94
217
  }
95
218
 
96
219
  stop(): void {
@@ -102,14 +225,25 @@ export class ProcessTerminal implements Terminal {
102
225
  // Disable bracketed paste mode
103
226
  process.stdout.write("\x1b[?2004l");
104
227
 
105
- // Disable Kitty keyboard protocol (pop the flags we pushed)
106
- process.stdout.write("\x1b[<u");
228
+ // Disable Kitty keyboard protocol (pop the flags we pushed) - only if we enabled it
229
+ if (this._kittyProtocolActive) {
230
+ process.stdout.write("\x1b[<u");
231
+ this._kittyProtocolActive = false;
232
+ setKittyProtocolActive(false);
233
+ }
234
+
235
+ // Clean up StdinBuffer
236
+ if (this.stdinBuffer) {
237
+ this.stdinBuffer.destroy();
238
+ this.stdinBuffer = undefined;
239
+ }
107
240
 
108
241
  // Remove event handlers
109
- if (this.inputHandler) {
110
- process.stdin.removeListener("data", this.inputHandler);
111
- this.inputHandler = undefined;
242
+ if (this.stdinDataHandler) {
243
+ process.stdin.removeListener("data", this.stdinDataHandler);
244
+ this.stdinDataHandler = undefined;
112
245
  }
246
+ this.inputHandler = undefined;
113
247
  if (this.resizeHandler) {
114
248
  process.stdout.removeListener("resize", this.resizeHandler);
115
249
  this.resizeHandler = undefined;
package/src/tui.ts CHANGED
@@ -5,10 +5,10 @@
5
5
  import * as fs from "node:fs";
6
6
  import * as os from "node:os";
7
7
  import * as path from "node:path";
8
- import { isShiftCtrlD } from "./keys";
8
+ import { isKeyRelease, isShiftCtrlD } from "./keys";
9
9
  import type { Terminal } from "./terminal";
10
10
  import { getCapabilities, setCellDimensions } from "./terminal-image";
11
- import { visibleWidth } from "./utils";
11
+ import { extractSegments, sliceByColumn, sliceWithWidth, visibleWidth } from "./utils";
12
12
 
13
13
  /**
14
14
  * Component interface - all components must implement this
@@ -26,6 +26,12 @@ export interface Component {
26
26
  */
27
27
  handleInput?(data: string): void;
28
28
 
29
+ /**
30
+ * If true, component receives key release events (Kitty protocol).
31
+ * Default is false - release events are filtered out.
32
+ */
33
+ wantsKeyRelease?: boolean;
34
+
29
35
  /**
30
36
  * Optional cursor position within the rendered output (0-based row/col).
31
37
  */
@@ -105,6 +111,11 @@ export class TUI extends Container {
105
111
  private previousCursor: { row: number; col: number } | null = null;
106
112
  private inputBuffer = ""; // Buffer for parsing terminal responses
107
113
  private cellSizeQueryPending = false;
114
+ private overlayStack: {
115
+ component: Component;
116
+ options?: { row?: number; col?: number; width?: number };
117
+ preFocus: Component | null;
118
+ }[] = [];
108
119
  private inputQueue: string[] = []; // Queue input during cell size query to avoid interleaving
109
120
 
110
121
  constructor(terminal: Terminal) {
@@ -116,6 +127,32 @@ export class TUI extends Container {
116
127
  this.focusedComponent = component;
117
128
  }
118
129
 
130
+ /** Show an overlay component centered (or at specified position). */
131
+ showOverlay(component: Component, options?: { row?: number; col?: number; width?: number }): void {
132
+ this.overlayStack.push({ component, options, preFocus: this.focusedComponent });
133
+ this.setFocus(component);
134
+ this.terminal.hideCursor();
135
+ this.requestRender();
136
+ }
137
+
138
+ /** Hide the topmost overlay and restore previous focus. */
139
+ hideOverlay(): void {
140
+ const overlay = this.overlayStack.pop();
141
+ if (!overlay) return;
142
+ this.setFocus(overlay.preFocus);
143
+ if (this.overlayStack.length === 0) this.terminal.hideCursor();
144
+ this.requestRender();
145
+ }
146
+
147
+ hasOverlay(): boolean {
148
+ return this.overlayStack.length > 0;
149
+ }
150
+
151
+ override invalidate(): void {
152
+ super.invalidate();
153
+ for (const overlay of this.overlayStack) overlay.component.invalidate?.();
154
+ }
155
+
119
156
  start(): void {
120
157
  this.terminal.start(
121
158
  (data) => this.handleInput(data),
@@ -230,6 +267,9 @@ export class TUI extends Container {
230
267
  // Pass input to focused component (including Ctrl+C)
231
268
  // The focused component can decide how to handle Ctrl+C
232
269
  if (this.focusedComponent?.handleInput) {
270
+ if (isKeyRelease(data) && !this.focusedComponent.wantsKeyRelease) {
271
+ return;
272
+ }
233
273
  this.focusedComponent.handleInput(data);
234
274
  this.requestRender();
235
275
  }
@@ -282,6 +322,77 @@ export class TUI extends Container {
282
322
  return line.includes("\x1b_G") || line.includes("\x1b]1337;File=");
283
323
  }
284
324
 
325
+ /** Composite all overlays into content lines (in stack order, later = on top). */
326
+ private compositeOverlays(lines: string[], termWidth: number, termHeight: number): string[] {
327
+ if (this.overlayStack.length === 0) return lines;
328
+ const result = [...lines];
329
+ const viewportStart = Math.max(0, result.length - termHeight);
330
+
331
+ for (const { component, options } of this.overlayStack) {
332
+ const w =
333
+ options?.width !== undefined
334
+ ? Math.max(1, Math.min(options.width, termWidth - 4))
335
+ : Math.max(1, Math.min(80, termWidth - 4));
336
+ const overlayLines = component.render(w);
337
+ const h = overlayLines.length;
338
+
339
+ const row = Math.max(0, Math.min(options?.row ?? Math.floor((termHeight - h) / 2), termHeight - h));
340
+ const col = Math.max(0, Math.min(options?.col ?? Math.floor((termWidth - w) / 2), termWidth - w));
341
+
342
+ for (let i = 0; i < h; i++) {
343
+ const idx = viewportStart + row + i;
344
+ if (idx >= 0 && idx < result.length) {
345
+ result[idx] = this.compositeLineAt(result[idx], overlayLines[i], col, w, termWidth);
346
+ }
347
+ }
348
+ }
349
+ return result;
350
+ }
351
+
352
+ private static readonly SEGMENT_RESET = "\x1b[0m\x1b]8;;\x07";
353
+
354
+ /** Splice overlay content into a base line at a specific column. Single-pass optimized. */
355
+ private compositeLineAt(
356
+ baseLine: string,
357
+ overlayLine: string,
358
+ startCol: number,
359
+ overlayWidth: number,
360
+ totalWidth: number,
361
+ ): string {
362
+ if (this.containsImage(baseLine)) return baseLine;
363
+
364
+ // Single pass through baseLine extracts both before and after segments
365
+ const afterStart = startCol + overlayWidth;
366
+ const base = extractSegments(baseLine, startCol, afterStart, totalWidth - afterStart, true);
367
+
368
+ // Extract overlay with width tracking
369
+ const overlay = sliceWithWidth(overlayLine, 0, overlayWidth);
370
+
371
+ // Pad segments to target widths
372
+ const beforePad = Math.max(0, startCol - base.beforeWidth);
373
+ const overlayPad = Math.max(0, overlayWidth - overlay.width);
374
+ const actualBeforeWidth = Math.max(startCol, base.beforeWidth);
375
+ const actualOverlayWidth = Math.max(overlayWidth, overlay.width);
376
+ const afterTarget = Math.max(0, totalWidth - actualBeforeWidth - actualOverlayWidth);
377
+ const afterPad = Math.max(0, afterTarget - base.afterWidth);
378
+
379
+ // Compose result - widths are tracked so no final visibleWidth check needed
380
+ const r = TUI.SEGMENT_RESET;
381
+ const result =
382
+ base.before +
383
+ " ".repeat(beforePad) +
384
+ r +
385
+ overlay.text +
386
+ " ".repeat(overlayPad) +
387
+ r +
388
+ base.after +
389
+ " ".repeat(afterPad);
390
+
391
+ // Only truncate if wide char at after boundary caused overflow (rare)
392
+ const resultWidth = actualBeforeWidth + actualOverlayWidth + Math.max(afterTarget, base.afterWidth);
393
+ return resultWidth <= totalWidth ? result : sliceByColumn(result, 0, totalWidth, true);
394
+ }
395
+
285
396
  private doRender(): void {
286
397
  // Capture terminal dimensions at start to ensure consistency throughout render
287
398
  const width = this.terminal.columns;
@@ -290,7 +401,13 @@ export class TUI extends Container {
290
401
  const currentCursorRow = this.cursorRow;
291
402
 
292
403
  // Render all components to get new lines
293
- const newLines = this.render(width);
404
+ let newLines = this.render(width);
405
+
406
+ // Composite overlays into the rendered lines (before differential compare)
407
+ if (this.overlayStack.length > 0) {
408
+ newLines = this.compositeOverlays(newLines, width, height);
409
+ }
410
+
294
411
  const cursorInfo = this.getCursorPosition(width);
295
412
 
296
413
  // Width changed - need full re-render
@@ -417,7 +534,19 @@ export class TUI extends Container {
417
534
  } catch {
418
535
  // Ignore - crash log is best-effort
419
536
  }
420
- throw new Error(`Rendered line ${i} exceeds terminal width. Debug log written to ${crashLogPath}`);
537
+
538
+ // Clean up terminal state before throwing
539
+ this.stop();
540
+
541
+ const errorMsg = [
542
+ `Rendered line ${i} exceeds terminal width (${visibleWidth(line)} > ${width}).`,
543
+ "",
544
+ "This is likely caused by a custom TUI component not truncating its output.",
545
+ "Use visibleWidth() to measure and truncateToWidth() to truncate lines.",
546
+ "",
547
+ `Debug log written to: ${crashLogPath}`,
548
+ ].join("\n");
549
+ throw new Error(errorMsg);
421
550
  }
422
551
  buffer += line;
423
552
  }
package/src/utils.ts CHANGED
@@ -135,21 +135,31 @@ export function visibleWidth(str: string): number {
135
135
  /**
136
136
  * Extract ANSI escape sequences from a string at the given position.
137
137
  */
138
- function extractAnsiCode(str: string, pos: number): { code: string; length: number } | null {
139
- if (pos >= str.length || str[pos] !== "\x1b" || str[pos + 1] !== "[") {
140
- return null;
141
- }
138
+ export function extractAnsiCode(str: string, pos: number): { code: string; length: number } | null {
139
+ if (pos >= str.length || str[pos] !== "\x1b") return null;
140
+
141
+ const next = str[pos + 1];
142
142
 
143
- let j = pos + 2;
144
- while (j < str.length && str[j] && !/[mGKHJ]/.test(str[j]!)) {
145
- j++;
143
+ // CSI sequence: ESC [ ... m/G/K/H/J
144
+ if (next === "[") {
145
+ let j = pos + 2;
146
+ while (j < str.length && !/[mGKHJ]/.test(str[j]!)) j++;
147
+ if (j < str.length) return { code: str.substring(pos, j + 1), length: j + 1 - pos };
148
+ return null;
146
149
  }
147
150
 
148
- if (j < str.length) {
149
- return {
150
- code: str.substring(pos, j + 1),
151
- length: j + 1 - pos,
152
- };
151
+ // OSC sequence: ESC ] ... BEL or ESC ] ... ST (ESC \)
152
+ // Used for hyperlinks (OSC 8), window titles, etc.
153
+ if (next === "]") {
154
+ let j = pos + 2;
155
+ while (j < str.length) {
156
+ if (str[j] === "\x07") return { code: str.substring(pos, j + 1), length: j + 1 - pos };
157
+ if (str[j] === "\x1b" && str[j + 1] === "\\") {
158
+ return { code: str.substring(pos, j + 2), length: j + 2 - pos };
159
+ }
160
+ j++;
161
+ }
162
+ return null;
153
163
  }
154
164
 
155
165
  return null;
@@ -308,6 +318,11 @@ class AnsiCodeTracker {
308
318
  this.bgColor = null;
309
319
  }
310
320
 
321
+ /** Clear all state for reuse. */
322
+ clear(): void {
323
+ this.reset();
324
+ }
325
+
311
326
  getActiveCodes(): string {
312
327
  const codes: string[] = [];
313
328
  if (this.bold) codes.push("1");
@@ -510,7 +525,7 @@ function wrapSingleLine(line: string, width: number): string[] {
510
525
  const totalNeeded = currentVisibleLength + tokenVisibleLength;
511
526
 
512
527
  if (totalNeeded > width && currentVisibleLength > 0) {
513
- // Add specific reset for underline only (preserves background)
528
+ // Trim trailing whitespace, then add underline reset (not full reset, to preserve background)
514
529
  let lineToWrap = currentLine.trimEnd();
515
530
  const lineEndReset = tracker.getLineEndReset();
516
531
  if (lineEndReset) {
@@ -539,7 +554,8 @@ function wrapSingleLine(line: string, width: number): string[] {
539
554
  wrapped.push(currentLine);
540
555
  }
541
556
 
542
- return wrapped.length > 0 ? wrapped : [""];
557
+ // Trailing whitespace can cause lines to exceed the requested width
558
+ return wrapped.length > 0 ? wrapped.map((line) => line.trimEnd()) : [""];
543
559
  }
544
560
 
545
561
  const PUNCTUATION_REGEX = /[(){}[\]<>.,;:'"!?+\-=*/\\|&%^$#@~`]/;
@@ -722,3 +738,140 @@ export function truncateToWidth(text: string, maxWidth: number, ellipsis: string
722
738
  // Add reset code before ellipsis to prevent styling leaking into it
723
739
  return `${result}\x1b[0m${ellipsis}`;
724
740
  }
741
+
742
+ /**
743
+ * Extract a range of visible columns from a line. Handles ANSI codes and wide chars.
744
+ * @param strict - If true, exclude wide chars at boundary that would extend past the range
745
+ */
746
+ export function sliceByColumn(line: string, startCol: number, length: number, strict = false): string {
747
+ return sliceWithWidth(line, startCol, length, strict).text;
748
+ }
749
+
750
+ /** Like sliceByColumn but also returns the actual visible width of the result. */
751
+ export function sliceWithWidth(
752
+ line: string,
753
+ startCol: number,
754
+ length: number,
755
+ strict = false,
756
+ ): { text: string; width: number } {
757
+ if (length <= 0) return { text: "", width: 0 };
758
+ const endCol = startCol + length;
759
+ let result = "",
760
+ resultWidth = 0,
761
+ currentCol = 0,
762
+ i = 0,
763
+ pendingAnsi = "";
764
+
765
+ while (i < line.length) {
766
+ const ansi = extractAnsiCode(line, i);
767
+ if (ansi) {
768
+ if (currentCol >= startCol && currentCol < endCol) result += ansi.code;
769
+ else if (currentCol < startCol) pendingAnsi += ansi.code;
770
+ i += ansi.length;
771
+ continue;
772
+ }
773
+
774
+ let textEnd = i;
775
+ while (textEnd < line.length && !extractAnsiCode(line, textEnd)) textEnd++;
776
+
777
+ for (const { segment } of segmenter.segment(line.slice(i, textEnd))) {
778
+ const w = graphemeWidth(segment);
779
+ const inRange = currentCol >= startCol && currentCol < endCol;
780
+ const fits = !strict || currentCol + w <= endCol;
781
+ if (inRange && fits) {
782
+ if (pendingAnsi) {
783
+ result += pendingAnsi;
784
+ pendingAnsi = "";
785
+ }
786
+ result += segment;
787
+ resultWidth += w;
788
+ }
789
+ currentCol += w;
790
+ if (currentCol >= endCol) break;
791
+ }
792
+ i = textEnd;
793
+ if (currentCol >= endCol) break;
794
+ }
795
+ return { text: result, width: resultWidth };
796
+ }
797
+
798
+ // Pooled tracker instance for extractSegments (avoids allocation per call)
799
+ const pooledStyleTracker = new AnsiCodeTracker();
800
+
801
+ /**
802
+ * Extract "before" and "after" segments from a line in a single pass.
803
+ * Used for overlay compositing where we need content before and after the overlay region.
804
+ * Preserves styling from before the overlay that should affect content after it.
805
+ */
806
+ export function extractSegments(
807
+ line: string,
808
+ beforeEnd: number,
809
+ afterStart: number,
810
+ afterLen: number,
811
+ strictAfter = false,
812
+ ): { before: string; beforeWidth: number; after: string; afterWidth: number } {
813
+ let before = "",
814
+ beforeWidth = 0,
815
+ after = "",
816
+ afterWidth = 0;
817
+ let currentCol = 0,
818
+ i = 0;
819
+ let pendingAnsiBefore = "";
820
+ let afterStarted = false;
821
+ const afterEnd = afterStart + afterLen;
822
+
823
+ // Track styling state so "after" inherits styling from before the overlay
824
+ pooledStyleTracker.clear();
825
+
826
+ while (i < line.length) {
827
+ const ansi = extractAnsiCode(line, i);
828
+ if (ansi) {
829
+ // Track all SGR codes to know styling state at afterStart
830
+ pooledStyleTracker.process(ansi.code);
831
+ // Include ANSI codes in their respective segments
832
+ if (currentCol < beforeEnd) {
833
+ pendingAnsiBefore += ansi.code;
834
+ } else if (currentCol >= afterStart && currentCol < afterEnd && afterStarted) {
835
+ // Only include after we've started "after" (styling already prepended)
836
+ after += ansi.code;
837
+ }
838
+ i += ansi.length;
839
+ continue;
840
+ }
841
+
842
+ let textEnd = i;
843
+ while (textEnd < line.length && !extractAnsiCode(line, textEnd)) textEnd++;
844
+
845
+ for (const { segment } of segmenter.segment(line.slice(i, textEnd))) {
846
+ const w = graphemeWidth(segment);
847
+
848
+ if (currentCol < beforeEnd) {
849
+ if (pendingAnsiBefore) {
850
+ before += pendingAnsiBefore;
851
+ pendingAnsiBefore = "";
852
+ }
853
+ before += segment;
854
+ beforeWidth += w;
855
+ } else if (currentCol >= afterStart && currentCol < afterEnd) {
856
+ const fits = !strictAfter || currentCol + w <= afterEnd;
857
+ if (fits) {
858
+ // On first "after" grapheme, prepend inherited styling from before overlay
859
+ if (!afterStarted) {
860
+ after += pooledStyleTracker.getActiveCodes();
861
+ afterStarted = true;
862
+ }
863
+ after += segment;
864
+ afterWidth += w;
865
+ }
866
+ }
867
+
868
+ currentCol += w;
869
+ // Early exit: done with "before" only, or done with both segments
870
+ if (afterLen <= 0 ? currentCol >= beforeEnd : currentCol >= afterEnd) break;
871
+ }
872
+ i = textEnd;
873
+ if (afterLen <= 0 ? currentCol >= beforeEnd : currentCol >= afterEnd) break;
874
+ }
875
+
876
+ return { before, beforeWidth, after, afterWidth };
877
+ }