phi-code-tui 0.56.3 → 0.74.1

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 (88) hide show
  1. package/README.md +29 -11
  2. package/dist/autocomplete.d.ts +18 -14
  3. package/dist/autocomplete.d.ts.map +1 -1
  4. package/dist/autocomplete.js +151 -112
  5. package/dist/autocomplete.js.map +1 -1
  6. package/dist/components/box.d.ts.map +1 -1
  7. package/dist/components/box.js +6 -1
  8. package/dist/components/box.js.map +1 -1
  9. package/dist/components/cancellable-loader.d.ts.map +1 -1
  10. package/dist/components/cancellable-loader.js +6 -7
  11. package/dist/components/cancellable-loader.js.map +1 -1
  12. package/dist/components/editor.d.ts +45 -1
  13. package/dist/components/editor.d.ts.map +1 -1
  14. package/dist/components/editor.js +505 -221
  15. package/dist/components/editor.js.map +1 -1
  16. package/dist/components/image.d.ts.map +1 -1
  17. package/dist/components/image.js +22 -7
  18. package/dist/components/image.js.map +1 -1
  19. package/dist/components/input.d.ts.map +1 -1
  20. package/dist/components/input.js +57 -74
  21. package/dist/components/input.js.map +1 -1
  22. package/dist/components/loader.d.ts +12 -2
  23. package/dist/components/loader.d.ts.map +1 -1
  24. package/dist/components/loader.js +36 -13
  25. package/dist/components/loader.js.map +1 -1
  26. package/dist/components/markdown.d.ts +0 -5
  27. package/dist/components/markdown.d.ts.map +1 -1
  28. package/dist/components/markdown.js +101 -114
  29. package/dist/components/markdown.js.map +1 -1
  30. package/dist/components/select-list.d.ts +19 -1
  31. package/dist/components/select-list.d.ts.map +1 -1
  32. package/dist/components/select-list.js +82 -71
  33. package/dist/components/select-list.js.map +1 -1
  34. package/dist/components/settings-list.d.ts.map +1 -1
  35. package/dist/components/settings-list.js +18 -10
  36. package/dist/components/settings-list.js.map +1 -1
  37. package/dist/components/spacer.d.ts.map +1 -1
  38. package/dist/components/spacer.js +1 -0
  39. package/dist/components/spacer.js.map +1 -1
  40. package/dist/components/text.d.ts.map +1 -1
  41. package/dist/components/text.js +8 -0
  42. package/dist/components/text.js.map +1 -1
  43. package/dist/components/truncated-text.d.ts.map +1 -1
  44. package/dist/components/truncated-text.js +3 -0
  45. package/dist/components/truncated-text.js.map +1 -1
  46. package/dist/editor-component.d.ts.map +1 -1
  47. package/dist/fuzzy.d.ts.map +1 -1
  48. package/dist/fuzzy.js +3 -0
  49. package/dist/fuzzy.js.map +1 -1
  50. package/dist/index.d.ts +5 -5
  51. package/dist/index.d.ts.map +1 -1
  52. package/dist/index.js +3 -3
  53. package/dist/index.js.map +1 -1
  54. package/dist/keybindings.d.ts +187 -33
  55. package/dist/keybindings.d.ts.map +1 -1
  56. package/dist/keybindings.js +156 -95
  57. package/dist/keybindings.js.map +1 -1
  58. package/dist/keys.d.ts +21 -12
  59. package/dist/keys.d.ts.map +1 -1
  60. package/dist/keys.js +270 -112
  61. package/dist/keys.js.map +1 -1
  62. package/dist/kill-ring.d.ts.map +1 -1
  63. package/dist/kill-ring.js +1 -3
  64. package/dist/kill-ring.js.map +1 -1
  65. package/dist/stdin-buffer.d.ts +2 -0
  66. package/dist/stdin-buffer.d.ts.map +1 -1
  67. package/dist/stdin-buffer.js +31 -8
  68. package/dist/stdin-buffer.js.map +1 -1
  69. package/dist/terminal-image.d.ts +17 -0
  70. package/dist/terminal-image.d.ts.map +1 -1
  71. package/dist/terminal-image.js +41 -5
  72. package/dist/terminal-image.js.map +1 -1
  73. package/dist/terminal.d.ts +4 -0
  74. package/dist/terminal.d.ts.map +1 -1
  75. package/dist/terminal.js +56 -8
  76. package/dist/terminal.js.map +1 -1
  77. package/dist/tui.d.ts +21 -5
  78. package/dist/tui.d.ts.map +1 -1
  79. package/dist/tui.js +234 -118
  80. package/dist/tui.js.map +1 -1
  81. package/dist/undo-stack.d.ts.map +1 -1
  82. package/dist/undo-stack.js +1 -3
  83. package/dist/undo-stack.js.map +1 -1
  84. package/dist/utils.d.ts +1 -0
  85. package/dist/utils.d.ts.map +1 -1
  86. package/dist/utils.js +281 -81
  87. package/dist/utils.js.map +1 -1
  88. package/package.json +3 -3
package/dist/utils.js CHANGED
@@ -30,6 +30,98 @@ const rgiEmojiRegex = /^\p{RGI_Emoji}$/v;
30
30
  // Cache for non-ASCII strings
31
31
  const WIDTH_CACHE_SIZE = 512;
32
32
  const widthCache = new Map();
33
+ function isPrintableAscii(str) {
34
+ for (let i = 0; i < str.length; i++) {
35
+ const code = str.charCodeAt(i);
36
+ if (code < 0x20 || code > 0x7e) {
37
+ return false;
38
+ }
39
+ }
40
+ return true;
41
+ }
42
+ function truncateFragmentToWidth(text, maxWidth) {
43
+ if (maxWidth <= 0 || text.length === 0) {
44
+ return { text: "", width: 0 };
45
+ }
46
+ if (isPrintableAscii(text)) {
47
+ const clipped = text.slice(0, maxWidth);
48
+ return { text: clipped, width: clipped.length };
49
+ }
50
+ const hasAnsi = text.includes("\x1b");
51
+ const hasTabs = text.includes("\t");
52
+ if (!hasAnsi && !hasTabs) {
53
+ let result = "";
54
+ let width = 0;
55
+ for (const { segment } of segmenter.segment(text)) {
56
+ const w = graphemeWidth(segment);
57
+ if (width + w > maxWidth) {
58
+ break;
59
+ }
60
+ result += segment;
61
+ width += w;
62
+ }
63
+ return { text: result, width };
64
+ }
65
+ let result = "";
66
+ let width = 0;
67
+ let i = 0;
68
+ let pendingAnsi = "";
69
+ while (i < text.length) {
70
+ const ansi = extractAnsiCode(text, i);
71
+ if (ansi) {
72
+ pendingAnsi += ansi.code;
73
+ i += ansi.length;
74
+ continue;
75
+ }
76
+ if (text[i] === "\t") {
77
+ if (width + 3 > maxWidth) {
78
+ break;
79
+ }
80
+ if (pendingAnsi) {
81
+ result += pendingAnsi;
82
+ pendingAnsi = "";
83
+ }
84
+ result += "\t";
85
+ width += 3;
86
+ i++;
87
+ continue;
88
+ }
89
+ let end = i;
90
+ while (end < text.length && text[end] !== "\t") {
91
+ const nextAnsi = extractAnsiCode(text, end);
92
+ if (nextAnsi) {
93
+ break;
94
+ }
95
+ end++;
96
+ }
97
+ for (const { segment } of segmenter.segment(text.slice(i, end))) {
98
+ const w = graphemeWidth(segment);
99
+ if (width + w > maxWidth) {
100
+ return { text: result, width };
101
+ }
102
+ if (pendingAnsi) {
103
+ result += pendingAnsi;
104
+ pendingAnsi = "";
105
+ }
106
+ result += segment;
107
+ width += w;
108
+ }
109
+ i = end;
110
+ }
111
+ return { text: result, width };
112
+ }
113
+ function finalizeTruncatedResult(prefix, prefixWidth, ellipsis, ellipsisWidth, maxWidth, pad) {
114
+ const reset = "\x1b[0m";
115
+ const visibleWidth = prefixWidth + ellipsisWidth;
116
+ let result;
117
+ if (ellipsis.length > 0) {
118
+ result = `${prefix}${reset}${ellipsis}${reset}`;
119
+ }
120
+ else {
121
+ result = `${prefix}${reset}`;
122
+ }
123
+ return pad ? result + " ".repeat(Math.max(0, maxWidth - visibleWidth)) : result;
124
+ }
33
125
  /**
34
126
  * Calculate the terminal width of a single grapheme cluster.
35
127
  * Based on code from the string-width library, but includes a possible-emoji
@@ -57,13 +149,16 @@ function graphemeWidth(segment) {
57
149
  return 2;
58
150
  }
59
151
  let width = eastAsianWidth(cp);
60
- // Trailing halfwidth/fullwidth forms
152
+ // Trailing halfwidth/fullwidth forms and AM vowels that segment with a base.
61
153
  if (segment.length > 1) {
62
154
  for (const char of segment.slice(1)) {
63
155
  const c = char.codePointAt(0);
64
156
  if (c >= 0xff00 && c <= 0xffef) {
65
157
  width += eastAsianWidth(c);
66
158
  }
159
+ else if (c === 0x0e33 || c === 0x0eb3) {
160
+ width += 1;
161
+ }
67
162
  }
68
163
  }
69
164
  return width;
@@ -76,15 +171,7 @@ export function visibleWidth(str) {
76
171
  return 0;
77
172
  }
78
173
  // Fast path: pure ASCII printable
79
- let isPureAscii = true;
80
- for (let i = 0; i < str.length; i++) {
81
- const code = str.charCodeAt(i);
82
- if (code < 0x20 || code > 0x7e) {
83
- isPureAscii = false;
84
- break;
85
- }
86
- }
87
- if (isPureAscii) {
174
+ if (isPrintableAscii(str)) {
88
175
  return str.length;
89
176
  }
90
177
  // Check cache
@@ -129,6 +216,19 @@ export function visibleWidth(str) {
129
216
  widthCache.set(str, width);
130
217
  return width;
131
218
  }
219
+ /**
220
+ * Normalize text for terminal output without changing logical editor content.
221
+ * Some terminals render precomposed Thai/Lao AM vowels inconsistently during
222
+ * differential repaint. Their compatibility decompositions have the same cell
223
+ * width but avoid stale-cell artifacts in terminal renderers.
224
+ */
225
+ const THAI_LAO_AM_REGEX = /[\u0e33\u0eb3]/;
226
+ const THAI_LAO_AM_GLOBAL_REGEX = /[\u0e33\u0eb3]/g;
227
+ export function normalizeTerminalOutput(str) {
228
+ if (!THAI_LAO_AM_REGEX.test(str))
229
+ return str;
230
+ return str.replace(THAI_LAO_AM_GLOBAL_REGEX, (char) => (char === "\u0e33" ? "\u0e4d\u0e32" : "\u0ecd\u0eb2"));
231
+ }
132
232
  /**
133
233
  * Extract ANSI escape sequences from a string at the given position.
134
234
  */
@@ -173,24 +273,55 @@ export function extractAnsiCode(str, pos) {
173
273
  }
174
274
  return null;
175
275
  }
276
+ function parseOsc8Hyperlink(ansiCode) {
277
+ if (!ansiCode.startsWith("\x1b]8;")) {
278
+ return undefined;
279
+ }
280
+ const terminator = ansiCode.endsWith("\x07") ? "\x07" : "\x1b\\";
281
+ const body = ansiCode.slice(4, terminator === "\x07" ? -1 : -2);
282
+ const separatorIndex = body.indexOf(";");
283
+ if (separatorIndex === -1) {
284
+ return undefined;
285
+ }
286
+ const params = body.slice(0, separatorIndex);
287
+ const url = body.slice(separatorIndex + 1);
288
+ if (!url) {
289
+ return null;
290
+ }
291
+ return { params, url, terminator };
292
+ }
293
+ function formatOsc8Hyperlink(hyperlink) {
294
+ return `\x1b]8;${hyperlink.params};${hyperlink.url}${hyperlink.terminator}`;
295
+ }
296
+ function formatOsc8Close(terminator) {
297
+ return `\x1b]8;;${terminator}`;
298
+ }
176
299
  /**
177
300
  * Track active ANSI SGR codes to preserve styling across line breaks.
178
301
  */
179
302
  class AnsiCodeTracker {
180
- constructor() {
181
- // Track individual attributes separately so we can reset them specifically
182
- this.bold = false;
183
- this.dim = false;
184
- this.italic = false;
185
- this.underline = false;
186
- this.blink = false;
187
- this.inverse = false;
188
- this.hidden = false;
189
- this.strikethrough = false;
190
- this.fgColor = null; // Stores the full code like "31" or "38;5;240"
191
- this.bgColor = null; // Stores the full code like "41" or "48;5;240"
192
- }
303
+ // Track individual attributes separately so we can reset them specifically
304
+ bold = false;
305
+ dim = false;
306
+ italic = false;
307
+ underline = false;
308
+ blink = false;
309
+ inverse = false;
310
+ hidden = false;
311
+ strikethrough = false;
312
+ fgColor = null; // Stores the full code like "31" or "38;5;240"
313
+ bgColor = null; // Stores the full code like "41" or "48;5;240"
314
+ activeHyperlink = null;
193
315
  process(ansiCode) {
316
+ // OSC 8 hyperlink: \x1b]8;;<url>\x1b\\ (open) or \x1b]8;;\x1b\\ (close).
317
+ // Preserve the original terminator because some terminals only make BEL-terminated
318
+ // links clickable. OAuth login URLs use BEL, so reopening wrapped lines with ST
319
+ // made only the first physical line clickable in those terminals.
320
+ const hyperlink = parseOsc8Hyperlink(ansiCode);
321
+ if (hyperlink !== undefined) {
322
+ this.activeHyperlink = hyperlink;
323
+ return;
324
+ }
194
325
  if (!ansiCode.endsWith("m")) {
195
326
  return;
196
327
  }
@@ -323,10 +454,12 @@ class AnsiCodeTracker {
323
454
  this.strikethrough = false;
324
455
  this.fgColor = null;
325
456
  this.bgColor = null;
457
+ // SGR reset does not affect OSC 8 hyperlink state
326
458
  }
327
459
  /** Clear all state for reuse. */
328
460
  clear() {
329
461
  this.reset();
462
+ this.activeHyperlink = null;
330
463
  }
331
464
  getActiveCodes() {
332
465
  const codes = [];
@@ -350,9 +483,11 @@ class AnsiCodeTracker {
350
483
  codes.push(this.fgColor);
351
484
  if (this.bgColor)
352
485
  codes.push(this.bgColor);
353
- if (codes.length === 0)
354
- return "";
355
- return `\x1b[${codes.join(";")}m`;
486
+ let result = codes.length > 0 ? `\x1b[${codes.join(";")}m` : "";
487
+ if (this.activeHyperlink) {
488
+ result += formatOsc8Hyperlink(this.activeHyperlink);
489
+ }
490
+ return result;
356
491
  }
357
492
  hasActiveCodes() {
358
493
  return (this.bold ||
@@ -364,20 +499,24 @@ class AnsiCodeTracker {
364
499
  this.hidden ||
365
500
  this.strikethrough ||
366
501
  this.fgColor !== null ||
367
- this.bgColor !== null);
502
+ this.bgColor !== null ||
503
+ this.activeHyperlink !== null);
368
504
  }
369
505
  /**
370
- * Get reset codes for attributes that need to be turned off at line end,
371
- * specifically underline which bleeds into padding.
372
- * Returns empty string if no problematic attributes are active.
506
+ * Get reset codes for attributes that need to be turned off at line end.
507
+ * Underline must be closed to prevent bleeding into padding.
508
+ * Active OSC 8 hyperlinks must be closed and re-opened on the next line.
509
+ * Returns empty string if no attributes need closing.
373
510
  */
374
511
  getLineEndReset() {
375
- // Only underline causes visual bleeding into padding
376
- // Other attributes like colors don't visually bleed to padding
512
+ let result = "";
377
513
  if (this.underline) {
378
- return "\x1b[24m"; // Underline off only
514
+ result += "\x1b[24m"; // Underline off only
379
515
  }
380
- return "";
516
+ if (this.activeHyperlink) {
517
+ result += formatOsc8Close(this.activeHyperlink.terminator); // Re-opened at line start via getActiveCodes()
518
+ }
519
+ return result;
381
520
  }
382
521
  }
383
522
  function updateTrackerFromText(text, tracker) {
@@ -637,67 +776,128 @@ export function applyBackgroundToLine(line, width, bgFn) {
637
776
  * @returns Truncated text, optionally padded to exactly maxWidth
638
777
  */
639
778
  export function truncateToWidth(text, maxWidth, ellipsis = "...", pad = false) {
640
- const textVisibleWidth = visibleWidth(text);
641
- if (textVisibleWidth <= maxWidth) {
642
- return pad ? text + " ".repeat(maxWidth - textVisibleWidth) : text;
779
+ if (maxWidth <= 0) {
780
+ return "";
781
+ }
782
+ if (text.length === 0) {
783
+ return pad ? " ".repeat(maxWidth) : "";
643
784
  }
644
785
  const ellipsisWidth = visibleWidth(ellipsis);
645
- const targetWidth = maxWidth - ellipsisWidth;
646
- if (targetWidth <= 0) {
647
- return ellipsis.substring(0, maxWidth);
786
+ if (ellipsisWidth >= maxWidth) {
787
+ const textWidth = visibleWidth(text);
788
+ if (textWidth <= maxWidth) {
789
+ return pad ? text + " ".repeat(maxWidth - textWidth) : text;
790
+ }
791
+ const clippedEllipsis = truncateFragmentToWidth(ellipsis, maxWidth);
792
+ if (clippedEllipsis.width === 0) {
793
+ return pad ? " ".repeat(maxWidth) : "";
794
+ }
795
+ return finalizeTruncatedResult("", 0, clippedEllipsis.text, clippedEllipsis.width, maxWidth, pad);
648
796
  }
649
- // Separate ANSI codes from visible content using grapheme segmentation
650
- let i = 0;
651
- const segments = [];
652
- while (i < text.length) {
653
- const ansiResult = extractAnsiCode(text, i);
654
- if (ansiResult) {
655
- segments.push({ type: "ansi", value: ansiResult.code });
656
- i += ansiResult.length;
797
+ if (isPrintableAscii(text)) {
798
+ if (text.length <= maxWidth) {
799
+ return pad ? text + " ".repeat(maxWidth - text.length) : text;
657
800
  }
658
- else {
659
- // Find the next ANSI code or end of string
801
+ const targetWidth = maxWidth - ellipsisWidth;
802
+ return finalizeTruncatedResult(text.slice(0, targetWidth), targetWidth, ellipsis, ellipsisWidth, maxWidth, pad);
803
+ }
804
+ const targetWidth = maxWidth - ellipsisWidth;
805
+ let result = "";
806
+ let pendingAnsi = "";
807
+ let visibleSoFar = 0;
808
+ let keptWidth = 0;
809
+ let keepContiguousPrefix = true;
810
+ let overflowed = false;
811
+ let exhaustedInput = false;
812
+ const hasAnsi = text.includes("\x1b");
813
+ const hasTabs = text.includes("\t");
814
+ if (!hasAnsi && !hasTabs) {
815
+ for (const { segment } of segmenter.segment(text)) {
816
+ const width = graphemeWidth(segment);
817
+ if (keepContiguousPrefix && keptWidth + width <= targetWidth) {
818
+ result += segment;
819
+ keptWidth += width;
820
+ }
821
+ else {
822
+ keepContiguousPrefix = false;
823
+ }
824
+ visibleSoFar += width;
825
+ if (visibleSoFar > maxWidth) {
826
+ overflowed = true;
827
+ break;
828
+ }
829
+ }
830
+ exhaustedInput = !overflowed;
831
+ }
832
+ else {
833
+ let i = 0;
834
+ while (i < text.length) {
835
+ const ansi = extractAnsiCode(text, i);
836
+ if (ansi) {
837
+ pendingAnsi += ansi.code;
838
+ i += ansi.length;
839
+ continue;
840
+ }
841
+ if (text[i] === "\t") {
842
+ if (keepContiguousPrefix && keptWidth + 3 <= targetWidth) {
843
+ if (pendingAnsi) {
844
+ result += pendingAnsi;
845
+ pendingAnsi = "";
846
+ }
847
+ result += "\t";
848
+ keptWidth += 3;
849
+ }
850
+ else {
851
+ keepContiguousPrefix = false;
852
+ pendingAnsi = "";
853
+ }
854
+ visibleSoFar += 3;
855
+ if (visibleSoFar > maxWidth) {
856
+ overflowed = true;
857
+ break;
858
+ }
859
+ i++;
860
+ continue;
861
+ }
660
862
  let end = i;
661
- while (end < text.length) {
863
+ while (end < text.length && text[end] !== "\t") {
662
864
  const nextAnsi = extractAnsiCode(text, end);
663
- if (nextAnsi)
865
+ if (nextAnsi) {
664
866
  break;
867
+ }
665
868
  end++;
666
869
  }
667
- // Segment this non-ANSI portion into graphemes
668
- const textPortion = text.slice(i, end);
669
- for (const seg of segmenter.segment(textPortion)) {
670
- segments.push({ type: "grapheme", value: seg.segment });
870
+ for (const { segment } of segmenter.segment(text.slice(i, end))) {
871
+ const width = graphemeWidth(segment);
872
+ if (keepContiguousPrefix && keptWidth + width <= targetWidth) {
873
+ if (pendingAnsi) {
874
+ result += pendingAnsi;
875
+ pendingAnsi = "";
876
+ }
877
+ result += segment;
878
+ keptWidth += width;
879
+ }
880
+ else {
881
+ keepContiguousPrefix = false;
882
+ pendingAnsi = "";
883
+ }
884
+ visibleSoFar += width;
885
+ if (visibleSoFar > maxWidth) {
886
+ overflowed = true;
887
+ break;
888
+ }
889
+ }
890
+ if (overflowed) {
891
+ break;
671
892
  }
672
893
  i = end;
673
894
  }
895
+ exhaustedInput = i >= text.length;
674
896
  }
675
- // Build truncated string from segments
676
- let result = "";
677
- let currentWidth = 0;
678
- for (const seg of segments) {
679
- if (seg.type === "ansi") {
680
- result += seg.value;
681
- continue;
682
- }
683
- const grapheme = seg.value;
684
- // Skip empty graphemes to avoid issues with string-width calculation
685
- if (!grapheme)
686
- continue;
687
- const graphemeWidth = visibleWidth(grapheme);
688
- if (currentWidth + graphemeWidth > targetWidth) {
689
- break;
690
- }
691
- result += grapheme;
692
- currentWidth += graphemeWidth;
693
- }
694
- // Add reset code before ellipsis to prevent styling leaking into it
695
- const truncated = `${result}\x1b[0m${ellipsis}`;
696
- if (pad) {
697
- const truncatedWidth = visibleWidth(truncated);
698
- return truncated + " ".repeat(Math.max(0, maxWidth - truncatedWidth));
897
+ if (!overflowed && exhaustedInput) {
898
+ return pad ? text + " ".repeat(Math.max(0, maxWidth - visibleSoFar)) : text;
699
899
  }
700
- return truncated;
900
+ return finalizeTruncatedResult(result, keptWidth, ellipsis, ellipsisWidth, maxWidth, pad);
701
901
  }
702
902
  /**
703
903
  * Extract a range of visible columns from a line. Handles ANSI codes and wide chars.