@oxecli/oxe 1.0.115 → 1.0.117

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 +136 -40
  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));
205
+ }
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;
145
219
  }
146
- // Truncate styled text to a max number of visible characters while keeping
147
- // ANSI escape sequences intact (never cut mid-sequence).
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);
@@ -441,10 +513,20 @@ export function markdownToAnsi(text) {
441
513
  const badge = codeLang ? ` \x1b[1;36m${codeLang}\x1b[0m ` : "";
442
514
  const barLen = Math.max(10, Math.min(width - plainLen(badge) - (codeLang ? 4 : 2), 60));
443
515
  const topPrefix = codeLang ? "─" : "";
516
+ // Full width of the framed box = the top border's column span, used to
517
+ // truncate body lines so they never run off the right edge past the border.
518
+ const borderW = 1 + (codeLang ? 1 : 0) + plainLen(badge) + barLen + 1;
519
+ // Body prefix is "│ " (3 columns). Content (incl. a trailing ellipsis)
520
+ // must fit in borderW - 3 so a truncated line ends flush with the border.
521
+ const contentMax = Math.max(borderW - 3, 1);
444
522
  out.push(`\x1b[90m╭${topPrefix}${badge}${"─".repeat(barLen)}╮\x1b[0m`);
445
523
  for (const cline of codeBuffer) {
446
524
  const highlighted = highlightCodeLine(cline, codeLang);
447
- out.push(`\x1b[90m│\x1b[0m ${highlighted}`);
525
+ // Truncate so a long code line never overflows the box's right edge
526
+ // (borders are only drawn at top/bottom, so a longer line would stick
527
+ // out past the closing ╯).
528
+ const body = plainLen(highlighted) > contentMax ? truncateStyled(highlighted, Math.max(contentMax - 1, 1)) + "…" : highlighted;
529
+ out.push(`\x1b[90m│\x1b[0m ${body}`);
448
530
  }
449
531
  out.push(`\x1b[90m╰${"─".repeat(barLen + plainLen(badge) + (codeLang ? 1 : 0))}╯\x1b[0m`);
450
532
  codeBuffer = [];
@@ -498,26 +580,29 @@ export function markdownToAnsi(text) {
498
580
  continue;
499
581
  }
500
582
  let line = raw;
501
- // Headers (render as styled headings with the '#' markers stripped)
583
+ // Headers (render as styled headings with the '#' markers stripped). Wrapped
584
+ // so a long heading never runs off the right edge.
502
585
  const h1 = line.match(/^#\s+(.+)$/);
503
586
  if (h1) {
504
- out.push(`\x1b[1;36m${formatInlineMarkdown(h1[1])}\x1b[0m`);
587
+ out.push(...wrapStyledToWidth(`\x1b[1;36m${formatInlineMarkdown(h1[1])}\x1b[0m`, width));
505
588
  continue;
506
589
  }
507
590
  const h2 = line.match(/^##\s+(.+)$/);
508
591
  if (h2) {
509
- out.push(`\x1b[1;36m${formatInlineMarkdown(h2[1])}\x1b[0m`);
592
+ out.push(...wrapStyledToWidth(`\x1b[1;36m${formatInlineMarkdown(h2[1])}\x1b[0m`, width));
510
593
  continue;
511
594
  }
512
595
  const h3 = line.match(/^###\s+(.+)$/);
513
596
  if (h3) {
514
- out.push(`\x1b[1;37m${formatInlineMarkdown(h3[1])}\x1b[0m`);
597
+ out.push(...wrapStyledToWidth(`\x1b[1;37m${formatInlineMarkdown(h3[1])}\x1b[0m`, width));
515
598
  continue;
516
599
  }
517
- // Blockquote
600
+ // Blockquote. Wrapped inside the "│ " gutter so a long quote never runs off
601
+ // the right edge.
518
602
  const bq = line.match(/^>\s*(.+)$/);
519
603
  if (bq) {
520
- out.push(`\x1b[90m│\x1b[0m \x1b[3m${formatInlineMarkdown(bq[1])}\x1b[0m`);
604
+ const wrapped = wrapStyledToWidth(`\x1b[3m${formatInlineMarkdown(bq[1])}\x1b[0m`, Math.max(width - 2, 1));
605
+ out.push(...wrapped.map((l) => `\x1b[90m│\x1b[0m ${l}`));
521
606
  continue;
522
607
  }
523
608
  // Unordered List item
@@ -565,7 +650,7 @@ function panelString(content, title = "", subtitle = "", expand = true, borderSt
565
650
  const plainLines = styledLines.map((l) => stripAnsi(l));
566
651
  let maxLineW = 0;
567
652
  for (const p of plainLines)
568
- maxLineW = Math.max(maxLineW, p.length);
653
+ maxLineW = Math.max(maxLineW, plainLen(p));
569
654
  if (title)
570
655
  maxLineW = Math.max(maxLineW, plainLen(title) + 4);
571
656
  if (subtitle)
@@ -599,8 +684,8 @@ function panelString(content, title = "", subtitle = "", expand = true, borderSt
599
684
  for (let i = 0; i < styledLines.length; i++) {
600
685
  const line = styledLines[i];
601
686
  const plain = plainLines[i] ?? "";
602
- const pad = Math.max(innerW - plain.length, 0);
603
- const display = plain.length > innerW ? truncateStyled(line, innerW) : line;
687
+ const pad = Math.max(innerW - plainLen(plain), 0);
688
+ const display = plainLen(plain) > innerW ? truncateStyled(line, innerW) : line;
604
689
  out.push(`\x1b[${borderStyle}m│\x1b[0m ${display}${" ".repeat(pad)} \x1b[${borderStyle}m│\x1b[0m`);
605
690
  }
606
691
  const bottom = `╰${embed(subtitle, "center")}╯`;
@@ -715,7 +800,7 @@ export function displayRows(text) {
715
800
  if (!lines.length)
716
801
  return 1;
717
802
  for (const line of lines) {
718
- rows += Math.max(1, Math.ceil(line.length / width));
803
+ rows += Math.max(1, Math.ceil(dispLen(line) / width));
719
804
  }
720
805
  return rows;
721
806
  }
@@ -1084,12 +1169,12 @@ export class Spinner {
1084
1169
  const first = parts[0];
1085
1170
  const rest = parts.slice(1);
1086
1171
  const plain = stripAnsi(first);
1087
- const pad = Math.max(0, this.lastLen - plain.length);
1172
+ const pad = Math.max(0, this.lastLen - plainLen(plain));
1088
1173
  process.stdout.write("\r" + first + " ".repeat(pad) + "\r\n");
1089
1174
  if (rest.length)
1090
1175
  process.stdout.write(rest.join("\n") + "\n");
1091
1176
  this.rows = 0;
1092
- this.lastLen = plain.length;
1177
+ this.lastLen = plainLen(plain);
1093
1178
  }
1094
1179
  update(text) {
1095
1180
  if (this.timer) {
@@ -1113,9 +1198,9 @@ export class Spinner {
1113
1198
  // cells (spinner frame, trailing timer) actually change on screen. Pad
1114
1199
  // with spaces so a shorter line clears any previously longer text.
1115
1200
  const plain = stripAnsi(rendered);
1116
- const pad = Math.max(0, this.lastLen - plain.length);
1201
+ const pad = Math.max(0, this.lastLen - plainLen(plain));
1117
1202
  process.stdout.write("\r" + rendered + " ".repeat(pad) + "\r");
1118
- this.lastLen = plain.length;
1203
+ this.lastLen = plainLen(plain);
1119
1204
  }
1120
1205
  else {
1121
1206
  // Multi-row wrapped text: fall back to an erase-based redraw.
@@ -1235,7 +1320,7 @@ export function formatToolResult(rawResult, failed) {
1235
1320
  const allLines = clean.split("\n");
1236
1321
  const lines = allLines
1237
1322
  .slice(0, TOOL_RESULT_MAX_LINES)
1238
- .map((ln) => (ln.length > lineMax ? ln.slice(0, lineMax) + "…" : ln));
1323
+ .map((ln) => (plainLen(ln) > lineMax ? truncateStyled(ln, lineMax) + "…" : ln));
1239
1324
  if (allLines.length > TOOL_RESULT_MAX_LINES)
1240
1325
  lines.push("…");
1241
1326
  const styleOpen = failed ? "\x1b[31m" : "\x1b[90m";
@@ -1379,7 +1464,7 @@ function wrapRuns(runs, width) {
1379
1464
  caretCol = lineLen;
1380
1465
  }
1381
1466
  line.push(u);
1382
- lineLen += 1;
1467
+ lineLen += charWidth(u.ch);
1383
1468
  };
1384
1469
  let i = 0;
1385
1470
  while (i < units.length) {
@@ -1398,7 +1483,8 @@ function wrapRuns(runs, width) {
1398
1483
  continue;
1399
1484
  }
1400
1485
  if (isWs(u)) {
1401
- if (lineLen + 1 > width)
1486
+ const w = charWidth(u.ch);
1487
+ if (lineLen + w > width)
1402
1488
  flushLine();
1403
1489
  // Never start a line with whitespace: if the space lands at the start of
1404
1490
  // a fresh line, drop it (it was the fill of the previous full line).
@@ -1413,28 +1499,38 @@ function wrapRuns(runs, width) {
1413
1499
  // Start of a word (which may contain the caret). Find its full extent in
1414
1500
  // the flattened stream so we never wrap mid-word.
1415
1501
  let j = i;
1416
- let wordLen = 0; // each unit (including a caret) occupies one column
1502
+ let wordW = 0; // display width of the whole word
1417
1503
  while (j < units.length && !isWs(units[j])) {
1418
- wordLen += 1;
1504
+ wordW += charWidth(units[j].ch);
1419
1505
  j++;
1420
1506
  }
1421
- if (wordLen === 0) {
1507
+ if (wordW === 0) {
1422
1508
  i++;
1423
1509
  continue;
1424
1510
  }
1425
1511
  if (lineLen === 0) {
1426
- if (wordLen > width) {
1512
+ if (wordW > width) {
1427
1513
  // Overlong word: split into width-sized chunks, keeping the caret in
1428
1514
  // the chunk that contains it.
1429
1515
  let from = i;
1430
- let remaining = wordLen;
1431
- while (remaining > 0) {
1432
- const n = Math.min(width, remaining);
1516
+ let used = 0;
1517
+ while (used < wordW) {
1518
+ let n = 0;
1519
+ let chunkW = 0;
1520
+ while (from + n < j && chunkW + charWidth(units[from + n].ch) <= width) {
1521
+ chunkW += charWidth(units[from + n].ch);
1522
+ n++;
1523
+ }
1524
+ if (n === 0) {
1525
+ // A single glyph is wider than the line; emit it alone.
1526
+ n = 1;
1527
+ chunkW = charWidth(units[from].ch);
1528
+ }
1433
1529
  for (let k = 0; k < n; k++)
1434
1530
  addUnit(units[from + k]);
1435
1531
  flushLine();
1436
1532
  from += n;
1437
- remaining -= n;
1533
+ used += chunkW;
1438
1534
  }
1439
1535
  }
1440
1536
  else {
@@ -1444,7 +1540,7 @@ function wrapRuns(runs, width) {
1444
1540
  i = j;
1445
1541
  }
1446
1542
  else {
1447
- if (lineLen + wordLen > width) {
1543
+ if (lineLen + wordW > width) {
1448
1544
  // Word doesn't fit on the current line; move the whole word to a new
1449
1545
  // line (leaving any trailing space from the previous word behind).
1450
1546
  flushLine();
@@ -1565,7 +1661,7 @@ export function renderBufferWithCursor(buffer, pasteSpans, prefix, label, cursor
1565
1661
  const body = [];
1566
1662
  for (const l of rows) {
1567
1663
  const plain = stripAnsi(l);
1568
- const pad = Math.max(0, innerW - plain.length);
1664
+ const pad = Math.max(0, innerW - plainLen(plain));
1569
1665
  body.push(`\x1b[90m│\x1b[0m ${l}${" ".repeat(pad)} \x1b[90m│\x1b[0m`);
1570
1666
  }
1571
1667
  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.117",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },