@oh-my-pi/pi-tui 9.6.0 → 9.6.3

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/README.md CHANGED
@@ -542,16 +542,16 @@ interface Terminal {
542
542
  ## Utilities
543
543
 
544
544
  ```typescript
545
- import { visibleWidth, truncateToWidth, wrapTextWithAnsi } from "@oh-my-pi/pi-tui";
545
+ import { Ellipsis, visibleWidth, truncateToWidth, wrapTextWithAnsi } from "@oh-my-pi/pi-tui";
546
546
 
547
547
  // Get visible width of string (ignoring ANSI codes, uses Bun.stringWidth)
548
548
  const width = visibleWidth("\x1b[31mHello\x1b[0m"); // 5
549
549
 
550
550
  // Truncate string to width (preserving ANSI codes, adds ellipsis)
551
- const truncated = truncateToWidth("Hello World", 8); // "Hello..."
551
+ const truncated = truncateToWidth("Hello World", 8); // "Hello" (default: Ellipsis.Unicode)
552
552
 
553
553
  // Truncate without ellipsis
554
- const truncatedNoEllipsis = truncateToWidth("Hello World", 8, ""); // "Hello Wo"
554
+ const truncatedNoEllipsis = truncateToWidth("Hello World", 8, Ellipsis.Omit); // "Hello Wo"
555
555
 
556
556
  // Wrap text to width (Bun.wrapAnsi word wrap, trims line ends, preserves ANSI)
557
557
  const lines = wrapTextWithAnsi("This is a long line that needs wrapping", 20);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oh-my-pi/pi-tui",
3
- "version": "9.6.0",
3
+ "version": "9.6.3",
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,7 +47,7 @@
47
47
  "bun": ">=1.3.7"
48
48
  },
49
49
  "dependencies": {
50
- "@oh-my-pi/pi-natives": "9.6.0",
50
+ "@oh-my-pi/pi-natives": "9.6.3",
51
51
  "@types/mime-types": "^3.0.1",
52
52
  "chalk": "^5.6.2",
53
53
  "marked": "^17.0.1",
@@ -516,7 +516,7 @@ export class Editor implements Component, Focusable {
516
516
  result.push(topLeft + content + this.borderColor(box.horizontal.repeat(fillWidth)) + topRight);
517
517
  } else {
518
518
  // Status too long - truncate it
519
- const truncated = truncateToWidth(content, topFillWidth - 1, this.borderColor(this.theme.symbols.ellipsis));
519
+ const truncated = truncateToWidth(content, topFillWidth - 1);
520
520
  const truncatedWidth = visibleWidth(truncated);
521
521
  const fillWidth = Math.max(0, topFillWidth - truncatedWidth);
522
522
  result.push(topLeft + truncated + this.borderColor(box.horizontal.repeat(fillWidth)) + topRight);
@@ -1,7 +1,7 @@
1
1
  import { matchesKey } from "../keys";
2
2
  import type { SymbolTheme } from "../symbols";
3
3
  import type { Component } from "../tui";
4
- import { padding, truncateToWidth, visibleWidth } from "../utils";
4
+ import { Ellipsis, padding, truncateToWidth, visibleWidth } from "../utils";
5
5
 
6
6
  export interface SelectItem {
7
7
  value: string;
@@ -83,7 +83,7 @@ export class SelectList implements Component {
83
83
  if (item.description && width > 40) {
84
84
  // Calculate how much space we have for value + description
85
85
  const maxValueWidth = Math.min(30, width - prefixWidth - 4);
86
- const truncatedValue = truncateToWidth(displayValue, maxValueWidth, "");
86
+ const truncatedValue = truncateToWidth(displayValue, maxValueWidth, Ellipsis.Omit);
87
87
  const spacing = padding(Math.max(1, 32 - truncatedValue.length));
88
88
 
89
89
  // Calculate remaining space for description using visible widths
@@ -91,18 +91,18 @@ export class SelectList implements Component {
91
91
  const remainingWidth = width - descriptionStart - 2; // -2 for safety
92
92
 
93
93
  if (remainingWidth > 10) {
94
- const truncatedDesc = truncateToWidth(item.description, remainingWidth, "");
94
+ const truncatedDesc = truncateToWidth(item.description, remainingWidth, Ellipsis.Omit);
95
95
  // Apply selectedText to entire line content
96
96
  line = this.theme.selectedText(`${prefix}${truncatedValue}${spacing}${truncatedDesc}`);
97
97
  } else {
98
98
  // Not enough space for description
99
99
  const maxWidth = width - prefixWidth - 2;
100
- line = this.theme.selectedText(`${prefix}${truncateToWidth(displayValue, maxWidth, "")}`);
100
+ line = this.theme.selectedText(`${prefix}${truncateToWidth(displayValue, maxWidth, Ellipsis.Omit)}`);
101
101
  }
102
102
  } else {
103
103
  // No description or not enough width
104
104
  const maxWidth = width - prefixWidth - 2;
105
- line = this.theme.selectedText(`${prefix}${truncateToWidth(displayValue, maxWidth, "")}`);
105
+ line = this.theme.selectedText(`${prefix}${truncateToWidth(displayValue, maxWidth, Ellipsis.Omit)}`);
106
106
  }
107
107
  } else {
108
108
  const displayValue = item.label || item.value;
@@ -111,7 +111,7 @@ export class SelectList implements Component {
111
111
  if (item.description && width > 40) {
112
112
  // Calculate how much space we have for value + description
113
113
  const maxValueWidth = Math.min(30, width - prefix.length - 4);
114
- const truncatedValue = truncateToWidth(displayValue, maxValueWidth, "");
114
+ const truncatedValue = truncateToWidth(displayValue, maxValueWidth, Ellipsis.Omit);
115
115
  const spacing = padding(Math.max(1, 32 - truncatedValue.length));
116
116
 
117
117
  // Calculate remaining space for description
@@ -119,18 +119,18 @@ export class SelectList implements Component {
119
119
  const remainingWidth = width - descriptionStart - 2; // -2 for safety
120
120
 
121
121
  if (remainingWidth > 10) {
122
- const truncatedDesc = truncateToWidth(item.description, remainingWidth, "");
122
+ const truncatedDesc = truncateToWidth(item.description, remainingWidth, Ellipsis.Omit);
123
123
  const descText = this.theme.description(spacing + truncatedDesc);
124
124
  line = prefix + truncatedValue + descText;
125
125
  } else {
126
126
  // Not enough space for description
127
127
  const maxWidth = width - prefix.length - 2;
128
- line = prefix + truncateToWidth(displayValue, maxWidth, "");
128
+ line = prefix + truncateToWidth(displayValue, maxWidth, Ellipsis.Omit);
129
129
  }
130
130
  } else {
131
131
  // No description or not enough width
132
132
  const maxWidth = width - prefix.length - 2;
133
- line = prefix + truncateToWidth(displayValue, maxWidth, "");
133
+ line = prefix + truncateToWidth(displayValue, maxWidth, Ellipsis.Omit);
134
134
  }
135
135
  }
136
136
 
@@ -141,7 +141,7 @@ export class SelectList implements Component {
141
141
  if (startIndex > 0 || endIndex < this.filteredItems.length) {
142
142
  const scrollText = ` (${this.selectedIndex + 1}/${this.filteredItems.length})`;
143
143
  // Truncate if too long for terminal
144
- lines.push(this.theme.scrollInfo(truncateToWidth(scrollText, width - 2, "")));
144
+ lines.push(this.theme.scrollInfo(truncateToWidth(scrollText, width - 2, Ellipsis.Omit)));
145
145
  }
146
146
 
147
147
  return lines;
@@ -1,6 +1,6 @@
1
1
  import { matchesKey } from "../keys";
2
2
  import type { Component } from "../tui";
3
- import { padding, truncateToWidth, visibleWidth, wrapTextWithAnsi } from "../utils";
3
+ import { Ellipsis, padding, truncateToWidth, visibleWidth, wrapTextWithAnsi } from "../utils";
4
4
 
5
5
  export interface SettingItem {
6
6
  /** Unique identifier for this setting */
@@ -108,7 +108,10 @@ export class SettingsList implements Component {
108
108
  const usedWidth = prefixWidth + maxLabelWidth + visibleWidth(separator);
109
109
  const valueMaxWidth = width - usedWidth - 2;
110
110
 
111
- const valueText = this.theme.value(truncateToWidth(item.currentValue, valueMaxWidth, ""), isSelected);
111
+ const valueText = this.theme.value(
112
+ truncateToWidth(item.currentValue, valueMaxWidth, Ellipsis.Omit),
113
+ isSelected,
114
+ );
112
115
 
113
116
  lines.push(prefix + labelText + separator + valueText);
114
117
  }
@@ -116,7 +119,7 @@ export class SettingsList implements Component {
116
119
  // Add scroll indicator if needed
117
120
  if (startIndex > 0 || endIndex < this.items.length) {
118
121
  const scrollText = ` (${this.selectedIndex + 1}/${this.items.length})`;
119
- lines.push(this.theme.hint(truncateToWidth(scrollText, width - 2, "")));
122
+ lines.push(this.theme.hint(truncateToWidth(scrollText, width - 2, Ellipsis.Omit)));
120
123
  }
121
124
 
122
125
  // Add description for selected item
package/src/index.ts CHANGED
@@ -85,4 +85,4 @@ export {
85
85
  } from "./terminal-image";
86
86
  export { type Component, Container, type OverlayHandle, type SizeValue, TUI } from "./tui";
87
87
  // Utilities
88
- export { padding, truncateToWidth, visibleWidth, wrapTextWithAnsi } from "./utils";
88
+ export { Ellipsis, padding, truncateToWidth, visibleWidth, wrapTextWithAnsi } from "./utils";
package/src/keys.ts CHANGED
@@ -18,6 +18,8 @@
18
18
  * - isKittyProtocolActive() - Query global Kitty protocol state
19
19
  */
20
20
 
21
+ import { matchesKittySequence } from "@oh-my-pi/pi-natives";
22
+
21
23
  // =============================================================================
22
24
  // Global Kitty Protocol State
23
25
  // =============================================================================
@@ -620,26 +622,6 @@ export function parseKittySequence(data: string): ParsedKittySequence | null {
620
622
  return null;
621
623
  }
622
624
 
623
- function matchesKittySequence(data: string, expectedCodepoint: number, expectedModifier: number): boolean {
624
- const parsed = parseKittySequence(data);
625
- if (!parsed) return false;
626
- const actualMod = parsed.modifier & ~LOCK_MASK;
627
- const expectedMod = expectedModifier & ~LOCK_MASK;
628
-
629
- // Check if modifiers match
630
- if (actualMod !== expectedMod) return false;
631
-
632
- // Primary match: codepoint matches directly
633
- if (parsed.codepoint === expectedCodepoint) return true;
634
-
635
- // Alternate match: use base layout key for non-Latin keyboard layouts
636
- // This allows Ctrl+С (Cyrillic) to match Ctrl+c (Latin) when terminal reports
637
- // the base layout key (the key in standard PC-101 layout)
638
- if (parsed.baseLayoutKey !== undefined && parsed.baseLayoutKey === expectedCodepoint) return true;
639
-
640
- return false;
641
- }
642
-
643
625
  /**
644
626
  * Match xterm modifyOtherKeys format: CSI 27 ; modifiers ; keycode ~
645
627
  * This is used by terminals when Kitty protocol is not enabled.
@@ -666,23 +648,26 @@ function rawCtrlChar(letter: string): string {
666
648
 
667
649
  type ParsedKeyId = { key: string; ctrl: boolean; shift: boolean; alt: boolean };
668
650
 
669
- const PARSED_KEY_ID_CACHE = new Map<string, ParsedKeyId>();
651
+ const PARSED_KEY_ID_CACHE = new Map<string, ParsedKeyId | null>();
670
652
 
671
- function parseKeyId(keyId: string): ParsedKeyId | null {
653
+ function parseKeyIdSlow(keyId: string): ParsedKeyId | null {
672
654
  const normalizedKeyId = keyId.toLowerCase();
673
- const cached = PARSED_KEY_ID_CACHE.get(normalizedKeyId);
674
- if (cached) return cached;
675
-
676
655
  const parts = normalizedKeyId.split("+");
677
656
  const key = parts[parts.length - 1];
678
657
  if (!key) return null;
679
- const parsed = {
658
+ return {
680
659
  key,
681
660
  ctrl: parts.includes("ctrl"),
682
661
  shift: parts.includes("shift"),
683
662
  alt: parts.includes("alt"),
684
663
  };
685
- PARSED_KEY_ID_CACHE.set(normalizedKeyId, parsed);
664
+ }
665
+
666
+ function parseKeyId(keyId: string): ParsedKeyId | null {
667
+ const cached = PARSED_KEY_ID_CACHE.get(keyId);
668
+ if (cached !== undefined) return cached;
669
+ const parsed = parseKeyIdSlow(keyId);
670
+ PARSED_KEY_ID_CACHE.set(keyId, parsed);
686
671
  return parsed;
687
672
  }
688
673
 
package/src/symbols.ts CHANGED
@@ -15,7 +15,6 @@ export interface BoxSymbols {
15
15
  export interface SymbolTheme {
16
16
  cursor: string;
17
17
  inputCursor: string;
18
- ellipsis: string;
19
18
  boxRound: Omit<BoxSymbols, "teeDown" | "teeUp" | "teeLeft" | "teeRight" | "cross">;
20
19
  boxSharp: BoxSymbols;
21
20
  table: BoxSymbols;
package/src/utils.ts CHANGED
@@ -1,9 +1,6 @@
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";
1
+ import { sliceWithWidth } from "@oh-my-pi/pi-natives";
2
+
3
+ export { Ellipsis, extractSegments, sliceWithWidth, truncateToWidth } from "@oh-my-pi/pi-natives";
7
4
 
8
5
  // Pre-allocated space buffer for padding
9
6
  const SPACE_BUFFER = " ".repeat(512);
@@ -28,56 +25,53 @@ export function getSegmenter(): Intl.Segmenter {
28
25
  }
29
26
 
30
27
  // Cache for non-ASCII strings
31
- const WIDTH_CACHE_SIZE = 512;
32
- const widthCache = new Map<string, number>();
33
- const NATIVE_WIDTH_THRESHOLD = 256;
28
+ //const WIDTH_CACHE_SIZE = 512;
29
+ //const widthCache = new Map<string, number>();
34
30
 
35
31
  /**
36
32
  * Calculate the visible width of a string in terminal columns.
37
33
  */
38
- export function visibleWidth(str: string): number {
34
+ export function visibleWidthRaw(str: string): number {
39
35
  if (str.length === 0) {
40
36
  return 0;
41
37
  }
42
38
 
43
39
  // Fast path: pure ASCII printable
44
40
  let isPureAscii = true;
41
+ let tabLength = 0;
45
42
  for (let i = 0; i < str.length; i++) {
46
43
  const code = str.charCodeAt(i);
47
- if (code < 0x20 || code > 0x7e) {
44
+ if (code === 9) {
45
+ tabLength += 3;
46
+ } else if (code < 0x20 || code > 0x7e) {
48
47
  isPureAscii = false;
49
- break;
50
48
  }
51
49
  }
52
50
  if (isPureAscii) {
53
- return str.length;
51
+ return str.length + tabLength;
54
52
  }
53
+ return Bun.stringWidth(str) + tabLength;
54
+ }
55
55
 
56
+ /**
57
+ * Calculate the visible width of a string in terminal columns.
58
+ */
59
+ export function visibleWidth(str: string): number {
60
+ if (str.length === 0) {
61
+ return 0;
62
+ }
63
+ return visibleWidthRaw(str);
64
+
65
+ // === Disabled cache ===
66
+
67
+ /*
56
68
  // Check cache
57
69
  const cached = widthCache.get(str);
58
70
  if (cached !== undefined) {
59
71
  return cached;
60
72
  }
61
73
 
62
- let width: number;
63
- if (str.length <= NATIVE_WIDTH_THRESHOLD) {
64
- // Normalize: tabs to 3 spaces, strip ANSI escape codes
65
- let clean = str;
66
- if (str.includes("\t")) {
67
- clean = clean.replace(/\t/g, " ");
68
- }
69
- if (clean.includes("\x1b")) {
70
- // Strip SGR codes (\x1b[...m) and cursor codes (\x1b[...G/K/H/J)
71
- clean = clean.replace(/\x1b\[[0-9;]*[mGKHJ]/g, "");
72
- // Strip OSC 8 hyperlinks: \x1b]8;;URL\x07 and \x1b]8;;\x07
73
- clean = clean.replace(/\x1b\]8;;[^\x07]*\x07/g, "");
74
- }
75
- width = Bun.stringWidth(clean);
76
- } else {
77
- width = nativeVisibleWidth(str);
78
- }
79
-
80
- // Cache result
74
+ const width = visibleWidthRaw(str);
81
75
  if (widthCache.size >= WIDTH_CACHE_SIZE) {
82
76
  const firstKey = widthCache.keys().next().value;
83
77
  if (firstKey !== undefined) {
@@ -87,39 +81,7 @@ export function visibleWidth(str: string): number {
87
81
  widthCache.set(str, width);
88
82
 
89
83
  return width;
90
- }
91
-
92
- /**
93
- * Extract ANSI escape sequences from a string at the given position.
94
- */
95
- export function extractAnsiCode(str: string, pos: number): { code: string; length: number } | null {
96
- if (pos >= str.length || str[pos] !== "\x1b") return null;
97
-
98
- const next = str[pos + 1];
99
-
100
- // CSI sequence: ESC [ ... m/G/K/H/J
101
- if (next === "[") {
102
- let j = pos + 2;
103
- while (j < str.length && !/[mGKHJ]/.test(str[j]!)) j++;
104
- if (j < str.length) return { code: str.substring(pos, j + 1), length: j + 1 - pos };
105
- return null;
106
- }
107
-
108
- // OSC sequence: ESC ] ... BEL or ESC ] ... ST (ESC \)
109
- // Used for hyperlinks (OSC 8), window titles, etc.
110
- if (next === "]") {
111
- let j = pos + 2;
112
- while (j < str.length) {
113
- if (str[j] === "\x07") return { code: str.substring(pos, j + 1), length: j + 1 - pos };
114
- if (str[j] === "\x1b" && str[j + 1] === "\\") {
115
- return { code: str.substring(pos, j + 2), length: j + 2 - pos };
116
- }
117
- j++;
118
- }
119
- return null;
120
- }
121
-
122
- return null;
84
+ */
123
85
  }
124
86
 
125
87
  const WRAP_OPTIONS = { wordWrap: true, hard: true, trim: false } as const;
@@ -173,21 +135,6 @@ export function applyBackgroundToLine(line: string, width: number, bgFn: (text:
173
135
  return bgFn(withPadding);
174
136
  }
175
137
 
176
- /**
177
- * Truncate text to fit within a maximum visible width, adding ellipsis if needed.
178
- * Optionally pad with spaces to reach exactly maxWidth.
179
- * Properly handles ANSI escape codes (they don't count toward width).
180
- *
181
- * @param text - Text to truncate (may contain ANSI codes)
182
- * @param maxWidth - Maximum visible width
183
- * @param ellipsis - Ellipsis string to append when truncating (default: "…")
184
- * @param pad - If true, pad result with spaces to exactly maxWidth (default: false)
185
- * @returns Truncated text, optionally padded to exactly maxWidth
186
- */
187
- export function truncateToWidth(text: string, maxWidth: number, ellipsis: string = "…", pad: boolean = false): string {
188
- return nativeTruncateToWidth(text, maxWidth, ellipsis, pad);
189
- }
190
-
191
138
  /**
192
139
  * Extract a range of visible columns from a line. Handles ANSI codes and wide chars.
193
140
  * @param strict - If true, exclude wide chars at boundary that would extend past the range
@@ -195,29 +142,3 @@ export function truncateToWidth(text: string, maxWidth: number, ellipsis: string
195
142
  export function sliceByColumn(line: string, startCol: number, length: number, strict = false): string {
196
143
  return sliceWithWidth(line, startCol, length, strict).text;
197
144
  }
198
-
199
- /** Like sliceByColumn but also returns the actual visible width of the result. */
200
- export function sliceWithWidth(
201
- line: string,
202
- startCol: number,
203
- length: number,
204
- strict = false,
205
- ): { text: string; width: number } {
206
- if (length <= 0) return { text: "", width: 0 };
207
- return nativeSliceWithWidth(line, startCol, length, strict);
208
- }
209
-
210
- /**
211
- * Extract "before" and "after" segments from a line in a single pass.
212
- * Used for overlay compositing where we need content before and after the overlay region.
213
- * Preserves styling from before the overlay that should affect content after it.
214
- */
215
- export function extractSegments(
216
- line: string,
217
- beforeEnd: number,
218
- afterStart: number,
219
- afterLen: number,
220
- strictAfter = false,
221
- ): { before: string; beforeWidth: number; after: string; afterWidth: number } {
222
- return nativeExtractSegments(line, beforeEnd, afterStart, afterLen, strictAfter);
223
- }