@oxecli/oxe 1.0.114 → 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 +227 -74
  2. package/package.json +1 -1
package/dist/ui.js CHANGED
@@ -116,8 +116,10 @@ export function clearScreen() {
116
116
  export function terminalWidth() {
117
117
  return process.stdout.columns || 80;
118
118
  }
119
- /** Minimum width for boxes/cards so they don't collapse to nothing. */
120
- const MIN_BOX_WIDTH = 52;
119
+ /** Minimum width for boxes/cards so they don't collapse to nothing. Kept
120
+ * well below the smallest terminal width so a card can keep scaling down
121
+ * (it never forces a box wider than the terminal). */
122
+ const MIN_BOX_WIDTH = 30;
121
123
  /** Horizontal margin reserved on each side of a box/card from the terminal edge. */
122
124
  const BOX_MARGIN = 4;
123
125
  /**
@@ -138,11 +140,85 @@ export const ANSI_RE = /\x1b\[[0-9;]*[a-zA-Z]|\x1b\].*?\x07|\x1b[()][AB012]|\x1b
138
140
  export function stripAnsi(s) {
139
141
  return s.replace(ANSI_RE, "");
140
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
+ }
141
203
  export function plainLen(s) {
142
- return stripAnsi(s).length;
204
+ return dispLen(stripAnsi(s));
143
205
  }
144
- // Truncate styled text to a max number of visible characters while keeping
145
- // 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).
146
222
  export function truncateStyled(text, maxVisible) {
147
223
  if (maxVisible <= 0)
148
224
  return "";
@@ -157,11 +233,9 @@ export function truncateStyled(text, maxVisible) {
157
233
  while ((m = re.exec(text)) !== null) {
158
234
  if (m.index > last) {
159
235
  const seg = text.slice(last, m.index);
160
- const room = maxVisible - visible;
161
- if (room <= 0)
162
- return out;
163
- out += seg.slice(0, room);
164
- visible += Math.min(seg.length, room);
236
+ const taken = takeVisible(seg, maxVisible - visible);
237
+ out += taken;
238
+ visible += dispLen(taken);
165
239
  if (visible >= maxVisible)
166
240
  return out;
167
241
  }
@@ -169,7 +243,7 @@ export function truncateStyled(text, maxVisible) {
169
243
  last = m.index + m[0].length;
170
244
  }
171
245
  if (visible < maxVisible) {
172
- out += text.slice(last, last + (maxVisible - visible));
246
+ out += takeVisible(text.slice(last), maxVisible - visible);
173
247
  }
174
248
  return out;
175
249
  }
@@ -232,11 +306,11 @@ function wrapStyledToWidth(line, width) {
232
306
  const isSpace = /\s/.test(a.ch);
233
307
  if (isSpace) {
234
308
  cur.push(a);
235
- curLen++;
309
+ curLen += charWidth(a.ch);
236
310
  breakAt = cur.length - 1;
237
311
  continue;
238
312
  }
239
- if (curLen + 1 > width) {
313
+ if (curLen + charWidth(a.ch) > width) {
240
314
  if (breakAt >= 0) {
241
315
  // Break after the whitespace at breakAt: keep the whitespace's ANSI
242
316
  // prefix (often a reset) so style never leaks onto the continuation.
@@ -258,7 +332,7 @@ function wrapStyledToWidth(line, width) {
258
332
  }
259
333
  }
260
334
  cur.push(a);
261
- curLen++;
335
+ curLen += charWidth(a.ch);
262
336
  }
263
337
  if (cur.length)
264
338
  rows.push(cur);
@@ -563,7 +637,7 @@ function panelString(content, title = "", subtitle = "", expand = true, borderSt
563
637
  const plainLines = styledLines.map((l) => stripAnsi(l));
564
638
  let maxLineW = 0;
565
639
  for (const p of plainLines)
566
- maxLineW = Math.max(maxLineW, p.length);
640
+ maxLineW = Math.max(maxLineW, plainLen(p));
567
641
  if (title)
568
642
  maxLineW = Math.max(maxLineW, plainLen(title) + 4);
569
643
  if (subtitle)
@@ -597,8 +671,8 @@ function panelString(content, title = "", subtitle = "", expand = true, borderSt
597
671
  for (let i = 0; i < styledLines.length; i++) {
598
672
  const line = styledLines[i];
599
673
  const plain = plainLines[i] ?? "";
600
- const pad = Math.max(innerW - plain.length, 0);
601
- 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;
602
676
  out.push(`\x1b[${borderStyle}m│\x1b[0m ${display}${" ".repeat(pad)} \x1b[${borderStyle}m│\x1b[0m`);
603
677
  }
604
678
  const bottom = `╰${embed(subtitle, "center")}╯`;
@@ -713,7 +787,7 @@ export function displayRows(text) {
713
787
  if (!lines.length)
714
788
  return 1;
715
789
  for (const line of lines) {
716
- rows += Math.max(1, Math.ceil(line.length / width));
790
+ rows += Math.max(1, Math.ceil(dispLen(line) / width));
717
791
  }
718
792
  return rows;
719
793
  }
@@ -1082,12 +1156,12 @@ export class Spinner {
1082
1156
  const first = parts[0];
1083
1157
  const rest = parts.slice(1);
1084
1158
  const plain = stripAnsi(first);
1085
- const pad = Math.max(0, this.lastLen - plain.length);
1159
+ const pad = Math.max(0, this.lastLen - plainLen(plain));
1086
1160
  process.stdout.write("\r" + first + " ".repeat(pad) + "\r\n");
1087
1161
  if (rest.length)
1088
1162
  process.stdout.write(rest.join("\n") + "\n");
1089
1163
  this.rows = 0;
1090
- this.lastLen = plain.length;
1164
+ this.lastLen = plainLen(plain);
1091
1165
  }
1092
1166
  update(text) {
1093
1167
  if (this.timer) {
@@ -1111,9 +1185,9 @@ export class Spinner {
1111
1185
  // cells (spinner frame, trailing timer) actually change on screen. Pad
1112
1186
  // with spaces so a shorter line clears any previously longer text.
1113
1187
  const plain = stripAnsi(rendered);
1114
- const pad = Math.max(0, this.lastLen - plain.length);
1188
+ const pad = Math.max(0, this.lastLen - plainLen(plain));
1115
1189
  process.stdout.write("\r" + rendered + " ".repeat(pad) + "\r");
1116
- this.lastLen = plain.length;
1190
+ this.lastLen = plainLen(plain);
1117
1191
  }
1118
1192
  else {
1119
1193
  // Multi-row wrapped text: fall back to an erase-based redraw.
@@ -1336,69 +1410,137 @@ export function splitBlocks(buffer, pasteSpans) {
1336
1410
  }
1337
1411
  const PROMPT_CARET = "\u0000";
1338
1412
  function wrapRuns(runs, width) {
1413
+ const units = [];
1414
+ for (const run of runs) {
1415
+ if (run.text === PROMPT_CARET) {
1416
+ units.push({ ch: PROMPT_CARET, style: run.style, caret: true });
1417
+ }
1418
+ else {
1419
+ for (const ch of run.text) {
1420
+ units.push({ ch, style: run.style, caret: false });
1421
+ }
1422
+ }
1423
+ }
1424
+ if (units.length === 0)
1425
+ units.push({ ch: "", style: "", caret: false });
1339
1426
  const rows = [];
1340
- let cur = "";
1341
- let curLen = 0;
1427
+ let line = [];
1428
+ let lineLen = 0;
1342
1429
  let caretRow = -1;
1343
1430
  let caretCol = -1;
1344
- const flush = () => {
1345
- rows.push(cur);
1346
- cur = "";
1347
- curLen = 0;
1431
+ const flushLine = () => {
1432
+ if (line.length === 0)
1433
+ return;
1434
+ let out = "";
1435
+ for (const u of line) {
1436
+ if (u.caret)
1437
+ out += u.style + "▏" + "\x1b[0m";
1438
+ else if (u.style)
1439
+ out += u.style + u.ch + "\x1b[0m";
1440
+ else
1441
+ out += u.ch;
1442
+ }
1443
+ rows.push(out);
1444
+ line = [];
1445
+ lineLen = 0;
1348
1446
  };
1349
- for (const run of runs) {
1350
- if (run.text === PROMPT_CARET) {
1351
- if (curLen >= width)
1352
- flush();
1447
+ const isWs = (u) => !u.caret && /\s/.test(u.ch);
1448
+ const addUnit = (u) => {
1449
+ if (u.caret) {
1353
1450
  caretRow = rows.length;
1354
- caretCol = curLen;
1355
- cur += run.style + "▏" + "\x1b[0m";
1356
- curLen += 1;
1451
+ caretCol = lineLen;
1452
+ }
1453
+ line.push(u);
1454
+ lineLen += charWidth(u.ch);
1455
+ };
1456
+ let i = 0;
1457
+ while (i < units.length) {
1458
+ const u = units[i];
1459
+ if (u.caret) {
1460
+ if (lineLen >= width)
1461
+ flushLine();
1462
+ addUnit(u);
1463
+ i++;
1357
1464
  continue;
1358
1465
  }
1359
- let text = run.text;
1360
- while (text) {
1361
- const nl = text.indexOf("\n");
1362
- const seg = nl === -1 ? text : text.slice(0, nl);
1363
- let rest = seg;
1364
- while (rest.length > 0) {
1365
- if (curLen >= width)
1366
- flush();
1367
- let take;
1368
- if (curLen === 0) {
1369
- // Start of a line: take a whole word (or one char for a lone space,
1370
- // or a full-width chunk for an overlong word).
1371
- const sp = rest.indexOf(" ");
1372
- take = sp === -1 ? rest.length : sp;
1373
- if (take === 0)
1374
- take = 1;
1375
- take = Math.min(take, width);
1376
- }
1377
- else {
1378
- // Mid-line: append the next whole word (plus its trailing space) only
1379
- // if it fits; otherwise move the entire word to the next line.
1380
- const sp = rest.indexOf(" ");
1381
- take = sp === -1 ? rest.length : sp + 1;
1382
- if (take > width - curLen) {
1383
- flush();
1384
- continue;
1466
+ if (u.ch === "\n") {
1467
+ // Hard line break: end the current row and start a fresh one.
1468
+ flushLine();
1469
+ i++;
1470
+ continue;
1471
+ }
1472
+ if (isWs(u)) {
1473
+ const w = charWidth(u.ch);
1474
+ if (lineLen + w > width)
1475
+ flushLine();
1476
+ // Never start a line with whitespace: if the space lands at the start of
1477
+ // a fresh line, drop it (it was the fill of the previous full line).
1478
+ if (lineLen === 0) {
1479
+ i++;
1480
+ continue;
1481
+ }
1482
+ addUnit(u);
1483
+ i++;
1484
+ continue;
1485
+ }
1486
+ // Start of a word (which may contain the caret). Find its full extent in
1487
+ // the flattened stream so we never wrap mid-word.
1488
+ let j = i;
1489
+ let wordW = 0; // display width of the whole word
1490
+ while (j < units.length && !isWs(units[j])) {
1491
+ wordW += charWidth(units[j].ch);
1492
+ j++;
1493
+ }
1494
+ if (wordW === 0) {
1495
+ i++;
1496
+ continue;
1497
+ }
1498
+ if (lineLen === 0) {
1499
+ if (wordW > width) {
1500
+ // Overlong word: split into width-sized chunks, keeping the caret in
1501
+ // the chunk that contains it.
1502
+ let from = i;
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);
1385
1515
  }
1516
+ for (let k = 0; k < n; k++)
1517
+ addUnit(units[from + k]);
1518
+ flushLine();
1519
+ from += n;
1520
+ used += chunkW;
1386
1521
  }
1387
- const chunk = rest.slice(0, take);
1388
- cur += run.style ? run.style + chunk + "\x1b[0m" : chunk;
1389
- curLen += take;
1390
- rest = rest.slice(take);
1391
- }
1392
- if (nl !== -1) {
1393
- flush();
1394
- text = text.slice(nl + 1);
1395
1522
  }
1396
1523
  else {
1397
- break;
1524
+ for (let k = i; k < j; k++)
1525
+ addUnit(units[k]);
1526
+ }
1527
+ i = j;
1528
+ }
1529
+ else {
1530
+ if (lineLen + wordW > width) {
1531
+ // Word doesn't fit on the current line; move the whole word to a new
1532
+ // line (leaving any trailing space from the previous word behind).
1533
+ flushLine();
1534
+ continue;
1398
1535
  }
1536
+ for (let k = i; k < j; k++)
1537
+ addUnit(units[k]);
1538
+ i = j;
1399
1539
  }
1400
1540
  }
1401
- flush();
1541
+ flushLine();
1542
+ if (rows.length === 0)
1543
+ rows.push("");
1402
1544
  return { rows, caretRow, caretCol };
1403
1545
  }
1404
1546
  export function renderBufferWithCursor(buffer, pasteSpans, prefix, label, cursor, hints) {
@@ -1490,12 +1632,23 @@ export function renderBufferWithCursor(buffer, pasteSpans, prefix, label, cursor
1490
1632
  }
1491
1633
  const { rows, caretRow, caretCol } = wrapRuns(runs, innerW);
1492
1634
  const labelLen = plainLen(label);
1493
- const topPad = Math.max(0, boxW - labelLen - 7);
1494
- const top = `\x1b[90m╭─── \x1b[1m\x1b[36m${label}\x1b[0m\x1b[90m ${"─".repeat(topPad)}╮\x1b[0m`;
1635
+ // Truncate the label so the top border never exceeds the box width — a long
1636
+ // label used to push past `boxW` and wrap onto its own row, breaking the box
1637
+ // on narrow/scaled terminals. Reserve room for "╭─── ", the two spaces and
1638
+ // "╮" (7 cols) plus at least one dash.
1639
+ let topLabel = label;
1640
+ let topLabelLen = labelLen;
1641
+ const topRoom = Math.max(0, boxW - 8);
1642
+ if (topLabelLen > topRoom) {
1643
+ topLabel = truncateStyledEllipsis(label, topRoom);
1644
+ topLabelLen = plainLen(topLabel);
1645
+ }
1646
+ const topPad = Math.max(0, boxW - topLabelLen - 7);
1647
+ const top = `\x1b[90m╭─── \x1b[1m\x1b[36m${topLabel}\x1b[0m\x1b[90m ${"─".repeat(topPad)}╮\x1b[0m`;
1495
1648
  const body = [];
1496
1649
  for (const l of rows) {
1497
1650
  const plain = stripAnsi(l);
1498
- const pad = Math.max(0, innerW - plain.length);
1651
+ const pad = Math.max(0, innerW - plainLen(plain));
1499
1652
  body.push(`\x1b[90m│\x1b[0m ${l}${" ".repeat(pad)} \x1b[90m│\x1b[0m`);
1500
1653
  }
1501
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.114",
3
+ "version": "1.0.116",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },