@oxecli/oxe 1.0.115 → 1.0.116

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 (2) hide show
  1. package/dist/ui.js +115 -32
  2. package/package.json +1 -1
package/dist/ui.js CHANGED
@@ -140,11 +140,85 @@ export const ANSI_RE = /\x1b\[[0-9;]*[a-zA-Z]|\x1b\].*?\x07|\x1b[()][AB012]|\x1b
140
140
  export function stripAnsi(s) {
141
141
  return s.replace(ANSI_RE, "");
142
142
  }
143
+ // ---------------------------------------------------------------------------
144
+ // Display width (wcwidth-style) helpers
145
+ //
146
+ // plainLen() historically counted JS characters, but wide characters (CJK,
147
+ // fullwidth forms, emoji) occupy two terminal columns while counting as one
148
+ // string character. That made table columns, panel borders, wrapping and
149
+ // truncation overflow by the number of wide glyphs. These helpers measure
150
+ // real terminal display width so every frame stays aligned.
151
+ // ---------------------------------------------------------------------------
152
+ /** Wide (double-width) code-point ranges. */
153
+ const WIDE_RANGES = [
154
+ [0x1100, 0x115f], // Hangul Jamo
155
+ [0x2e80, 0xa4cf], // CJK radicals, Kangxi, CJK symbols, Hiragana/Katakana, Yi
156
+ [0xac00, 0xd7a3], // Hangul syllables
157
+ [0xf900, 0xfaff], // CJK compatibility
158
+ [0xfe30, 0xfe4f], // CJK compatibility forms
159
+ [0xff00, 0xff60], // fullwidth forms
160
+ [0xffe0, 0xffe6], // fullwidth signs
161
+ [0x1f300, 0x1f64f], // emoji / misc pictographs
162
+ [0x1f680, 0x1f6ff], // transport & map symbols
163
+ [0x1f900, 0x1f9ff], // supplemental symbols & pictographs
164
+ [0x1fa70, 0x1faff], // symbols & pictographs extended-A
165
+ [0x20000, 0x3fffd], // CJK extension B+ (astral)
166
+ ];
167
+ const WIDE_POINTS = new Set([
168
+ 0x231a, 0x231b, 0x23e9, 0x23ea, 0x23eb, 0x23ec, 0x23f0, 0x23f3, 0x25fd,
169
+ 0x25fe, 0x2614, 0x2615, 0x2648, 0x2649, 0x264a, 0x264b, 0x264c, 0x264d,
170
+ 0x264e, 0x264f, 0x2650, 0x2651, 0x2652, 0x2653, 0x267f, 0x2693, 0x26a1,
171
+ 0x26aa, 0x26ab, 0x26bd, 0x26be, 0x26c4, 0x26c5, 0x26ce, 0x26d4, 0x26ea,
172
+ 0x26f2, 0x26f3, 0x26f5, 0x26fa, 0x26fd, 0x2705, 0x270a, 0x270b, 0x2728,
173
+ 0x274c, 0x274e, 0x2753, 0x2754, 0x2755, 0x2757, 0x2795, 0x2796, 0x2797,
174
+ 0x27b0, 0x27bf, 0x2b1b, 0x2b1c, 0x2b50, 0x2b55, 0x2329, 0x232a, 0x303f,
175
+ ]);
176
+ /** Terminal columns occupied by a single character (0 combining, 1 normal,
177
+ * 2 wide). */
178
+ export function charWidth(ch) {
179
+ const c = ch.codePointAt(0);
180
+ if (c == null)
181
+ return 0;
182
+ // Combining marks occupy no columns of their own.
183
+ if ((c >= 0x0300 && c <= 0x036f) ||
184
+ (c >= 0x1ab0 && c <= 0x1aff) ||
185
+ (c >= 0x1dc0 && c <= 0x1dff) ||
186
+ (c >= 0x20d0 && c <= 0x20ff) ||
187
+ (c >= 0xfe20 && c <= 0xfe2f))
188
+ return 0;
189
+ for (const [lo, hi] of WIDE_RANGES)
190
+ if (c >= lo && c <= hi)
191
+ return 2;
192
+ if (WIDE_POINTS.has(c))
193
+ return 2;
194
+ return 1;
195
+ }
196
+ /** Terminal display width of a plain (ANSI-stripped) string. */
197
+ export function dispLen(s) {
198
+ let w = 0;
199
+ for (const ch of s)
200
+ w += charWidth(ch);
201
+ return w;
202
+ }
143
203
  export function plainLen(s) {
144
- return stripAnsi(s).length;
204
+ return dispLen(stripAnsi(s));
145
205
  }
146
- // Truncate styled text to a max number of visible characters while keeping
147
- // ANSI escape sequences intact (never cut mid-sequence).
206
+ /** Take as many characters as fit within maxW terminal columns, never cutting a
207
+ * wide glyph in half. */
208
+ function takeVisible(s, maxW) {
209
+ let w = 0;
210
+ let out = "";
211
+ for (const ch of s) {
212
+ const cw = charWidth(ch);
213
+ if (w + cw > maxW)
214
+ break;
215
+ out += ch;
216
+ w += cw;
217
+ }
218
+ return out;
219
+ }
220
+ // Truncate styled text to a max number of visible terminal columns while
221
+ // keeping ANSI escape sequences intact (never cut mid-sequence).
148
222
  export function truncateStyled(text, maxVisible) {
149
223
  if (maxVisible <= 0)
150
224
  return "";
@@ -159,11 +233,9 @@ export function truncateStyled(text, maxVisible) {
159
233
  while ((m = re.exec(text)) !== null) {
160
234
  if (m.index > last) {
161
235
  const seg = text.slice(last, m.index);
162
- const room = maxVisible - visible;
163
- if (room <= 0)
164
- return out;
165
- out += seg.slice(0, room);
166
- visible += Math.min(seg.length, room);
236
+ const taken = takeVisible(seg, maxVisible - visible);
237
+ out += taken;
238
+ visible += dispLen(taken);
167
239
  if (visible >= maxVisible)
168
240
  return out;
169
241
  }
@@ -171,7 +243,7 @@ export function truncateStyled(text, maxVisible) {
171
243
  last = m.index + m[0].length;
172
244
  }
173
245
  if (visible < maxVisible) {
174
- out += text.slice(last, last + (maxVisible - visible));
246
+ out += takeVisible(text.slice(last), maxVisible - visible);
175
247
  }
176
248
  return out;
177
249
  }
@@ -234,11 +306,11 @@ function wrapStyledToWidth(line, width) {
234
306
  const isSpace = /\s/.test(a.ch);
235
307
  if (isSpace) {
236
308
  cur.push(a);
237
- curLen++;
309
+ curLen += charWidth(a.ch);
238
310
  breakAt = cur.length - 1;
239
311
  continue;
240
312
  }
241
- if (curLen + 1 > width) {
313
+ if (curLen + charWidth(a.ch) > width) {
242
314
  if (breakAt >= 0) {
243
315
  // Break after the whitespace at breakAt: keep the whitespace's ANSI
244
316
  // prefix (often a reset) so style never leaks onto the continuation.
@@ -260,7 +332,7 @@ function wrapStyledToWidth(line, width) {
260
332
  }
261
333
  }
262
334
  cur.push(a);
263
- curLen++;
335
+ curLen += charWidth(a.ch);
264
336
  }
265
337
  if (cur.length)
266
338
  rows.push(cur);
@@ -565,7 +637,7 @@ function panelString(content, title = "", subtitle = "", expand = true, borderSt
565
637
  const plainLines = styledLines.map((l) => stripAnsi(l));
566
638
  let maxLineW = 0;
567
639
  for (const p of plainLines)
568
- maxLineW = Math.max(maxLineW, p.length);
640
+ maxLineW = Math.max(maxLineW, plainLen(p));
569
641
  if (title)
570
642
  maxLineW = Math.max(maxLineW, plainLen(title) + 4);
571
643
  if (subtitle)
@@ -599,8 +671,8 @@ function panelString(content, title = "", subtitle = "", expand = true, borderSt
599
671
  for (let i = 0; i < styledLines.length; i++) {
600
672
  const line = styledLines[i];
601
673
  const plain = plainLines[i] ?? "";
602
- const pad = Math.max(innerW - plain.length, 0);
603
- const display = plain.length > innerW ? truncateStyled(line, innerW) : line;
674
+ const pad = Math.max(innerW - plainLen(plain), 0);
675
+ const display = plainLen(plain) > innerW ? truncateStyled(line, innerW) : line;
604
676
  out.push(`\x1b[${borderStyle}m│\x1b[0m ${display}${" ".repeat(pad)} \x1b[${borderStyle}m│\x1b[0m`);
605
677
  }
606
678
  const bottom = `╰${embed(subtitle, "center")}╯`;
@@ -715,7 +787,7 @@ export function displayRows(text) {
715
787
  if (!lines.length)
716
788
  return 1;
717
789
  for (const line of lines) {
718
- rows += Math.max(1, Math.ceil(line.length / width));
790
+ rows += Math.max(1, Math.ceil(dispLen(line) / width));
719
791
  }
720
792
  return rows;
721
793
  }
@@ -1084,12 +1156,12 @@ export class Spinner {
1084
1156
  const first = parts[0];
1085
1157
  const rest = parts.slice(1);
1086
1158
  const plain = stripAnsi(first);
1087
- const pad = Math.max(0, this.lastLen - plain.length);
1159
+ const pad = Math.max(0, this.lastLen - plainLen(plain));
1088
1160
  process.stdout.write("\r" + first + " ".repeat(pad) + "\r\n");
1089
1161
  if (rest.length)
1090
1162
  process.stdout.write(rest.join("\n") + "\n");
1091
1163
  this.rows = 0;
1092
- this.lastLen = plain.length;
1164
+ this.lastLen = plainLen(plain);
1093
1165
  }
1094
1166
  update(text) {
1095
1167
  if (this.timer) {
@@ -1113,9 +1185,9 @@ export class Spinner {
1113
1185
  // cells (spinner frame, trailing timer) actually change on screen. Pad
1114
1186
  // with spaces so a shorter line clears any previously longer text.
1115
1187
  const plain = stripAnsi(rendered);
1116
- const pad = Math.max(0, this.lastLen - plain.length);
1188
+ const pad = Math.max(0, this.lastLen - plainLen(plain));
1117
1189
  process.stdout.write("\r" + rendered + " ".repeat(pad) + "\r");
1118
- this.lastLen = plain.length;
1190
+ this.lastLen = plainLen(plain);
1119
1191
  }
1120
1192
  else {
1121
1193
  // Multi-row wrapped text: fall back to an erase-based redraw.
@@ -1379,7 +1451,7 @@ function wrapRuns(runs, width) {
1379
1451
  caretCol = lineLen;
1380
1452
  }
1381
1453
  line.push(u);
1382
- lineLen += 1;
1454
+ lineLen += charWidth(u.ch);
1383
1455
  };
1384
1456
  let i = 0;
1385
1457
  while (i < units.length) {
@@ -1398,7 +1470,8 @@ function wrapRuns(runs, width) {
1398
1470
  continue;
1399
1471
  }
1400
1472
  if (isWs(u)) {
1401
- if (lineLen + 1 > width)
1473
+ const w = charWidth(u.ch);
1474
+ if (lineLen + w > width)
1402
1475
  flushLine();
1403
1476
  // Never start a line with whitespace: if the space lands at the start of
1404
1477
  // a fresh line, drop it (it was the fill of the previous full line).
@@ -1413,28 +1486,38 @@ function wrapRuns(runs, width) {
1413
1486
  // Start of a word (which may contain the caret). Find its full extent in
1414
1487
  // the flattened stream so we never wrap mid-word.
1415
1488
  let j = i;
1416
- let wordLen = 0; // each unit (including a caret) occupies one column
1489
+ let wordW = 0; // display width of the whole word
1417
1490
  while (j < units.length && !isWs(units[j])) {
1418
- wordLen += 1;
1491
+ wordW += charWidth(units[j].ch);
1419
1492
  j++;
1420
1493
  }
1421
- if (wordLen === 0) {
1494
+ if (wordW === 0) {
1422
1495
  i++;
1423
1496
  continue;
1424
1497
  }
1425
1498
  if (lineLen === 0) {
1426
- if (wordLen > width) {
1499
+ if (wordW > width) {
1427
1500
  // Overlong word: split into width-sized chunks, keeping the caret in
1428
1501
  // the chunk that contains it.
1429
1502
  let from = i;
1430
- let remaining = wordLen;
1431
- while (remaining > 0) {
1432
- const n = Math.min(width, remaining);
1503
+ let used = 0;
1504
+ while (used < wordW) {
1505
+ let n = 0;
1506
+ let chunkW = 0;
1507
+ while (from + n < j && chunkW + charWidth(units[from + n].ch) <= width) {
1508
+ chunkW += charWidth(units[from + n].ch);
1509
+ n++;
1510
+ }
1511
+ if (n === 0) {
1512
+ // A single glyph is wider than the line; emit it alone.
1513
+ n = 1;
1514
+ chunkW = charWidth(units[from].ch);
1515
+ }
1433
1516
  for (let k = 0; k < n; k++)
1434
1517
  addUnit(units[from + k]);
1435
1518
  flushLine();
1436
1519
  from += n;
1437
- remaining -= n;
1520
+ used += chunkW;
1438
1521
  }
1439
1522
  }
1440
1523
  else {
@@ -1444,7 +1527,7 @@ function wrapRuns(runs, width) {
1444
1527
  i = j;
1445
1528
  }
1446
1529
  else {
1447
- if (lineLen + wordLen > width) {
1530
+ if (lineLen + wordW > width) {
1448
1531
  // Word doesn't fit on the current line; move the whole word to a new
1449
1532
  // line (leaving any trailing space from the previous word behind).
1450
1533
  flushLine();
@@ -1565,7 +1648,7 @@ export function renderBufferWithCursor(buffer, pasteSpans, prefix, label, cursor
1565
1648
  const body = [];
1566
1649
  for (const l of rows) {
1567
1650
  const plain = stripAnsi(l);
1568
- const pad = Math.max(0, innerW - plain.length);
1651
+ const pad = Math.max(0, innerW - plainLen(plain));
1569
1652
  body.push(`\x1b[90m│\x1b[0m ${l}${" ".repeat(pad)} \x1b[90m│\x1b[0m`);
1570
1653
  }
1571
1654
  const bottom = `\x1b[90m╰${"─".repeat(Math.max(0, boxW - 2))}╯\x1b[0m`;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oxecli/oxe",
3
- "version": "1.0.115",
3
+ "version": "1.0.116",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },