@quandev104/pi-style 0.2.1 → 0.2.2

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 (27) hide show
  1. package/CHANGELOG.md +10 -0
  2. package/README.md +1 -1
  3. package/dist/extensions/pi-style.js +632 -262
  4. package/dist/extensions/pi-style.js.map +1 -1
  5. package/extension-src/pi-style/app/runtime.ts +29 -4
  6. package/extension-src/pi-style/domain/status-renderer.ts +40 -8
  7. package/extension-src/pi-style/domain/status.ts +15 -5
  8. package/extension-src/pi-style/domain/theme.ts +32 -1
  9. package/extension-src/pi-style/features/editor/index.ts +17 -5
  10. package/extension-src/pi-style/features/messages/index.ts +302 -61
  11. package/extension-src/pi-style/features/status-line/index.ts +41 -11
  12. package/extension-src/pi-style/features/tools/boxed/bash.ts +101 -40
  13. package/extension-src/pi-style/features/tools/boxed/batch.ts +17 -1
  14. package/extension-src/pi-style/features/tools/boxed/edit.ts +20 -15
  15. package/extension-src/pi-style/features/tools/boxed/find.ts +9 -4
  16. package/extension-src/pi-style/features/tools/boxed/git.ts +46 -2
  17. package/extension-src/pi-style/features/tools/boxed/grep.ts +9 -2
  18. package/extension-src/pi-style/features/tools/boxed/ls.ts +9 -4
  19. package/extension-src/pi-style/features/tools/boxed/quick-edit.ts +18 -11
  20. package/extension-src/pi-style/features/tools/boxed/read.ts +4 -2
  21. package/extension-src/pi-style/features/tools/boxed/shared.ts +27 -1
  22. package/extension-src/pi-style/features/tools/boxed/turn-summary.ts +12 -0
  23. package/extension-src/pi-style/pi/index.ts +2 -0
  24. package/extension-src/pi-style/shared/ansi.ts +17 -5
  25. package/extension-src/pi-style/shared/box.ts +70 -4
  26. package/extension-src/pi-style/shared/split-diff.ts +8 -5
  27. package/package.json +1 -1
@@ -1,3 +1,5 @@
1
+ import { visibleWidth as tuiVisibleWidth } from "@earendil-works/pi-tui";
2
+
1
3
  function isFinal(byte: string): boolean {
2
4
  return byte >= "@" && byte <= "~";
3
5
  }
@@ -91,8 +93,12 @@ export function stripAnsi(value: string): string {
91
93
  }
92
94
  return output;
93
95
  }
96
+ /**
97
+ * Terminal-correct visible width: delegates to pi-tui (ANSI-stripping,
98
+ * ASCII fast path, per-string cache, wide chars = 2 columns, tabs = 3).
99
+ */
94
100
  export function visibleWidth(value: string): number {
95
- return [...stripAnsi(value)].length;
101
+ return tuiVisibleWidth(value);
96
102
  }
97
103
  export function resetAnsi(value: string): string {
98
104
  return `${value}\x1b[0m`;
@@ -105,9 +111,10 @@ export function fitAnsiWidth(value: string, width: number, ellipsis = "…"): st
105
111
  export function truncateAnsi(value: string, width: number, ellipsis = "…"): string {
106
112
  if (width <= 0) return "";
107
113
  if (visibleWidth(value) <= width) return resetAnsi(value);
114
+ const ellipsisWidth = visibleWidth(ellipsis);
108
115
  let output = "";
109
116
  let visible = 0;
110
- for (let i = 0; i < value.length && visible < width - visibleWidth(ellipsis); i++) {
117
+ for (let i = 0; i < value.length && visible < width - ellipsisWidth; i++) {
111
118
  if (value.charCodeAt(i) === 27) {
112
119
  const start = i;
113
120
  i++;
@@ -125,12 +132,17 @@ export function wrapAnsi(value: string, width: number): string[] {
125
132
  if (width <= 0) return [""];
126
133
  const lines: string[] = [];
127
134
  let line = "";
135
+ let lineWidth = 0;
128
136
  for (const word of value.split(/\s+/)) {
129
- const next = line ? `${line} ${word}` : word;
130
- if (visibleWidth(next) <= width) line = next;
131
- else {
137
+ const wordWidth = visibleWidth(word);
138
+ const nextWidth = line ? lineWidth + 1 + wordWidth : wordWidth;
139
+ if (nextWidth <= width) {
140
+ line = line ? `${line} ${word}` : word;
141
+ lineWidth = nextWidth;
142
+ } else {
132
143
  if (line) lines.push(resetAnsi(line));
133
144
  line = truncateAnsi(word, width);
145
+ lineWidth = visibleWidth(line);
134
146
  }
135
147
  }
136
148
  if (line || lines.length === 0) lines.push(resetAnsi(line));
@@ -149,12 +149,63 @@ export function countLines(text: string): number {
149
149
  return normalized.split("\n").length;
150
150
  }
151
151
 
152
+ // Word-character membership table powering countWords. ASCII and the UTF-16
153
+ // surrogate range are initialized eagerly; every other BMP code point is
154
+ // resolved through the Unicode letter/digit class on first sight and memoized,
155
+ // so repeat scans are pure table lookups. The per-code-point regex dispatch
156
+ // this replaces measured ~1.9ms per 90KB output on every boxed footer render;
157
+ // note that String.match with a \p{L}\p{N} class is no faster on V8 — the
158
+ // memoized scan is the only variant that hit the <0.2ms budget.
159
+ const WORD_CP_UNKNOWN = 255;
160
+ const WORD_CP_SURROGATE = 254;
161
+ const WORD_CP_CLASS = new Uint8Array(0x10000).fill(WORD_CP_UNKNOWN);
162
+ for (let code = 0x30; code <= 0x39; code++) WORD_CP_CLASS[code] = 1; // 0-9
163
+ for (let code = 0x41; code <= 0x5a; code++) WORD_CP_CLASS[code] = 1; // A-Z
164
+ for (let code = 0x61; code <= 0x7a; code++) WORD_CP_CLASS[code] = 1; // a-z
165
+ WORD_CP_CLASS[0x27] = 1; // '
166
+ WORD_CP_CLASS[0x2d] = 1; // -
167
+ WORD_CP_CLASS[0x5f] = 1; // _
168
+ WORD_CP_CLASS.fill(WORD_CP_SURROGATE, 0xd800, 0xe000);
169
+ const NON_ASCII_WORD_RE = /[\p{L}\p{N}]/u;
170
+ const ASTRAL_WORD_CP = new Map<number, 0 | 1>();
171
+
172
+ function astralWordMembership(codePoint: number): 0 | 1 {
173
+ const cached = ASTRAL_WORD_CP.get(codePoint);
174
+ if (cached !== undefined) return cached;
175
+ const membership: 0 | 1 = NON_ASCII_WORD_RE.test(String.fromCodePoint(codePoint)) ? 1 : 0;
176
+ ASTRAL_WORD_CP.set(codePoint, membership);
177
+ return membership;
178
+ }
179
+
180
+ /** Counts words as maximal runs of word characters (letters, digits,
181
+ * underscore, apostrophe, hyphen) — identical counts to a per-code-point
182
+ * `\p{L}\p{N}_'-` class test, in one table-driven pass with no allocations. */
152
183
  export function countWords(text: string): number {
184
+ const len = text.length;
153
185
  let count = 0;
154
- let inWord = false;
155
- for (const char of text) {
156
- const isWord = /[\p{L}\p{N}_'-]/u.test(char);
157
- if (isWord && !inWord) count++;
186
+ let inWord: number = 0;
187
+ for (let i = 0; i < len; i++) {
188
+ const code = text.charCodeAt(i);
189
+ const membership = WORD_CP_CLASS[code] ?? 0;
190
+ let isWord: number;
191
+ if (membership <= 1) {
192
+ isWord = membership;
193
+ } else if (membership === WORD_CP_SURROGATE) {
194
+ const next = i + 1 < len ? text.charCodeAt(i + 1) : 0;
195
+ if (code <= 0xdbff && next >= 0xdc00 && next <= 0xdfff) {
196
+ // Astral word characters (e.g. mathematical alphanumerics) count as
197
+ // one code point, exactly like the previous code-point iteration.
198
+ isWord = astralWordMembership(0x10000 + ((code - 0xd800) << 10) + (next - 0xdc00));
199
+ i++; // consumed the pair's low surrogate half
200
+ } else {
201
+ isWord = 0; // lone surrogate half: never a word character
202
+ }
203
+ } else {
204
+ // First sight of this non-ASCII BMP code point: resolve once, memoize.
205
+ isWord = NON_ASCII_WORD_RE.test(String.fromCharCode(code)) ? 1 : 0;
206
+ WORD_CP_CLASS[code] = isWord;
207
+ }
208
+ count += isWord & (inWord ^ 1);
158
209
  inWord = isWord;
159
210
  }
160
211
  return count;
@@ -210,6 +261,9 @@ const BOX_DIVIDER_RIGHT = "┤";
210
261
  /** Dash run before the right corner when a right-side border label is present. */
211
262
  const BOX_LABELED_RIGHT_DASH_MIN = 3;
212
263
  const BOX_WIDTH_CACHE = new Map<string, number>();
264
+ /** Hard cap for BOX_WIDTH_CACHE; the oldest entry is evicted beyond this. Keys
265
+ * embed full bash commands, so the cache must stay bounded across a session. */
266
+ const BOX_WIDTH_CACHE_MAX_ENTRIES = 512;
213
267
 
214
268
  export function boxWidth(width: number): number {
215
269
  return Math.max(BOX_MIN_WIDTH, width);
@@ -236,6 +290,13 @@ function _tightBoxWidth(
236
290
  if (!widthKey) return measuredWidth;
237
291
  const cachedWidth = BOX_WIDTH_CACHE.get(widthKey) ?? 0;
238
292
  const nextWidth = Math.min(boxWidth(availableWidth), Math.max(cachedWidth, measuredWidth));
293
+ // Bounded LRU: Map preserves insertion order, so delete+set refreshes the
294
+ // key's recency and the first key is the oldest evict candidate.
295
+ BOX_WIDTH_CACHE.delete(widthKey);
296
+ if (BOX_WIDTH_CACHE.size >= BOX_WIDTH_CACHE_MAX_ENTRIES) {
297
+ const oldestKey = BOX_WIDTH_CACHE.keys().next().value;
298
+ if (oldestKey !== undefined) BOX_WIDTH_CACHE.delete(oldestKey);
299
+ }
239
300
  BOX_WIDTH_CACHE.set(widthKey, nextWidth);
240
301
  return nextWidth;
241
302
  }
@@ -244,6 +305,11 @@ export function boxedToolWidthKey(toolName: string, detail: string): string {
244
305
  return `${toolName}:${detail}`;
245
306
  }
246
307
 
308
+ /** Test-only debug view of the bounded box width cache. */
309
+ export function __getBoxWidthCacheDebugState(): { size: number } {
310
+ return { size: BOX_WIDTH_CACHE.size };
311
+ }
312
+
247
313
  export function formatToolName(toolName: string): string {
248
314
  const spaced = toolName
249
315
  .replace(/[_-]+/g, " ")
@@ -56,7 +56,13 @@ type DiffEntry =
56
56
 
57
57
  const ESC = "\x1b";
58
58
  const BG_ANSI_PATTERN = new RegExp(`${ESC}\\[(?:4\\d|10\\d|48;5;\\d{1,3}|48;2;\\d{1,3};\\d{1,3};\\d{1,3}|49)m`, "g");
59
+ /** Any SGR escape (capture: parameter bytes). Shared with String.replace only —
60
+ * replace resets lastIndex, so the global flag is safe here. */
61
+ const ANSI_SGR_PATTERN = new RegExp(`${ESC}\\[([0-9;]*)m`, "g");
59
62
  const CONTROL_CHARS = "\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F";
63
+ /** Control characters (minus \r/\n, handled separately) stripped from diff
64
+ * text. Replace-only usage, so the global flag is safe (see above). */
65
+ const CONTROL_CHARS_PATTERN = new RegExp(`[${CONTROL_CHARS}]`, "g");
60
66
 
61
67
  const ADD_ROW_BACKGROUND_MIX_RATIO = 0.24;
62
68
  const REMOVE_ROW_BACKGROUND_MIX_RATIO = 0.12;
@@ -173,7 +179,7 @@ function resolveDiffPalette(theme: SplitDiffTheme): DiffPalette {
173
179
  function keepBackgroundAcrossResets(text: string, rowBgAnsi: string): string {
174
180
  if (!text) return text;
175
181
 
176
- return text.replace(new RegExp(`${ESC}\\[([0-9;]*)m`, "g"), (sequence, rawCodes) => {
182
+ return text.replace(ANSI_SGR_PATTERN, (sequence, rawCodes) => {
177
183
  const split = String(rawCodes ?? "")
178
184
  .split(";")
179
185
  .filter(Boolean);
@@ -233,10 +239,7 @@ function applyBackgroundToVisibleRange(
233
239
  // ── Text utilities ─────────────────────────────────────────────────
234
240
 
235
241
  function sanitizeSingleLineText(value: string): string {
236
- return value
237
- .replace(/\r/g, "")
238
- .replace(/\n/g, "")
239
- .replace(new RegExp(`[${CONTROL_CHARS}]`, "g"), "");
242
+ return value.replace(/\r/g, "").replace(/\n/g, "").replace(CONTROL_CHARS_PATTERN, "");
240
243
  }
241
244
 
242
245
  function stripInlineBreaksPreserveAnsi(value: string): string {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@quandev104/pi-style",
3
- "version": "0.2.1",
3
+ "version": "0.2.2",
4
4
  "description": "A native-layout, cohesive visual style package for Pi.",
5
5
  "license": "MIT",
6
6
  "type": "module",