@oh-my-pi/pi-tui 8.12.5 → 8.12.7

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": "8.12.5",
3
+ "version": "8.12.7",
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",
@@ -47,6 +47,7 @@
47
47
  "bun": ">=1.3.7"
48
48
  },
49
49
  "dependencies": {
50
+ "@oh-my-pi/pi-natives": "8.12.7",
50
51
  "@types/mime-types": "^3.0.1",
51
52
  "chalk": "^5.6.2",
52
53
  "marked": "^17.0.1",
@@ -743,21 +743,29 @@ export class Editor implements Component, Focusable {
743
743
  (matchesKey(data, "enter") || matchesKey(data, "return") || data === "\n") &&
744
744
  this.autocompletePrefix.startsWith("/")
745
745
  ) {
746
- const selected = this.autocompleteList.getSelectedItem();
747
- if (selected && this.autocompleteProvider) {
748
- const result = this.autocompleteProvider.applyCompletion(
749
- this.state.lines,
750
- this.state.cursorLine,
751
- this.state.cursorCol,
752
- selected,
753
- this.autocompletePrefix,
754
- );
755
-
756
- this.state.lines = result.lines;
757
- this.state.cursorLine = result.cursorLine;
758
- this.state.cursorCol = result.cursorCol;
746
+ // Check for stale autocomplete state due to debounce
747
+ const currentLine = this.state.lines[this.state.cursorLine] ?? "";
748
+ const currentTextBeforeCursor = currentLine.slice(0, this.state.cursorCol);
749
+ if (currentTextBeforeCursor !== this.autocompletePrefix) {
750
+ // Autocomplete is stale - cancel and fall through to normal submission
751
+ this.cancelAutocomplete();
752
+ } else {
753
+ const selected = this.autocompleteList.getSelectedItem();
754
+ if (selected && this.autocompleteProvider) {
755
+ const result = this.autocompleteProvider.applyCompletion(
756
+ this.state.lines,
757
+ this.state.cursorLine,
758
+ this.state.cursorCol,
759
+ selected,
760
+ this.autocompletePrefix,
761
+ );
762
+
763
+ this.state.lines = result.lines;
764
+ this.state.cursorLine = result.cursorLine;
765
+ this.state.cursorCol = result.cursorCol;
766
+ }
767
+ this.cancelAutocomplete();
759
768
  }
760
- this.cancelAutocomplete();
761
769
  // Don't return - fall through to submission logic
762
770
  }
763
771
  // If Enter was pressed on a file path, apply completion
package/src/keys.ts CHANGED
@@ -664,16 +664,26 @@ function rawCtrlChar(letter: string): string {
664
664
  return String.fromCharCode(code);
665
665
  }
666
666
 
667
- function parseKeyId(keyId: string): { key: string; ctrl: boolean; shift: boolean; alt: boolean } | null {
668
- const parts = keyId.toLowerCase().split("+");
667
+ type ParsedKeyId = { key: string; ctrl: boolean; shift: boolean; alt: boolean };
668
+
669
+ const PARSED_KEY_ID_CACHE = new Map<string, ParsedKeyId>();
670
+
671
+ function parseKeyId(keyId: string): ParsedKeyId | null {
672
+ const normalizedKeyId = keyId.toLowerCase();
673
+ const cached = PARSED_KEY_ID_CACHE.get(normalizedKeyId);
674
+ if (cached) return cached;
675
+
676
+ const parts = normalizedKeyId.split("+");
669
677
  const key = parts[parts.length - 1];
670
678
  if (!key) return null;
671
- return {
679
+ const parsed = {
672
680
  key,
673
681
  ctrl: parts.includes("ctrl"),
674
682
  shift: parts.includes("shift"),
675
683
  alt: parts.includes("alt"),
676
684
  };
685
+ PARSED_KEY_ID_CACHE.set(normalizedKeyId, parsed);
686
+ return parsed;
677
687
  }
678
688
 
679
689
  /**
package/src/utils.ts CHANGED
@@ -1,3 +1,10 @@
1
+ import {
2
+ extractSegments as nativeExtractSegments,
3
+ sliceWithWidth as nativeSliceWithWidth,
4
+ truncateToWidth as nativeTruncateToWidth,
5
+ visibleWidth as nativeVisibleWidth,
6
+ } from "@oh-my-pi/pi-natives";
7
+
1
8
  // Grapheme segmenter (shared instance)
2
9
  const segmenter = new Intl.Segmenter(undefined, { granularity: "grapheme" });
3
10
 
@@ -11,6 +18,7 @@ export function getSegmenter(): Intl.Segmenter {
11
18
  // Cache for non-ASCII strings
12
19
  const WIDTH_CACHE_SIZE = 512;
13
20
  const widthCache = new Map<string, number>();
21
+ const NATIVE_WIDTH_THRESHOLD = 256;
14
22
 
15
23
  /**
16
24
  * Calculate the visible width of a string in terminal columns.
@@ -39,20 +47,24 @@ export function visibleWidth(str: string): number {
39
47
  return cached;
40
48
  }
41
49
 
42
- // Normalize: tabs to 3 spaces, strip ANSI escape codes
43
- let clean = str;
44
- if (str.includes("\t")) {
45
- clean = clean.replace(/\t/g, " ");
46
- }
47
- if (clean.includes("\x1b")) {
48
- // Strip SGR codes (\x1b[...m) and cursor codes (\x1b[...G/K/H/J)
49
- clean = clean.replace(/\x1b\[[0-9;]*[mGKHJ]/g, "");
50
- // Strip OSC 8 hyperlinks: \x1b]8;;URL\x07 and \x1b]8;;\x07
51
- clean = clean.replace(/\x1b\]8;;[^\x07]*\x07/g, "");
50
+ let width: number;
51
+ if (str.length <= NATIVE_WIDTH_THRESHOLD) {
52
+ // Normalize: tabs to 3 spaces, strip ANSI escape codes
53
+ let clean = str;
54
+ if (str.includes("\t")) {
55
+ clean = clean.replace(/\t/g, " ");
56
+ }
57
+ if (clean.includes("\x1b")) {
58
+ // Strip SGR codes (\x1b[...m) and cursor codes (\x1b[...G/K/H/J)
59
+ clean = clean.replace(/\x1b\[[0-9;]*[mGKHJ]/g, "");
60
+ // Strip OSC 8 hyperlinks: \x1b]8;;URL\x07 and \x1b]8;;\x07
61
+ clean = clean.replace(/\x1b\]8;;[^\x07]*\x07/g, "");
62
+ }
63
+ width = Bun.stringWidth(clean);
64
+ } else {
65
+ width = nativeVisibleWidth(str);
52
66
  }
53
67
 
54
- const width = Bun.stringWidth(clean);
55
-
56
68
  // Cache result
57
69
  if (widthCache.size >= WIDTH_CACHE_SIZE) {
58
70
  const firstKey = widthCache.keys().next().value;
@@ -98,211 +110,6 @@ export function extractAnsiCode(str: string, pos: number): { code: string; lengt
98
110
  return null;
99
111
  }
100
112
 
101
- /**
102
- * Track active ANSI SGR codes to preserve styling across line breaks.
103
- */
104
- class AnsiCodeTracker {
105
- // Track individual attributes separately so we can reset them specifically
106
- private bold = false;
107
- private dim = false;
108
- private italic = false;
109
- private underline = false;
110
- private blink = false;
111
- private inverse = false;
112
- private hidden = false;
113
- private strikethrough = false;
114
- private fgColor: string | null = null; // Stores the full code like "31" or "38;5;240"
115
- private bgColor: string | null = null; // Stores the full code like "41" or "48;5;240"
116
-
117
- process(ansiCode: string): void {
118
- if (!ansiCode.endsWith("m")) {
119
- return;
120
- }
121
-
122
- // Extract the parameters between \x1b[ and m
123
- const match = ansiCode.match(/\x1b\[([\d;]*)m/);
124
- if (!match) return;
125
-
126
- const params = match[1];
127
- if (params === "" || params === "0") {
128
- // Full reset
129
- this.reset();
130
- return;
131
- }
132
-
133
- // Parse parameters (can be semicolon-separated)
134
- const parts = params.split(";");
135
- let i = 0;
136
- while (i < parts.length) {
137
- const code = Number.parseInt(parts[i], 10);
138
-
139
- // Handle 256-color and RGB codes which consume multiple parameters
140
- if (code === 38 || code === 48) {
141
- // 38;5;N (256 color fg) or 38;2;R;G;B (RGB fg)
142
- // 48;5;N (256 color bg) or 48;2;R;G;B (RGB bg)
143
- if (parts[i + 1] === "5" && parts[i + 2] !== undefined) {
144
- // 256 color: 38;5;N or 48;5;N
145
- const colorCode = `${parts[i]};${parts[i + 1]};${parts[i + 2]}`;
146
- if (code === 38) {
147
- this.fgColor = colorCode;
148
- } else {
149
- this.bgColor = colorCode;
150
- }
151
- i += 3;
152
- continue;
153
- } else if (parts[i + 1] === "2" && parts[i + 4] !== undefined) {
154
- // RGB color: 38;2;R;G;B or 48;2;R;G;B
155
- const colorCode = `${parts[i]};${parts[i + 1]};${parts[i + 2]};${parts[i + 3]};${parts[i + 4]}`;
156
- if (code === 38) {
157
- this.fgColor = colorCode;
158
- } else {
159
- this.bgColor = colorCode;
160
- }
161
- i += 5;
162
- continue;
163
- }
164
- }
165
-
166
- // Standard SGR codes
167
- switch (code) {
168
- case 0:
169
- this.reset();
170
- break;
171
- case 1:
172
- this.bold = true;
173
- break;
174
- case 2:
175
- this.dim = true;
176
- break;
177
- case 3:
178
- this.italic = true;
179
- break;
180
- case 4:
181
- this.underline = true;
182
- break;
183
- case 5:
184
- this.blink = true;
185
- break;
186
- case 7:
187
- this.inverse = true;
188
- break;
189
- case 8:
190
- this.hidden = true;
191
- break;
192
- case 9:
193
- this.strikethrough = true;
194
- break;
195
- case 21:
196
- this.bold = false;
197
- break; // Some terminals
198
- case 22:
199
- this.bold = false;
200
- this.dim = false;
201
- break;
202
- case 23:
203
- this.italic = false;
204
- break;
205
- case 24:
206
- this.underline = false;
207
- break;
208
- case 25:
209
- this.blink = false;
210
- break;
211
- case 27:
212
- this.inverse = false;
213
- break;
214
- case 28:
215
- this.hidden = false;
216
- break;
217
- case 29:
218
- this.strikethrough = false;
219
- break;
220
- case 39:
221
- this.fgColor = null;
222
- break; // Default fg
223
- case 49:
224
- this.bgColor = null;
225
- break; // Default bg
226
- default:
227
- // Standard foreground colors 30-37, 90-97
228
- if ((code >= 30 && code <= 37) || (code >= 90 && code <= 97)) {
229
- this.fgColor = String(code);
230
- }
231
- // Standard background colors 40-47, 100-107
232
- else if ((code >= 40 && code <= 47) || (code >= 100 && code <= 107)) {
233
- this.bgColor = String(code);
234
- }
235
- break;
236
- }
237
- i++;
238
- }
239
- }
240
-
241
- private reset(): void {
242
- this.bold = false;
243
- this.dim = false;
244
- this.italic = false;
245
- this.underline = false;
246
- this.blink = false;
247
- this.inverse = false;
248
- this.hidden = false;
249
- this.strikethrough = false;
250
- this.fgColor = null;
251
- this.bgColor = null;
252
- }
253
-
254
- /** Clear all state for reuse. */
255
- clear(): void {
256
- this.reset();
257
- }
258
-
259
- getActiveCodes(): string {
260
- const codes: string[] = [];
261
- if (this.bold) codes.push("1");
262
- if (this.dim) codes.push("2");
263
- if (this.italic) codes.push("3");
264
- if (this.underline) codes.push("4");
265
- if (this.blink) codes.push("5");
266
- if (this.inverse) codes.push("7");
267
- if (this.hidden) codes.push("8");
268
- if (this.strikethrough) codes.push("9");
269
- if (this.fgColor) codes.push(this.fgColor);
270
- if (this.bgColor) codes.push(this.bgColor);
271
-
272
- if (codes.length === 0) return "";
273
- return `\x1b[${codes.join(";")}m`;
274
- }
275
-
276
- hasActiveCodes(): boolean {
277
- return (
278
- this.bold ||
279
- this.dim ||
280
- this.italic ||
281
- this.underline ||
282
- this.blink ||
283
- this.inverse ||
284
- this.hidden ||
285
- this.strikethrough ||
286
- this.fgColor !== null ||
287
- this.bgColor !== null
288
- );
289
- }
290
-
291
- /**
292
- * Get reset codes for attributes that need to be turned off at line end,
293
- * specifically underline which bleeds into padding.
294
- * Returns empty string if no problematic attributes are active.
295
- */
296
- getLineEndReset(): string {
297
- // Only underline causes visual bleeding into padding
298
- // Other attributes like colors don't visually bleed to padding
299
- if (this.underline) {
300
- return "\x1b[24m"; // Underline off only
301
- }
302
- return "";
303
- }
304
- }
305
-
306
113
  const WRAP_OPTIONS = { wordWrap: true, hard: true, trim: false } as const;
307
114
 
308
115
  /**
@@ -367,76 +174,7 @@ export function applyBackgroundToLine(line: string, width: number, bgFn: (text:
367
174
  * @returns Truncated text, optionally padded to exactly maxWidth
368
175
  */
369
176
  export function truncateToWidth(text: string, maxWidth: number, ellipsis: string = "…", pad: boolean = false): string {
370
- const textVisibleWidth = visibleWidth(text);
371
-
372
- if (textVisibleWidth <= maxWidth) {
373
- return pad ? text + " ".repeat(maxWidth - textVisibleWidth) : text;
374
- }
375
-
376
- const ellipsisWidth = visibleWidth(ellipsis);
377
- const targetWidth = maxWidth - ellipsisWidth;
378
-
379
- if (targetWidth <= 0) {
380
- return ellipsis.substring(0, maxWidth);
381
- }
382
-
383
- // Separate ANSI codes from visible content using grapheme segmentation
384
- let i = 0;
385
- const segments: Array<{ type: "ansi" | "grapheme"; value: string }> = [];
386
-
387
- while (i < text.length) {
388
- const ansiResult = extractAnsiCode(text, i);
389
- if (ansiResult) {
390
- segments.push({ type: "ansi", value: ansiResult.code });
391
- i += ansiResult.length;
392
- } else {
393
- // Find the next ANSI code or end of string
394
- let end = i;
395
- while (end < text.length) {
396
- const nextAnsi = extractAnsiCode(text, end);
397
- if (nextAnsi) break;
398
- end++;
399
- }
400
- // Segment this non-ANSI portion into graphemes
401
- const textPortion = text.slice(i, end);
402
- for (const seg of segmenter.segment(textPortion)) {
403
- segments.push({ type: "grapheme", value: seg.segment });
404
- }
405
- i = end;
406
- }
407
- }
408
-
409
- // Build truncated string from segments
410
- let result = "";
411
- let currentWidth = 0;
412
-
413
- for (const seg of segments) {
414
- if (seg.type === "ansi") {
415
- result += seg.value;
416
- continue;
417
- }
418
-
419
- const grapheme = seg.value;
420
- // Skip empty graphemes to avoid issues with string-width calculation
421
- if (!grapheme) continue;
422
-
423
- const graphemeWidth = visibleWidth(grapheme);
424
-
425
- if (currentWidth + graphemeWidth > targetWidth) {
426
- break;
427
- }
428
-
429
- result += grapheme;
430
- currentWidth += graphemeWidth;
431
- }
432
-
433
- // Add reset code before ellipsis to prevent styling leaking into it
434
- const truncated = `${result}\x1b[0m${ellipsis}`;
435
- if (pad) {
436
- const truncatedWidth = visibleWidth(truncated);
437
- return truncated + " ".repeat(Math.max(0, maxWidth - truncatedWidth));
438
- }
439
- return truncated;
177
+ return nativeTruncateToWidth(text, maxWidth, ellipsis, pad);
440
178
  }
441
179
 
442
180
  /**
@@ -455,49 +193,9 @@ export function sliceWithWidth(
455
193
  strict = false,
456
194
  ): { text: string; width: number } {
457
195
  if (length <= 0) return { text: "", width: 0 };
458
- const endCol = startCol + length;
459
- let result = "",
460
- resultWidth = 0,
461
- currentCol = 0,
462
- i = 0,
463
- pendingAnsi = "";
464
-
465
- while (i < line.length) {
466
- const ansi = extractAnsiCode(line, i);
467
- if (ansi) {
468
- if (currentCol >= startCol && currentCol < endCol) result += ansi.code;
469
- else if (currentCol < startCol) pendingAnsi += ansi.code;
470
- i += ansi.length;
471
- continue;
472
- }
473
-
474
- let textEnd = i;
475
- while (textEnd < line.length && !extractAnsiCode(line, textEnd)) textEnd++;
476
-
477
- for (const { segment } of segmenter.segment(line.slice(i, textEnd))) {
478
- const w = visibleWidth(segment);
479
- const inRange = currentCol >= startCol && currentCol < endCol;
480
- const fits = !strict || currentCol + w <= endCol;
481
- if (inRange && fits) {
482
- if (pendingAnsi) {
483
- result += pendingAnsi;
484
- pendingAnsi = "";
485
- }
486
- result += segment;
487
- resultWidth += w;
488
- }
489
- currentCol += w;
490
- if (currentCol >= endCol) break;
491
- }
492
- i = textEnd;
493
- if (currentCol >= endCol) break;
494
- }
495
- return { text: result, width: resultWidth };
196
+ return nativeSliceWithWidth(line, startCol, length, strict);
496
197
  }
497
198
 
498
- // Pooled tracker instance for extractSegments (avoids allocation per call)
499
- const pooledStyleTracker = new AnsiCodeTracker();
500
-
501
199
  /**
502
200
  * Extract "before" and "after" segments from a line in a single pass.
503
201
  * Used for overlay compositing where we need content before and after the overlay region.
@@ -510,68 +208,5 @@ export function extractSegments(
510
208
  afterLen: number,
511
209
  strictAfter = false,
512
210
  ): { before: string; beforeWidth: number; after: string; afterWidth: number } {
513
- let before = "",
514
- beforeWidth = 0,
515
- after = "",
516
- afterWidth = 0;
517
- let currentCol = 0,
518
- i = 0;
519
- let pendingAnsiBefore = "";
520
- let afterStarted = false;
521
- const afterEnd = afterStart + afterLen;
522
-
523
- // Track styling state so "after" inherits styling from before the overlay
524
- pooledStyleTracker.clear();
525
-
526
- while (i < line.length) {
527
- const ansi = extractAnsiCode(line, i);
528
- if (ansi) {
529
- // Track all SGR codes to know styling state at afterStart
530
- pooledStyleTracker.process(ansi.code);
531
- // Include ANSI codes in their respective segments
532
- if (currentCol < beforeEnd) {
533
- pendingAnsiBefore += ansi.code;
534
- } else if (currentCol >= afterStart && currentCol < afterEnd && afterStarted) {
535
- // Only include after we've started "after" (styling already prepended)
536
- after += ansi.code;
537
- }
538
- i += ansi.length;
539
- continue;
540
- }
541
-
542
- let textEnd = i;
543
- while (textEnd < line.length && !extractAnsiCode(line, textEnd)) textEnd++;
544
-
545
- for (const { segment } of segmenter.segment(line.slice(i, textEnd))) {
546
- const w = visibleWidth(segment);
547
-
548
- if (currentCol < beforeEnd) {
549
- if (pendingAnsiBefore) {
550
- before += pendingAnsiBefore;
551
- pendingAnsiBefore = "";
552
- }
553
- before += segment;
554
- beforeWidth += w;
555
- } else if (currentCol >= afterStart && currentCol < afterEnd) {
556
- const fits = !strictAfter || currentCol + w <= afterEnd;
557
- if (fits) {
558
- // On first "after" grapheme, prepend inherited styling from before overlay
559
- if (!afterStarted) {
560
- after += pooledStyleTracker.getActiveCodes();
561
- afterStarted = true;
562
- }
563
- after += segment;
564
- afterWidth += w;
565
- }
566
- }
567
-
568
- currentCol += w;
569
- // Early exit: done with "before" only, or done with both segments
570
- if (afterLen <= 0 ? currentCol >= beforeEnd : currentCol >= afterEnd) break;
571
- }
572
- i = textEnd;
573
- if (afterLen <= 0 ? currentCol >= beforeEnd : currentCol >= afterEnd) break;
574
- }
575
-
576
- return { before, beforeWidth, after, afterWidth };
211
+ return nativeExtractSegments(line, beforeEnd, afterStart, afterLen, strictAfter);
577
212
  }