@oxecli/oxe 1.0.106 → 1.0.108

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/cli.js CHANGED
@@ -60,7 +60,7 @@ export class CLI {
60
60
  // The result body was formatted against the terminal width at tool time,
61
61
  // but the window may be narrower on resume — re-truncate defensively so
62
62
  // long persisted lines never run off the screen.
63
- const lineMax = Math.max(terminalWidth() - 4, 20);
63
+ const lineMax = Math.max(terminalWidth() - 4, 1);
64
64
  const allLines = body.split("\n");
65
65
  const lines = allLines
66
66
  .slice(0, TOOL_RESULT_MAX_LINES)
@@ -81,7 +81,7 @@ export class CLI {
81
81
  const truncated = truncateEllipsis(body, Math.min(max_action_chars, MAX_COMMAND_DISPLAY_CHARS), "text");
82
82
  // truncateEllipsis appends a "…(text truncated: N chars total)" suffix, so
83
83
  // re-truncate to the current width to keep the legacy action on one row.
84
- const displayMax = Math.max(terminalWidth() - 6, 20);
84
+ const displayMax = Math.max(terminalWidth() - 6, 1);
85
85
  process.stdout.write(`${icon} ${style}${truncateStyled(truncated, displayMax)}\x1b[0m\n`);
86
86
  }
87
87
  printAssistantBlock(text) {
package/dist/oxe.js CHANGED
@@ -1,10 +1,18 @@
1
1
  import { main } from "./cli.js";
2
+ import { dockTearDown, showCursor } from "./ui.js";
2
3
  export { main };
3
4
  // The TUI keeps the terminal cursor hidden except inside a prompt field. Always
4
- // restore it on exit so the shell isn't left with an invisible cursor.
5
+ // restore it on exit and tear down any docked prompt box so the shell isn't
6
+ // left with a stale prompt box or an invisible cursor.
5
7
  process.on("exit", () => {
6
8
  try {
7
- process.stdout.write("\x1b[?25h");
9
+ dockTearDown();
10
+ }
11
+ catch {
12
+ /* ignore */
13
+ }
14
+ try {
15
+ showCursor();
8
16
  }
9
17
  catch {
10
18
  /* ignore */
package/dist/ui.js CHANGED
@@ -76,8 +76,11 @@ export function startDraftCapture(onInterrupt, renderBox) {
76
76
  return;
77
77
  }
78
78
  if (str && !key?.ctrl && !key?.meta) {
79
- draft += str.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
80
- renderBox?.(draft, pasteSpans);
79
+ const added = str.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
80
+ if (draft.length + added.length <= MAX_PROMPT_CHARS) {
81
+ draft += added;
82
+ renderBox?.(draft, pasteSpans);
83
+ }
81
84
  }
82
85
  };
83
86
  readline.emitKeypressEvents(process.stdin);
@@ -113,6 +116,21 @@ export function clearScreen() {
113
116
  export function terminalWidth() {
114
117
  return process.stdout.columns || 80;
115
118
  }
119
+ /** Minimum width for boxes/cards so they don't collapse to nothing. */
120
+ const MIN_BOX_WIDTH = 20;
121
+ /** Horizontal margin reserved on each side of a box/card from the terminal edge. */
122
+ const BOX_MARGIN = 4;
123
+ /**
124
+ * Clamp a preferred box width so it fits inside the terminal (borders never wrap
125
+ * or duplicate on narrow/scaled terminals, e.g. a larger command-prompt scale)
126
+ * while keeping a preferred minimum. Fitting wins over the minimum when the
127
+ * terminal is narrower than min+margin, so cards size down smoothly instead of
128
+ * overflowing.
129
+ */
130
+ function fitBoxWidth(pref, termW) {
131
+ const maxW = Math.max(termW - BOX_MARGIN, 1);
132
+ return Math.min(Math.max(Math.min(pref, maxW), MIN_BOX_WIDTH), maxW);
133
+ }
116
134
  // ---------------------------------------------------------------------------
117
135
  // ANSI and plain text length helpers
118
136
  // ---------------------------------------------------------------------------
@@ -155,6 +173,81 @@ export function truncateStyled(text, maxVisible) {
155
173
  }
156
174
  return out;
157
175
  }
176
+ /**
177
+ * Wrap a styled (ANSI) string into lines of at most `width` visible characters,
178
+ * wrapping whole words onto new lines and splitting any single word longer than
179
+ * `width`. ANSI escape sequences are preserved and re-attached to their glyphs,
180
+ * so no style is lost and no escape sequence is ever cut mid-way.
181
+ */
182
+ function wrapStyledToWidth(line, width) {
183
+ if (width <= 0)
184
+ return [line];
185
+ if (plainLen(line) <= width)
186
+ return [line];
187
+ // Tokenize into glyph units, each carrying the ANSI prefix that precedes it.
188
+ const units = [];
189
+ const re = /(\x1b\[[0-9;]*[a-zA-Z]|\x1b\].*?\x07)|([\s\S])/g;
190
+ let pending = "";
191
+ let m;
192
+ re.lastIndex = 0;
193
+ while ((m = re.exec(line)) !== null) {
194
+ if (m[1] !== undefined)
195
+ pending += m[1];
196
+ else {
197
+ units.push({ ch: m[2], pre: pending });
198
+ pending = "";
199
+ }
200
+ }
201
+ const out = [];
202
+ let cur = "";
203
+ let curLen = 0;
204
+ let word = [];
205
+ const emitChunk = (chunk, prependSpace) => {
206
+ const add = (prependSpace && curLen > 0 ? 1 : 0) + chunk.length;
207
+ if (curLen + add > width)
208
+ return false;
209
+ if (prependSpace && curLen > 0) {
210
+ cur += " ";
211
+ curLen++;
212
+ }
213
+ for (const u of chunk)
214
+ cur += u.pre + u.ch;
215
+ curLen += chunk.length;
216
+ return true;
217
+ };
218
+ const flushWord = () => {
219
+ if (!word.length)
220
+ return;
221
+ const wlen = word.length;
222
+ // Split overlong words into <= width chunks so nothing is ever cut off.
223
+ const chunks = [];
224
+ if (wlen <= width)
225
+ chunks.push(word);
226
+ else
227
+ for (let i = 0; i < wlen; i += width)
228
+ chunks.push(word.slice(i, i + width));
229
+ for (let ci = 0; ci < chunks.length; ci++) {
230
+ const chunk = chunks[ci];
231
+ if (!emitChunk(chunk, ci === 0)) {
232
+ out.push(cur);
233
+ cur = "";
234
+ curLen = 0;
235
+ emitChunk(chunk, false);
236
+ }
237
+ }
238
+ word = [];
239
+ };
240
+ for (const u of units) {
241
+ if (/\s/.test(u.ch))
242
+ flushWord();
243
+ else
244
+ word.push(u);
245
+ }
246
+ flushWord();
247
+ if (cur)
248
+ out.push(cur);
249
+ return out.length ? out : [line];
250
+ }
158
251
  // ---------------------------------------------------------------------------
159
252
  // Markup tag -> ANSI
160
253
  // ---------------------------------------------------------------------------
@@ -290,7 +383,7 @@ function formatMarkdownTable(tableLines) {
290
383
  // Rich Markdown -> ANSI Terminal Renderer
291
384
  // ---------------------------------------------------------------------------
292
385
  export function markdownToAnsi(text) {
293
- const width = Math.max(terminalWidth() - 2, 40);
386
+ const width = Math.max(terminalWidth() - 2, 1);
294
387
  const rawLines = text.split("\n");
295
388
  const out = [];
296
389
  let inCode = false;
@@ -394,22 +487,11 @@ export function markdownToAnsi(text) {
394
487
  if (!line.includes("\x1b[36m•\x1b[0m") && !line.includes(".\x1b[0m ")) {
395
488
  line = formatInlineMarkdown(line);
396
489
  }
397
- // Word wrap paragraph line if too long
490
+ // Word wrap paragraph line if too long. Whole words move to a new row; any
491
+ // single word longer than the width is split so nothing is ever cut off at
492
+ // the right edge.
398
493
  if (plainLen(line) > width && !line.startsWith(" ")) {
399
- const words = line.split(" ");
400
- let cur = "";
401
- for (const w of words) {
402
- if (plainLen(cur) + plainLen(w) + 1 > width) {
403
- if (cur)
404
- out.push(cur);
405
- cur = w;
406
- }
407
- else {
408
- cur = cur ? `${cur} ${w}` : w;
409
- }
410
- }
411
- if (cur)
412
- out.push(cur);
494
+ out.push(...wrapStyledToWidth(line, width));
413
495
  }
414
496
  else {
415
497
  out.push(line);
@@ -445,8 +527,8 @@ function panelString(content, title = "", subtitle = "", expand = true, borderSt
445
527
  maxLineW = Math.max(maxLineW, plainLen(subtitle) + 4);
446
528
  const termW = terminalWidth();
447
529
  const boxW = expand
448
- ? Math.max(termW - 4, 20)
449
- : Math.min(Math.max(maxLineW + 4, 20), termW - 4);
530
+ ? fitBoxWidth(termW - 4, termW)
531
+ : fitBoxWidth(maxLineW + 4, termW);
450
532
  const innerW = Math.max(boxW - 4, 1);
451
533
  const embed = (text, align) => {
452
534
  if (!text)
@@ -1072,7 +1154,7 @@ export function toolCallLabel(name, argumentsJson) {
1072
1154
  arg = arg.trim().replace(/\s*\r?\n\s*/g, " ");
1073
1155
  if (!arg)
1074
1156
  return `\x1b[1;36m${display}\x1b[0m`;
1075
- const cap = Math.max(Math.min(terminalWidth() - plainLen(display) - 4, MAX_COMMAND_DISPLAY_CHARS), 20);
1157
+ const cap = Math.max(Math.min(terminalWidth() - plainLen(display) - 4, MAX_COMMAND_DISPLAY_CHARS), Math.min(terminalWidth() - plainLen(display) - 2, 8));
1076
1158
  if (plainLen(arg) > cap)
1077
1159
  arg = truncateStyled(arg, cap) + "…";
1078
1160
  return `\x1b[1;36m${display}\x1b[0m(${arg})`;
@@ -1097,7 +1179,7 @@ export function formatToolResult(rawResult, failed) {
1097
1179
  // Truncate to the terminal width (rows OR per-line length, whichever trips
1098
1180
  // first) so long tool output — e.g. Glob paths — never runs off the screen.
1099
1181
  const termW = terminalWidth();
1100
- const lineMax = Math.max(termW - 4, 20);
1182
+ const lineMax = Math.max(termW - 4, 1);
1101
1183
  const allLines = clean.split("\n");
1102
1184
  const lines = allLines
1103
1185
  .slice(0, TOOL_RESULT_MAX_LINES)
@@ -1116,10 +1198,32 @@ export function formatToolResult(rawResult, failed) {
1116
1198
  export const PROMPT_PLACEHOLDER = "Describe a coding task, or type /help for commands";
1117
1199
  const MAX_PROMPT_DISPLAY_LINES = 12;
1118
1200
  const MAX_PROMPT_PASTE_CHARS = 400;
1201
+ const MAX_PROMPT_CHARS = 500;
1119
1202
  function shouldCollapsePaste(text) {
1120
1203
  return (text.split("\n").length > MAX_PROMPT_DISPLAY_LINES ||
1121
1204
  text.length > MAX_PROMPT_PASTE_CHARS);
1122
1205
  }
1206
+ /**
1207
+ * Effective character count of a prompt for its length limit. Each non-collapsed
1208
+ * char counts as one; a collapsed pasted block counts as a single char.
1209
+ */
1210
+ export function promptCharCount(buffer, pasteSpans) {
1211
+ if (!buffer)
1212
+ return 0;
1213
+ const spans = [...pasteSpans].sort((a, b) => a[0] - b[0]);
1214
+ let count = 0;
1215
+ let last = 0;
1216
+ for (const [s, e] of spans) {
1217
+ if (s > last)
1218
+ count += s - last;
1219
+ const seg = buffer.slice(s, e);
1220
+ count += shouldCollapsePaste(seg) ? 1 : e - s;
1221
+ last = e;
1222
+ }
1223
+ if (last < buffer.length)
1224
+ count += buffer.length - last;
1225
+ return count;
1226
+ }
1123
1227
  export function cursorLineCol(buffer, cursor) {
1124
1228
  const before = buffer.slice(0, cursor);
1125
1229
  const lines = before.split("\n");
@@ -1238,7 +1342,7 @@ function wrapRuns(runs, width) {
1238
1342
  }
1239
1343
  export function renderBufferWithCursor(buffer, pasteSpans, prefix, label, cursor, hints) {
1240
1344
  const termW = terminalWidth();
1241
- const boxW = Math.max(termW - 4, 16);
1345
+ const boxW = fitBoxWidth(termW - 4, termW);
1242
1346
  const innerW = boxW - 4;
1243
1347
  const runs = [];
1244
1348
  runs.push({ text: prefix, style: "\x1b[90m" });
@@ -1337,11 +1441,18 @@ export function renderBufferWithCursor(buffer, pasteSpans, prefix, label, cursor
1337
1441
  const lines = [top, ...body, bottom];
1338
1442
  let totalRows = body.length + 2;
1339
1443
  if (hints && (hints.left || hints.right)) {
1340
- const left = hints.left ?? "";
1444
+ let left = hints.left ?? "";
1341
1445
  const right = hints.right ?? "";
1342
1446
  // Inset the hint row inside the box (box spans columns 0..boxW-1): a
1343
1447
  // leading space gives left-edge padding, and the right hint ends one
1344
- // column before the box's right border for the same padding.
1448
+ // column before the box's right border for the same padding. Truncate the
1449
+ // left hint so the row never exceeds the box width on narrow terminals
1450
+ // (otherwise it overflows past the right border instead of scaling down).
1451
+ const hintMax = boxW - 2;
1452
+ if (plainLen(left) + plainLen(right) + 1 > hintMax) {
1453
+ const room = Math.max(0, hintMax - plainLen(right) - 1);
1454
+ left = truncateStyled(left, room);
1455
+ }
1345
1456
  const gap = Math.max(1, boxW - 2 - plainLen(left) - plainLen(right));
1346
1457
  lines.push(" " + left + " ".repeat(gap) + right);
1347
1458
  totalRows += 1;
@@ -1464,6 +1575,7 @@ export function askBottomPrompt(label = "You", prefix = "❯", history = [], hin
1464
1575
  dockEnterDocked();
1465
1576
  }
1466
1577
  process.stdin.removeListener("keypress", onKeypress);
1578
+ process.stdout.removeListener("resize", onResize);
1467
1579
  process.stdin.pause();
1468
1580
  showCursor();
1469
1581
  if (isErr)
@@ -1536,14 +1648,32 @@ export function askBottomPrompt(label = "You", prefix = "❯", history = [], hin
1536
1648
  draft = { buffer, cursor, spans: pasteSpans.slice() };
1537
1649
  histIdx = hist.length;
1538
1650
  }
1539
- const pasteStart = cursor;
1540
- buffer = buffer.slice(0, cursor) + text + buffer.slice(cursor);
1541
- pasteSpans = shiftSpansAfterInsert(cursor, text.length);
1651
+ // Non-collapsing pastes get a leading space when inserted after non-empty,
1652
+ // non-whitespace text (never when a space is already present). The space is
1653
+ // inserted as plain text (outside the paste span) so display spacing isn't
1654
+ // doubled.
1655
+ let prefix = "";
1656
+ if (isPaste &&
1657
+ !shouldCollapsePaste(text) &&
1658
+ cursor > 0 &&
1659
+ !endsWithWs(buffer.slice(0, cursor))) {
1660
+ prefix = " ";
1661
+ }
1662
+ const pasteStart = cursor + prefix.length;
1663
+ const insertLen = prefix.length + text.length;
1664
+ const newBuffer = buffer.slice(0, cursor) + prefix + text + buffer.slice(cursor);
1665
+ const newSpans = shiftSpansAfterInsert(cursor, insertLen);
1542
1666
  if (isPaste) {
1543
- pasteSpans.push([pasteStart, pasteStart + text.length]);
1544
- pasteSpans.sort((a, b) => a[0] - b[0]);
1667
+ newSpans.push([pasteStart, pasteStart + text.length]);
1668
+ newSpans.sort((a, b) => a[0] - b[0]);
1545
1669
  }
1546
- cursor += text.length;
1670
+ // Enforce the 500-char effective limit; a collapsed block counts as one.
1671
+ if (promptCharCount(newBuffer, newSpans) > MAX_PROMPT_CHARS) {
1672
+ return;
1673
+ }
1674
+ buffer = newBuffer;
1675
+ pasteSpans = newSpans;
1676
+ cursor += insertLen;
1547
1677
  };
1548
1678
  const spanCollapsed = (span) => {
1549
1679
  return shouldCollapsePaste(buffer.slice(span[0], span[1]));
@@ -1887,6 +2017,17 @@ export function askBottomPrompt(label = "You", prefix = "❯", history = [], hin
1887
2017
  }
1888
2018
  };
1889
2019
  process.stdin.on("keypress", onKeypress);
2020
+ // Reflow the idle prompt box when the terminal resizes so it scales down
2021
+ // instead of wrapping/overflowing its borders on a narrow/scaled terminal.
2022
+ const onResize = () => {
2023
+ try {
2024
+ repaint(false);
2025
+ }
2026
+ catch {
2027
+ /* ignore */
2028
+ }
2029
+ };
2030
+ process.stdout.on("resize", onResize);
1890
2031
  repaint(true);
1891
2032
  });
1892
2033
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oxecli/oxe",
3
- "version": "1.0.106",
3
+ "version": "1.0.108",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },