@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.
- package/dist/ui.js +227 -74
- 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
|
-
|
|
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)
|
|
204
|
+
return dispLen(stripAnsi(s));
|
|
143
205
|
}
|
|
144
|
-
|
|
145
|
-
|
|
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
|
|
161
|
-
|
|
162
|
-
|
|
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,
|
|
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 +
|
|
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
|
|
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
|
|
601
|
-
const display = plain
|
|
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
|
|
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
|
|
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
|
|
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
|
|
1188
|
+
const pad = Math.max(0, this.lastLen - plainLen(plain));
|
|
1115
1189
|
process.stdout.write("\r" + rendered + " ".repeat(pad) + "\r");
|
|
1116
|
-
this.lastLen = plain
|
|
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
|
|
1341
|
-
let
|
|
1427
|
+
let line = [];
|
|
1428
|
+
let lineLen = 0;
|
|
1342
1429
|
let caretRow = -1;
|
|
1343
1430
|
let caretCol = -1;
|
|
1344
|
-
const
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
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
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
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 =
|
|
1355
|
-
|
|
1356
|
-
|
|
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
|
-
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1494
|
-
|
|
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
|
|
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`;
|