@oxecli/oxe 1.0.50 → 1.0.52

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 CHANGED
@@ -28,10 +28,6 @@ export function disableRawStdin() {
28
28
  catch {
29
29
  /* ignore */
30
30
  }
31
- // Do NOT pause stdin here: askBottomPrompt re-acquires raw mode and
32
- // resumes the stream itself. Pausing can race with that resume and drop
33
- // keypress events on some terminals (prompt appearing "locked" after an
34
- // interactive help/picker).
35
31
  showCursor();
36
32
  }
37
33
  }
@@ -40,7 +36,7 @@ export function waitRawKey() {
40
36
  return new Promise((resolve) => {
41
37
  const onKeypress = (str, key) => {
42
38
  process.stdin.removeListener("keypress", onKeypress);
43
- resolve({ name: key?.name ?? "", str: str ?? "", ctrl: !!(key?.ctrl) });
39
+ resolve({ name: key?.name ?? "", str: str ?? "", ctrl: !!key?.ctrl });
44
40
  };
45
41
  process.stdin.on("keypress", onKeypress);
46
42
  });
@@ -52,22 +48,25 @@ export function isWindows() {
52
48
  return process.platform === "win32";
53
49
  }
54
50
  export function enableAnsi() {
55
- // Node's tty on Windows Terminal / modern conhost supports ANSI automatically.
56
- // No native SetConsoleMode available without a native module; rely on the host.
51
+ // Modern Windows Terminal and host consoles support ANSI automatically.
57
52
  }
58
53
  export function clearScreen() {
59
- if (isWindows()) {
60
- // crude but reliable clear
61
- process.stdout.write("\x1b[2J\x1b[3J\x1b[H");
62
- }
63
- else {
64
- process.stdout.write("\x1b[2J\x1b[3J\x1b[H");
65
- }
54
+ process.stdout.write("\x1b[2J\x1b[3J\x1b[H");
66
55
  }
67
56
  export function terminalWidth() {
68
57
  return process.stdout.columns || 80;
69
58
  }
70
59
  // ---------------------------------------------------------------------------
60
+ // ANSI and plain text length helpers
61
+ // ---------------------------------------------------------------------------
62
+ export const ANSI_RE = /\x1b\[[0-9;]*[a-zA-Z]|\x1b\].*?\x07|\x1b[()][AB012]|\x1b\[\?[0-9]+[a-zA-Z]|\x1b\[>[0-9]+[a-zA-Z]/g;
63
+ export function stripAnsi(s) {
64
+ return s.replace(ANSI_RE, "");
65
+ }
66
+ export function plainLen(s) {
67
+ return stripAnsi(s).length;
68
+ }
69
+ // ---------------------------------------------------------------------------
71
70
  // Markup tag -> ANSI
72
71
  // ---------------------------------------------------------------------------
73
72
  const TAG_MAP = {
@@ -82,9 +81,17 @@ const TAG_MAP = {
82
81
  magenta: "\x1b[35m",
83
82
  cyan: "\x1b[36m",
84
83
  white: "\x1b[37m",
84
+ bright_black: "\x1b[90m",
85
+ bright_red: "\x1b[91m",
86
+ bright_green: "\x1b[92m",
87
+ bright_yellow: "\x1b[93m",
88
+ bright_blue: "\x1b[94m",
89
+ bright_magenta: "\x1b[95m",
90
+ bright_cyan: "\x1b[96m",
91
+ bright_white: "\x1b[97m",
85
92
  };
86
93
  const RESET = "\x1b[0m";
87
- const MARKUP_RE = /\[(\/?)([a-z0-9 ]+)\]/gi;
94
+ const MARKUP_RE = /\[(\/?)([a-z0-9_ ]+)\]/gi;
88
95
  export function escapeMarkup(text) {
89
96
  return text.replace(/\[/g, "\\[").replace(/\]/g, "\\]");
90
97
  }
@@ -115,90 +122,143 @@ export function markupToAnsi(text) {
115
122
  });
116
123
  }
117
124
  // ---------------------------------------------------------------------------
118
- // Lightweight markdown -> ANSI
125
+ // Syntax Highlighter for Code Blocks
119
126
  // ---------------------------------------------------------------------------
120
- export function markdownToAnsi(text) {
121
- const width = Math.max(terminalWidth() || 80, 1);
122
- const lines = text.split("\n").flatMap((raw) => {
123
- if (!raw || raw.length <= width)
124
- return [raw];
125
- const indent = raw.match(/^\s*/)?.[0] ?? "";
126
- const words = raw.trim().split(/\s+/);
127
- const wrapped = [];
128
- let current = indent;
129
- for (const word of words) {
130
- if (word.length > width) {
131
- if (current.trim())
132
- wrapped.push(current);
133
- current = "";
134
- for (let i = 0; i < word.length; i += width) {
135
- const part = word.slice(i, i + width);
136
- if (part.length === width)
137
- wrapped.push(part);
138
- else
139
- current = part;
140
- }
141
- continue;
142
- }
143
- const next = current.trim() ? `${current} ${word}` : `${current}${word}`;
144
- if (current.trim() && next.length > width) {
145
- wrapped.push(current);
146
- current = `${indent}${word}`;
147
- }
148
- else {
149
- current = next;
150
- }
151
- }
152
- if (current || !wrapped.length)
153
- wrapped.push(current);
154
- return wrapped;
127
+ const KEYWORDS = new Set([
128
+ "const", "let", "var", "function", "class", "import", "export", "from",
129
+ "return", "if", "else", "for", "while", "do", "switch", "case", "break",
130
+ "continue", "async", "await", "try", "catch", "finally", "throw", "new",
131
+ "typeof", "instanceof", "interface", "type", "enum", "extends", "implements",
132
+ "public", "private", "protected", "readonly", "static", "def", "self",
133
+ "lambda", "with", "as", "yield", "pass", "None", "True", "False", "null",
134
+ "undefined", "true", "false", "fn", "struct", "pub", "impl", "mut", "use",
135
+ ]);
136
+ function highlightCodeLine(line, _lang = "") {
137
+ if (!line)
138
+ return "";
139
+ // Comments
140
+ if (/^\s*(\/\/|#|\/\*)/.test(line)) {
141
+ return `\x1b[2;37m${line}\x1b[0m`;
142
+ }
143
+ // Tokenize words, strings, numbers
144
+ return line.replace(/("(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|`(?:\\.|[^`\\])*`)|(\b\d+\.?\d*\b)|(\b[a-zA-Z_$][\w$]*\b)|(\/\/[^\n]*|#[^\n]*)/g, (m, str, num, ident, comment) => {
145
+ if (str)
146
+ return `\x1b[32m${str}\x1b[0m`;
147
+ if (num)
148
+ return `\x1b[33m${num}\x1b[0m`;
149
+ if (comment)
150
+ return `\x1b[2;37m${comment}\x1b[0m`;
151
+ if (ident && KEYWORDS.has(ident))
152
+ return `\x1b[1;36m${ident}\x1b[0m`;
153
+ return m;
155
154
  });
155
+ }
156
+ // ---------------------------------------------------------------------------
157
+ // Rich Markdown -> ANSI Terminal Renderer
158
+ // ---------------------------------------------------------------------------
159
+ export function markdownToAnsi(text) {
160
+ const width = Math.max(terminalWidth() - 2, 40);
161
+ const rawLines = text.split("\n");
156
162
  const out = [];
157
163
  let inCode = false;
158
- let codeBuf = [];
159
- const flushCode = () => {
160
- if (codeBuf.length) {
161
- out.push(`\x1b[2m${codeBuf.join("\n")}\x1b[0m`);
162
- codeBuf = [];
164
+ let codeLang = "";
165
+ let codeBuffer = [];
166
+ const flushCodeBlock = () => {
167
+ if (!codeBuffer.length && !codeLang)
168
+ return;
169
+ const badge = codeLang ? ` \x1b[1;36m${codeLang}\x1b[0m ` : " ";
170
+ const barLen = Math.max(10, Math.min(width - plainLen(badge) - 4, 60));
171
+ out.push(`\x1b[90m┌─${badge}${"─".repeat(barLen)}┐\x1b[0m`);
172
+ for (const cline of codeBuffer) {
173
+ const highlighted = highlightCodeLine(cline, codeLang);
174
+ out.push(`\x1b[90m│\x1b[0m ${highlighted}`);
163
175
  }
176
+ out.push(`\x1b[90m└${"─".repeat(barLen + plainLen(badge) + 2)}┘\x1b[0m`);
177
+ codeBuffer = [];
178
+ codeLang = "";
164
179
  };
165
- for (const raw of lines) {
166
- const line = raw;
167
- const fence = line.match(/^(`{3,})/);
168
- if (fence) {
180
+ for (let i = 0; i < rawLines.length; i++) {
181
+ const raw = rawLines[i];
182
+ const fenceMatch = raw.match(/^(`{3,})(.*)$/);
183
+ if (fenceMatch) {
169
184
  if (!inCode) {
170
185
  inCode = true;
186
+ codeLang = fenceMatch[2].trim().toLowerCase();
187
+ codeBuffer = [];
171
188
  continue;
172
189
  }
173
190
  else {
174
191
  inCode = false;
175
- flushCode();
192
+ flushCodeBlock();
176
193
  continue;
177
194
  }
178
195
  }
179
196
  if (inCode) {
180
- codeBuf.push(line);
197
+ codeBuffer.push(raw);
181
198
  continue;
182
199
  }
183
- let s = line;
184
- // inline code
185
- s = s.replace(/`([^`]+)`/g, (_, c) => `\x1b[2m${c}\x1b[0m`);
186
- // bold
187
- s = s.replace(/\*\*([^*]+)\*\*/g, "\x1b[1m$1\x1b[0m");
188
- // italic
189
- s = s.replace(/(^|[^\w*])\*([^*\n]+)\*(?=$|[^\w*])/g, "$1\x1b[3m$2\x1b[0m");
190
- // headers
191
- const h = s.match(/^(#{1,6})\s+(.*)$/);
192
- if (h) {
193
- s = `\x1b[1m${h[2]}\x1b[0m`;
200
+ let line = raw;
201
+ // Headers
202
+ const h1 = line.match(/^#\s+(.+)$/);
203
+ if (h1) {
204
+ out.push(`\x1b[1;36m# ${h1[1]}\x1b[0m`);
205
+ continue;
206
+ }
207
+ const h2 = line.match(/^##\s+(.+)$/);
208
+ if (h2) {
209
+ out.push(`\x1b[1;34m## ${h2[1]}\x1b[0m`);
210
+ continue;
194
211
  }
195
- // links
196
- s = s.replace(/\[([^\]]+)\]\([^)]+\)/g, "$1");
197
- // leading list markers
198
- s = s.replace(/^(\s*)([-*+]\s)/, "$1· ");
199
- out.push(s);
212
+ const h3 = line.match(/^###\s+(.+)$/);
213
+ if (h3) {
214
+ out.push(`\x1b[1;37m### ${h3[1]}\x1b[0m`);
215
+ continue;
216
+ }
217
+ // Blockquote
218
+ const bq = line.match(/^>\s*(.+)$/);
219
+ if (bq) {
220
+ out.push(`\x1b[90m│\x1b[0m \x1b[3m${bq[1]}\x1b[0m`);
221
+ continue;
222
+ }
223
+ // Unordered List item
224
+ line = line.replace(/^(\s*)[-*+]\s+(.+)$/, "$1\x1b[36m•\x1b[0m $2");
225
+ // Ordered List item
226
+ line = line.replace(/^(\s*)(\d+)\.\s+(.+)$/, "$1\x1b[36m$2.\x1b[0m $3");
227
+ // Inline formatting:
228
+ // Bold + Italic (***text*** or ___text___)
229
+ line = line.replace(/\*\*\*([^*]+)\*\*\*/g, "\x1b[1;3m$1\x1b[0m");
230
+ // Bold (**text** or __text__)
231
+ line = line.replace(/\*\*([^*]+)\*\*/g, "\x1b[1m$1\x1b[0m");
232
+ // Italic (*text* or _text_)
233
+ line = line.replace(/(^|[^\w*])\*([^*\n]+)\*(?=$|[^\w*])/g, "$1\x1b[3m$2\x1b[0m");
234
+ // Inline code (`code`)
235
+ line = line.replace(/`([^`]+)`/g, "\x1b[1;36m`$1`\x1b[0m");
236
+ // Links [text](url) -> text (url)
237
+ line = line.replace(/\[([^\]]+)\]\(([^)]+)\)/g, "\x1b[4;36m$1\x1b[0m \x1b[2m($2)\x1b[0m");
238
+ // Word wrap paragraph line if too long
239
+ if (plainLen(line) > width && !line.startsWith(" ")) {
240
+ const words = line.split(" ");
241
+ let cur = "";
242
+ for (const w of words) {
243
+ if (plainLen(cur) + plainLen(w) + 1 > width) {
244
+ if (cur)
245
+ out.push(cur);
246
+ cur = w;
247
+ }
248
+ else {
249
+ cur = cur ? `${cur} ${w}` : w;
250
+ }
251
+ }
252
+ if (cur)
253
+ out.push(cur);
254
+ }
255
+ else {
256
+ out.push(line);
257
+ }
258
+ }
259
+ if (inCode) {
260
+ flushCodeBlock();
200
261
  }
201
- flushCode();
202
262
  return out.join("\n");
203
263
  }
204
264
  export function aiMarkdown(text) {
@@ -212,39 +272,27 @@ export function mutedMarkdown(text) {
212
272
  return markdownToAnsi(dimmed);
213
273
  }
214
274
  // ---------------------------------------------------------------------------
215
- // Panel
275
+ // Panel Rendering
216
276
  // ---------------------------------------------------------------------------
217
- /**
218
- * Render a rich-style rounded panel.
219
- *
220
- * Matches rich's Panel behavior:
221
- * - `expand=false` shrinks the frame to fit the content (banner/auth).
222
- * - title/subtitle are embedded in the top/bottom borders, centered by
223
- * default (rich default) or left-aligned.
224
- */
225
- function panelString(content, title = "", subtitle = "", expand = true, borderStyle = "90", // 90 = bright black (grey50)
226
- titleAlign = "center") {
277
+ function panelString(content, title = "", subtitle = "", expand = true, borderStyle = "90", titleAlign = "center") {
227
278
  const styled = markupToAnsi(content);
228
279
  const styledLines = styled.split("\n");
229
- const plainLines = styled.replace(/\x1b\[[0-9;]*m/g, "").split("\n");
230
- // Inner width = widest content line + 1 space padding each side.
231
- let contentW = 0;
280
+ const plainLines = styledLines.map((l) => stripAnsi(l));
281
+ let maxLineW = 0;
232
282
  for (const p of plainLines)
233
- contentW = Math.max(contentW, p.length);
234
- contentW = Math.max(contentW, 1);
235
- let innerW = contentW + 2;
283
+ maxLineW = Math.max(maxLineW, p.length);
236
284
  if (title)
237
- innerW = Math.max(innerW, plainLen(title) + 2);
285
+ maxLineW = Math.max(maxLineW, plainLen(title) + 4);
238
286
  if (subtitle)
239
- innerW = Math.max(innerW, plainLen(subtitle) + 2);
240
- const w = terminalWidth();
241
- innerW = expand ? Math.max(w - 4, 10) : Math.min(innerW, w - 4);
242
- // Build a border run of `innerW` chars with `text` embedded.
287
+ maxLineW = Math.max(maxLineW, plainLen(subtitle) + 4);
288
+ const termW = terminalWidth();
289
+ const boxW = expand ? Math.max(termW - 4, 20) : Math.min(maxLineW + 4, termW - 4);
290
+ const innerW = boxW - 4;
243
291
  const embed = (text, align) => {
244
292
  if (!text)
245
- return "─".repeat(innerW);
293
+ return "─".repeat(boxW - 2);
246
294
  const inner = ` ${text} `;
247
- const fill = Math.max(innerW - plainLen(text) - 2, 0);
295
+ const fill = Math.max(boxW - 2 - plainLen(inner), 0);
248
296
  if (align === "left") {
249
297
  return `${inner}${"─".repeat(fill)}`;
250
298
  }
@@ -258,39 +306,32 @@ titleAlign = "center") {
258
306
  for (let i = 0; i < styledLines.length; i++) {
259
307
  const line = styledLines[i];
260
308
  const plain = plainLines[i] ?? "";
261
- // Keep exactly one space padding each side; only pad the right to fill.
262
- const pad = Math.max(innerW - plain.length - 2, 0);
309
+ const pad = Math.max(innerW - plain.length, 0);
263
310
  out.push(`\x1b[${borderStyle}m│\x1b[0m ${line}${" ".repeat(pad)} \x1b[${borderStyle}m│\x1b[0m`);
264
311
  }
265
312
  const bottom = `╰${embed(subtitle, "center")}╯`;
266
313
  out.push(`\x1b[${borderStyle}m${bottom}\x1b[0m`);
267
314
  return out.join("\n");
268
315
  }
269
- export function renderPanel(content, title = "", subtitle = "", expand = true, borderStyle = "90", // 90 = bright black (grey50)
270
- titleAlign = "center") {
316
+ export function renderPanel(content, title = "", subtitle = "", expand = true, borderStyle = "90", titleAlign = "center") {
271
317
  process.stdout.write(panelString(content, title, subtitle, expand, borderStyle, titleAlign) + "\n");
272
318
  }
273
- function plainLen(s) {
274
- return s.replace(/\x1b\[[0-9;]*m/g, "").length;
275
- }
276
- /**
277
- * Render a rich-style bordered table panel (mirrors rich's Table + Panel).
278
- *
279
- * `rows` is a list of {cells, style?} where `style` (ANSI prefix) is applied to
280
- * the whole row. Columns are left-aligned and padded; `colWidths` can fix a
281
- * column's width (used for the selection gutter).
282
- */
319
+ // ---------------------------------------------------------------------------
320
+ // Table Panel Rendering
321
+ // ---------------------------------------------------------------------------
283
322
  export function renderTableString(rows, opts = {}) {
284
323
  const ncols = Math.max(0, ...rows.map((r) => r.cells.length));
285
- if (ncols === 0)
324
+ if (ncols === 0) {
286
325
  return panelString("", opts.title ?? "", opts.subtitle ?? "", opts.expand, opts.borderStyle, opts.titleAlign);
326
+ }
287
327
  const colGap = opts.colGap ?? 2;
288
328
  const widths = [];
289
329
  for (let c = 0; c < ncols; c++) {
290
330
  let mw = 0;
291
- for (const r of rows)
331
+ for (const r of rows) {
292
332
  if (r.cells[c])
293
333
  mw = Math.max(mw, plainLen(r.cells[c]));
334
+ }
294
335
  widths.push(Math.max(mw, opts.colWidths?.[c] ?? 0));
295
336
  }
296
337
  const contentLines = [];
@@ -307,7 +348,7 @@ export function renderTableString(rows, opts = {}) {
307
348
  return panelString(contentLines.join("\n"), opts.title ?? "", opts.subtitle ?? "", opts.expand, opts.borderStyle, opts.titleAlign);
308
349
  }
309
350
  // ---------------------------------------------------------------------------
310
- // Durations
351
+ // Durations & Text Formatting
311
352
  // ---------------------------------------------------------------------------
312
353
  export function formatDuration(seconds) {
313
354
  const total = Math.max(0, Math.round(seconds));
@@ -325,22 +366,14 @@ export function formatDuration(seconds) {
325
366
  return parts.join(" ");
326
367
  }
327
368
  export function tickDuration(seconds) {
328
- // Durations are status values, not code. Keep the surrounding text muted
329
- // while making values such as `0s` readable in normal white intensity.
330
- // Restore dim afterward so the following labels keep their muted styling.
331
369
  return `\x1b[22;37m${formatDuration(seconds)}\x1b[2m`;
332
370
  }
333
- // ---------------------------------------------------------------------------
334
- // Text helpers
335
- // ---------------------------------------------------------------------------
336
- const FENCE_MARKER_RE = /`{3,}/g;
337
371
  export function printAiChunk(chunk) {
338
- // Streaming commits are slices of the model's exact text. Do not trim or
339
- // append newlines here, otherwise paragraph breaks from the AI disappear.
340
372
  if (!chunk)
341
373
  return;
342
374
  process.stdout.write(markdownToAnsi(chunk));
343
375
  }
376
+ const FENCE_MARKER_RE = /`{3,}/g;
344
377
  export function safeCommitPoint(text) {
345
378
  let idx = text.lastIndexOf("\n\n");
346
379
  while (idx !== -1) {
@@ -353,14 +386,15 @@ export function safeCommitPoint(text) {
353
386
  }
354
387
  export function truncateEllipsis(text, maxChars, label = "text") {
355
388
  if (text.length > maxChars) {
356
- return text.slice(0, maxChars) + ` …(${label} truncated: ${text.length.toLocaleString()} chars total)`;
389
+ return (text.slice(0, maxChars) +
390
+ ` …(${label} truncated: ${text.length.toLocaleString()} chars total)`);
357
391
  }
358
392
  return text;
359
393
  }
360
394
  export function displayRows(text) {
361
395
  if (!text)
362
396
  return 1;
363
- const plain = text.replace(/\x1b\[[0-9;]*m/g, "").replace(/`/g, "");
397
+ const plain = stripAnsi(text);
364
398
  const width = Math.max(terminalWidth() || 80, 1);
365
399
  let rows = 0;
366
400
  const lines = plain.split("\n");
@@ -372,7 +406,7 @@ export function displayRows(text) {
372
406
  return rows;
373
407
  }
374
408
  // ---------------------------------------------------------------------------
375
- // Cursor + spinner helpers (mirror rich's console.status spinner="dots")
409
+ // Cursor & Spinner
376
410
  // ---------------------------------------------------------------------------
377
411
  let cursorHidden = false;
378
412
  export function hideCursor() {
@@ -388,11 +422,6 @@ export function showCursor() {
388
422
  }
389
423
  }
390
424
  const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
391
- /**
392
- * A transient status line with an animated dot spinner, updated in place
393
- * (no new lines appended on every tick). On stop() the line is erased.
394
- * Mirrors rich's console.status(..., spinner="dots").
395
- */
396
425
  export class Spinner {
397
426
  timer = null;
398
427
  frame = 0;
@@ -415,7 +444,7 @@ export class Spinner {
415
444
  this.timer = setInterval(() => {
416
445
  this.frame++;
417
446
  this.draw();
418
- }, 120);
447
+ }, 80);
419
448
  this.draw();
420
449
  }
421
450
  update(text) {
@@ -424,24 +453,20 @@ export class Spinner {
424
453
  this.draw();
425
454
  }
426
455
  draw() {
427
- const rendered = mutedMarkdown(`${SPINNER_FRAMES[this.frame % SPINNER_FRAMES.length]} ${this.text}`);
456
+ const icon = SPINNER_FRAMES[this.frame % SPINNER_FRAMES.length];
457
+ const rendered = `\x1b[36m${icon}\x1b[0m \x1b[2m${this.text}\x1b[0m`;
428
458
  const newRows = Math.max(1, displayRows(rendered));
429
459
  const clearRows = Math.max(this.rows, newRows, 1);
430
- // Cursor is always at the home position (col 0) after each draw. Clear
431
- // `clearRows` lines downward from home, rewrite the content, then return
432
- // the cursor to home (col 0) so the next frame overwrites in place.
433
460
  for (let i = 0; i < clearRows; i++) {
434
461
  process.stdout.write("\r\x1b[2K");
435
462
  if (i < clearRows - 1)
436
463
  process.stdout.write("\n");
437
464
  }
438
- // Cursor is now `clearRows-1` lines below home; move back to home.
439
465
  if (clearRows > 1)
440
466
  process.stdout.write(`\x1b[${clearRows - 1}A\r`);
441
467
  else
442
468
  process.stdout.write("\r");
443
469
  process.stdout.write(rendered);
444
- // Return cursor to home (col 0) for the next draw.
445
470
  if (newRows > 1)
446
471
  process.stdout.write(`\x1b[${newRows - 1}A\r`);
447
472
  else
@@ -454,7 +479,6 @@ export class Spinner {
454
479
  this.timer = null;
455
480
  }
456
481
  if (this.enabled && this.rows > 0) {
457
- // Cursor is at home (col 0); clear the rendered rows downward.
458
482
  for (let i = 0; i < this.rows; i++) {
459
483
  process.stdout.write("\r\x1b[2K");
460
484
  if (i < this.rows - 1)
@@ -469,7 +493,7 @@ export class Spinner {
469
493
  }
470
494
  }
471
495
  // ---------------------------------------------------------------------------
472
- // Tool action formatter
496
+ // Tool Action Formatter
473
497
  // ---------------------------------------------------------------------------
474
498
  export function formatToolAction(name, argumentsJson, status = "ok") {
475
499
  let args = {};
@@ -492,19 +516,19 @@ export function formatToolAction(name, argumentsJson, status = "ok") {
492
516
  const start = args["start_line"];
493
517
  const end = args["end_line"];
494
518
  const span = start != null || end != null
495
- ? `, lines ${start ?? 1} to ${end ?? "end"}`
519
+ ? ` (lines ${start ?? 1} to ${end ?? "end"})`
496
520
  : "";
497
521
  return phrase("Read", "Reading", "Failed to read", `${p}${span}`);
498
522
  }
499
523
  case "write_file":
500
- return phrase("Wrote", "Writing", "Failed to write", `${p} (${String(args["content"] ?? "").length} chars)`);
524
+ return phrase("Wrote", "Writing", "Failed to write", `${p} (${String(args["content"] ?? "").length.toLocaleString()} chars)`);
501
525
  case "edit_file": {
502
526
  const suffix = args["replace_all"] ? " (replace all)" : "";
503
527
  return phrase("Edited", "Editing", "Failed to edit", `${p}${suffix}`);
504
528
  }
505
529
  case "bash": {
506
530
  const cwd = args["cwd"];
507
- const location = cwd ? ` in ${cwd}` : "";
531
+ const location = cwd ? ` [in ${cwd}]` : "";
508
532
  const command = truncateEllipsis(String(args["command"] ?? ""), 400, "command");
509
533
  if (status === "started")
510
534
  return `Running command${location}: ${command}`;
@@ -515,27 +539,27 @@ export function formatToolAction(name, argumentsJson, status = "ok") {
515
539
  case "glob": {
516
540
  const detail = String(args["pattern"] ?? "");
517
541
  if (status === "failed")
518
- return `Search failed for ${detail}`;
519
- return phrase("Searched for", "Searching for", "", detail);
542
+ return `Search failed for: ${detail}`;
543
+ return phrase("Searched files for", "Searching files for", "Search failed for", detail);
520
544
  }
521
545
  case "grep": {
522
546
  const detail = String(args["pattern"] ?? "");
523
547
  if (status === "failed")
524
- return `Grep failed for ${detail}`;
525
- return phrase("Grepped for", "Grepping for", "", detail);
548
+ return `Grep failed for: ${detail}`;
549
+ return phrase("Grepped for", "Grepping for", "Grep failed for", detail);
526
550
  }
527
551
  case "load_skill": {
528
552
  const skill = String(args["skill_name"] ?? "");
529
553
  return phrase("Loaded skill", "Loading skill", "Failed to load skill", skill);
530
554
  }
531
555
  default:
532
- return phrase("Invoked internal routine", "Invoking internal routine", "Internal routine failed", name);
556
+ return phrase("Executed", "Executing", "Failed to execute", name);
533
557
  }
534
558
  }
535
559
  // ---------------------------------------------------------------------------
536
- // Prompt editing / interactive input
560
+ // Prompt Editing & Interactive Input
537
561
  // ---------------------------------------------------------------------------
538
- export const PROMPT_PLACEHOLDER = "Describe a coding task, or type /help to see available commands";
562
+ export const PROMPT_PLACEHOLDER = "Describe a coding task, or type /help for commands";
539
563
  const MAX_PROMPT_DISPLAY_LINES = 12;
540
564
  const MAX_PROMPT_PASTE_CHARS = 400;
541
565
  function shouldCollapsePaste(text) {
@@ -577,13 +601,7 @@ export function splitBlocks(buffer, pasteSpans) {
577
601
  }
578
602
  return segs;
579
603
  }
580
- const PROMPT_CARET = "\u0000"; // sentinel marking the caret position
581
- /**
582
- * Wrap styled runs into physical rows of `width` visible columns, wrapping long
583
- * lines so the panel box stays rectangular (mirrors rich's Panel auto-wrap).
584
- * Returns the styled rows (no borders) and the caret's (row, col) in content
585
- * coordinates, or (-1,-1) if no caret sentinel is present.
586
- */
604
+ const PROMPT_CARET = "\u0000";
587
605
  function wrapRuns(runs, width) {
588
606
  const rows = [];
589
607
  let cur = "";
@@ -597,8 +615,6 @@ function wrapRuns(runs, width) {
597
615
  };
598
616
  for (const run of runs) {
599
617
  if (run.text === PROMPT_CARET) {
600
- // Caret occupies one visible column; if the current row is full, move it
601
- // to the start of the next row so it stays inside the box.
602
618
  if (curLen >= width)
603
619
  flush();
604
620
  caretRow = rows.length;
@@ -609,7 +625,6 @@ function wrapRuns(runs, width) {
609
625
  }
610
626
  let text = run.text;
611
627
  while (text) {
612
- // Split off the leading run up to the next newline (if any).
613
628
  const nl = text.indexOf("\n");
614
629
  const seg = nl === -1 ? text : text.slice(0, nl);
615
630
  let rest = seg;
@@ -617,9 +632,6 @@ function wrapRuns(runs, width) {
617
632
  if (curLen >= width)
618
633
  flush();
619
634
  let take = Math.min(rest.length, width - curLen);
620
- // Keep normal text readable by wrapping at a word boundary. A single
621
- // oversized token still falls back to a hard wrap so the frame stays
622
- // within its width.
623
635
  if (take < rest.length && curLen > 0) {
624
636
  const lastSpace = rest.slice(0, take).lastIndexOf(" ");
625
637
  if (lastSpace > 0)
@@ -643,32 +655,27 @@ function wrapRuns(runs, width) {
643
655
  return { rows, caretRow, caretCol };
644
656
  }
645
657
  export function renderBufferWithCursor(buffer, pasteSpans, prefix, label, cursor) {
646
- const w = terminalWidth();
647
- const borderW = Math.max(w - 4, 10);
648
- // Content width inside the box (between `│ ` and ` │`).
649
- const innerW = borderW - 2;
650
- // Build styled runs: prefix, then the text with the `▏` block caret always
651
- // drawn at the cursor position (mirrors the original rich frame).
658
+ const termW = terminalWidth();
659
+ const boxW = Math.max(termW - 4, 16);
660
+ // Content inside border: `│ ` on left (2 chars) and ` │` on right (2 chars)
661
+ const innerW = boxW - 4;
652
662
  const runs = [];
653
- runs.push({ text: prefix, style: "\x1b[1m" });
663
+ runs.push({ text: prefix, style: "\x1b[1;36m" });
654
664
  runs.push({ text: " ", style: "" });
655
665
  if (!buffer) {
656
- runs.push({ text: PROMPT_CARET, style: "\x1b[1m" });
657
- runs.push({ text: PROMPT_PLACEHOLDER, style: "\x1b[2m" });
666
+ runs.push({ text: PROMPT_CARET, style: "\x1b[1;36m" });
667
+ runs.push({ text: PROMPT_PLACEHOLDER, style: "\x1b[2;37m" });
658
668
  }
659
669
  else {
660
670
  const segs = splitBlocks(buffer, pasteSpans);
661
- // Recompute each segment's buffer range [a, b) so we can locate the cursor.
662
671
  const ranges = [];
663
672
  {
664
- let pos = 0;
665
673
  const pts = new Set([0, buffer.length]);
666
674
  for (const [s, e] of pasteSpans) {
667
675
  pts.add(s);
668
676
  pts.add(e);
669
677
  }
670
678
  const sorted = [...pts].sort((a, b) => a - b);
671
- const isPaste = (a, b) => pasteSpans.some(([s, e]) => s <= a && b <= e);
672
679
  const used = new Set();
673
680
  for (const seg of segs) {
674
681
  for (let i = 0; i < sorted.length - 1; i++) {
@@ -682,12 +689,13 @@ export function renderBufferWithCursor(buffer, pasteSpans, prefix, label, cursor
682
689
  }
683
690
  }
684
691
  }
685
- // Determine the target segment + inside offset (mirrors Python).
686
692
  let target = -1;
687
693
  let inside = 0;
688
694
  if (cursor >= buffer.length) {
689
695
  target = segs.length - 1;
690
- inside = ranges.length ? ranges[ranges.length - 1][1] - ranges[ranges.length - 1][0] : 0;
696
+ inside = ranges.length
697
+ ? ranges[ranges.length - 1][1] - ranges[ranges.length - 1][0]
698
+ : 0;
691
699
  }
692
700
  else {
693
701
  for (let i = 0; i < ranges.length; i++) {
@@ -700,7 +708,9 @@ export function renderBufferWithCursor(buffer, pasteSpans, prefix, label, cursor
700
708
  }
701
709
  if (target === -1) {
702
710
  target = segs.length - 1;
703
- inside = ranges.length ? ranges[ranges.length - 1][1] - ranges[ranges.length - 1][0] : 0;
711
+ inside = ranges.length
712
+ ? ranges[ranges.length - 1][1] - ranges[ranges.length - 1][0]
713
+ : 0;
704
714
  }
705
715
  }
706
716
  for (let i = 0; i < segs.length; i++) {
@@ -709,41 +719,44 @@ export function renderBufferWithCursor(buffer, pasteSpans, prefix, label, cursor
709
719
  runs.push({ text: " ", style: "" });
710
720
  if (i === target) {
711
721
  if (kind === "collapsed") {
712
- runs.push({ text: disp, style: "\x1b[1m\x1b[36m" });
722
+ runs.push({ text: disp, style: "\x1b[1;36m" });
713
723
  if (!endsWithWs(disp))
714
724
  runs.push({ text: " ", style: "" });
715
- runs.push({ text: PROMPT_CARET, style: "\x1b[1m" });
725
+ runs.push({ text: PROMPT_CARET, style: "\x1b[1;36m" });
716
726
  }
717
727
  else {
718
728
  const before = disp.slice(0, inside);
719
729
  const after = disp.slice(inside);
720
730
  if (before)
721
731
  runs.push({ text: before, style: "" });
722
- runs.push({ text: PROMPT_CARET, style: "\x1b[1m" });
732
+ runs.push({ text: PROMPT_CARET, style: "\x1b[1;36m" });
723
733
  if (after)
724
734
  runs.push({ text: after, style: "" });
725
735
  }
726
736
  }
727
737
  else {
728
- runs.push({ text: disp, style: kind === "collapsed" ? "\x1b[1m\x1b[36m" : "" });
738
+ runs.push({
739
+ text: disp,
740
+ style: kind === "collapsed" ? "\x1b[1;36m" : "",
741
+ });
729
742
  }
730
743
  }
731
744
  }
732
745
  const { rows, caretRow, caretCol } = wrapRuns(runs, innerW);
733
- // Top border with the label embedded on the left.
734
- const topPad = Math.max(borderW - label.length - 3, 0);
735
- const top = `\x1b[90m╭─ ${label} ${"─".repeat(topPad)}╮\x1b[0m`;
736
- // Body rows.
746
+ // Math for exact box alignment:
747
+ // Top: `╭─ ` (3 chars) + `label` (L chars) + ` ` (1 char) + topPad + `╮` (1 char) = boxW
748
+ const labelLen = plainLen(label);
749
+ const topPad = Math.max(0, boxW - labelLen - 5);
750
+ const top = `\x1b[90m╭─ \x1b[1m\x1b[36m${label}\x1b[0m\x1b[90m ${"─".repeat(topPad)}╮\x1b[0m`;
737
751
  const body = [];
738
752
  for (const l of rows) {
739
- const plain = l.replace(/\x1b\[[0-9;]*m/g, "");
740
- const pad = Math.max(borderW - 2 - plain.length, 0);
753
+ const plain = stripAnsi(l);
754
+ const pad = Math.max(0, innerW - plain.length);
741
755
  body.push(`\x1b[90m│\x1b[0m ${l}${" ".repeat(pad)} \x1b[90m│\x1b[0m`);
742
756
  }
743
- const bottom = `\x1b[90m╰${"─".repeat(borderW)}╯\x1b[0m`;
757
+ // Bottom: `╰` (1 char) + `─` * (boxW - 2) + `╯` (1 char) = boxW
758
+ const bottom = `\x1b[90m╰${"─".repeat(Math.max(0, boxW - 2))}╯\x1b[0m`;
744
759
  const frame = [top, ...body, bottom].join("\n");
745
- // Cursor placement: one row below the top border. The body prefix (`│ `)
746
- // shifts the content right by 2 columns, so add 2 to the wrapped caret col.
747
760
  const cursorRow = (caretRow === -1 ? 0 : caretRow) + 1;
748
761
  const cursorCol = (caretCol === -1 ? 0 : caretCol) + 2;
749
762
  const totalRows = body.length + 2;
@@ -753,14 +766,14 @@ export function askBottomPrompt(label = "You", prefix = "❯", history = []) {
753
766
  return new Promise((resolve, reject) => {
754
767
  const isTTY = process.stdin.isTTY;
755
768
  if (!isTTY) {
756
- // Non-interactive: read a line from stdin
757
769
  const chunks = [];
758
770
  process.stdin.setEncoding("utf-8");
759
771
  process.stdin.on("data", (d) => chunks.push(Buffer.from(String(d))));
760
772
  process.stdin.on("end", () => {
761
- const text = Buffer.concat(chunks).toString().replace(/\r\n/g, "\n").replace(/\r/g, "\n");
762
- // Piped input commonly ends with the transport newline from the
763
- // shell. Preserve every newline the user supplied before it.
773
+ const text = Buffer.concat(chunks)
774
+ .toString()
775
+ .replace(/\r\n/g, "\n")
776
+ .replace(/\r/g, "\n");
764
777
  resolve([text.endsWith("\n") ? text.slice(0, -1) : text, []]);
765
778
  });
766
779
  return;
@@ -778,12 +791,7 @@ export function askBottomPrompt(label = "You", prefix = "❯", history = []) {
778
791
  readline.emitKeypressEvents(process.stdin);
779
792
  if (process.stdin.isTTY) {
780
793
  process.stdin.setRawMode(true);
781
- // Ask the terminal to wrap clipboard input in explicit paste markers.
782
- // This makes embedded newlines unambiguous instead of relying on timing.
783
794
  process.stdout.write("\x1b[?2004h");
784
- // Enable the kitty keyboard protocol (CSI u) so Windows Terminal / modern
785
- // terminals send distinct sequences for Shift+Enter / Ctrl+Enter
786
- // (\x1b[13;<mod>u) instead of an indistinguishable plain \r.
787
795
  process.stdout.write("\x1b[>1u");
788
796
  }
789
797
  process.stdin.resume();
@@ -792,11 +800,6 @@ export function askBottomPrompt(label = "You", prefix = "❯", history = []) {
792
800
  let lastCursorRow = 0;
793
801
  let lastTotalRows = 0;
794
802
  let prevFrameLines = null;
795
- // Erase the prompt region: a leading blank line + the box. Cursor sits at
796
- // lastCursorRow inside the box; move up to the box's top border, erase the
797
- // box rows, then erase the leading blank line, leaving the cursor on that
798
- // blank row. The caller's single leading newline then yields exactly ONE
799
- // blank row before the echoed user prompt / message.
800
803
  const clearBox = () => {
801
804
  process.stdout.write(`\x1b[${lastCursorRow}A`);
802
805
  for (let i = 0; i < lastTotalRows; i++) {
@@ -828,11 +831,6 @@ export function askBottomPrompt(label = "You", prefix = "❯", history = []) {
828
831
  const { frame, cursorRow, cursorCol, totalRows } = renderBufferWithCursor(buffer, pasteSpans, prefix, label, cursor);
829
832
  const newLines = frame.split("\n");
830
833
  if (isFirst) {
831
- // The caller's content always ends with "\n", so the cursor sits at the
832
- // start of a fresh (blank) line — that line is the leading blank
833
- // separator (mirrors the original's Group(Text(""), panel)). Move down
834
- // one line so the box starts below that blank; do NOT emit a second
835
- // blank here (it caused a double gap above the box / on cancel).
836
834
  process.stdout.write("\n");
837
835
  for (let i = 0; i < newLines.length; i++) {
838
836
  process.stdout.write(newLines[i]);
@@ -842,16 +840,10 @@ export function askBottomPrompt(label = "You", prefix = "❯", history = []) {
842
840
  prevFrameLines = newLines;
843
841
  lastCursorRow = cursorRow;
844
842
  lastTotalRows = totalRows;
845
- // Cursor sits on the bottom border line; move it up to the input row.
846
843
  const up = totalRows - 1 - cursorRow;
847
844
  process.stdout.write(`\x1b[${up}A\r\x1b[${cursorCol}C`);
848
- return;
849
845
  }
850
846
  else {
851
- // Cursor currently sits at the previous input position (lastCursorRow
852
- // inside the box). Move up to the top border, then rewrite ONLY the
853
- // lines whose content actually changed, leaving the static borders
854
- // untouched — this avoids the whole-box flicker on every keystroke.
855
847
  process.stdout.write(`\x1b[${lastCursorRow}A\r`);
856
848
  const oldLines = prevFrameLines ?? [];
857
849
  const maxLen = Math.max(oldLines.length, newLines.length);
@@ -862,8 +854,6 @@ export function askBottomPrompt(label = "You", prefix = "❯", history = []) {
862
854
  if (i < maxLen - 1)
863
855
  process.stdout.write("\n");
864
856
  }
865
- // Cursor now sits at line (maxLen-1), column 0. Move it to the target
866
- // input row (cursorRow), handling line-count changes in either direction.
867
857
  const atLine = maxLen - 1;
868
858
  if (atLine > cursorRow)
869
859
  process.stdout.write(`\x1b[${atLine - cursorRow}A`);
@@ -873,7 +863,6 @@ export function askBottomPrompt(label = "You", prefix = "❯", history = []) {
873
863
  prevFrameLines = newLines;
874
864
  lastCursorRow = cursorRow;
875
865
  lastTotalRows = totalRows;
876
- return;
877
866
  }
878
867
  };
879
868
  const shiftSpansAfterInsert = (pos, delta) => {
@@ -897,12 +886,7 @@ export function askBottomPrompt(label = "You", prefix = "❯", history = []) {
897
886
  }
898
887
  const pasteStart = cursor;
899
888
  buffer = buffer.slice(0, cursor) + text + buffer.slice(cursor);
900
- // Keep existing paste spans valid across the insertion point.
901
889
  pasteSpans = shiftSpansAfterInsert(cursor, text.length);
902
- // A real paste records a span so the prompt box can collapse it (mirrors
903
- // Python's handle_paste). splitBlocks only collapses segments inside a
904
- // span that meet the threshold, so small pastes stay expanded but remain
905
- // tagged for cursor/backspace handling.
906
890
  if (isPaste) {
907
891
  pasteSpans.push([pasteStart, pasteStart + text.length]);
908
892
  pasteSpans.sort((a, b) => a[0] - b[0]);
@@ -916,9 +900,6 @@ export function askBottomPrompt(label = "You", prefix = "❯", history = []) {
916
900
  draft = { buffer, cursor, spans: pasteSpans.slice() };
917
901
  histIdx = hist.length;
918
902
  }
919
- // A collapsed paste is one editable unit. Backspace removes the entire
920
- // pasted block, matching the Python prompt rather than deleting only its
921
- // final character.
922
903
  const containing = pasteSpans.find(([s, e]) => s < cursor && cursor <= e);
923
904
  if (containing) {
924
905
  const [start, end] = containing;
@@ -930,14 +911,11 @@ export function askBottomPrompt(label = "You", prefix = "❯", history = []) {
930
911
  cursor = start;
931
912
  return;
932
913
  }
933
- // remove one grapheme before cursor
934
914
  const before = Array.from(buffer.slice(0, cursor));
935
915
  before.pop();
936
916
  buffer = before.join("") + buffer.slice(cursor);
937
917
  const deletedAt = cursor - 1;
938
918
  cursor -= 1;
939
- // Adjust spans: shrink any span covering the deleted char, shift spans
940
- // that start after it left by one.
941
919
  const adjusted = [];
942
920
  for (const [s, e] of pasteSpans) {
943
921
  if (deletedAt >= s && deletedAt < e) {
@@ -957,8 +935,6 @@ export function askBottomPrompt(label = "You", prefix = "❯", history = []) {
957
935
  const moveLeft = () => {
958
936
  if (cursor <= 0)
959
937
  return;
960
- // Jump the caret to the start of a paste span it sits just past (mirrors
961
- // Python), so a collapsed block is treated as one unit.
962
938
  for (const [s, e] of pasteSpans) {
963
939
  if (s < cursor && cursor <= e) {
964
940
  cursor = s;
@@ -980,7 +956,6 @@ export function askBottomPrompt(label = "You", prefix = "❯", history = []) {
980
956
  };
981
957
  const moveUp = () => {
982
958
  if (!buffer.includes("\n")) {
983
- // Single row: navigate command history.
984
959
  if (!hist.length)
985
960
  return;
986
961
  if (histIdx === hist.length) {
@@ -994,7 +969,6 @@ export function askBottomPrompt(label = "You", prefix = "❯", history = []) {
994
969
  }
995
970
  return;
996
971
  }
997
- // Multi row: move the caret up to the previous line, preserving column.
998
972
  if (cursor > 0) {
999
973
  let lineStart = buffer.lastIndexOf("\n", cursor - 1);
1000
974
  lineStart = lineStart !== -1 ? lineStart : 0;
@@ -1006,7 +980,6 @@ export function askBottomPrompt(label = "You", prefix = "❯", history = []) {
1006
980
  };
1007
981
  const moveDown = () => {
1008
982
  if (!buffer.includes("\n")) {
1009
- // Single row: navigate command history.
1010
983
  if (histIdx === hist.length || !draft)
1011
984
  return;
1012
985
  histIdx += 1;
@@ -1022,7 +995,6 @@ export function askBottomPrompt(label = "You", prefix = "❯", history = []) {
1022
995
  }
1023
996
  return;
1024
997
  }
1025
- // Multi row: move the caret down to the next line, preserving column.
1026
998
  if (cursor < buffer.length) {
1027
999
  const [, col] = cursorLineCol(buffer, cursor);
1028
1000
  let nextStart = buffer.indexOf("\n", cursor);
@@ -1040,23 +1012,17 @@ export function askBottomPrompt(label = "You", prefix = "❯", history = []) {
1040
1012
  const isNewlineKey = (key) => {
1041
1013
  if (!key)
1042
1014
  return false;
1043
- // Bare \n / Ctrl+J = newline.
1044
1015
  if (key.ctrl && key.name === "j")
1045
1016
  return true;
1046
1017
  if (key.name === "enter")
1047
1018
  return !!(key.shift || key.ctrl || key.meta);
1048
- // Enter with a modifier (Shift/Ctrl/Alt/Meta) = newline.
1049
1019
  if (key.name === "return")
1050
1020
  return !!(key.shift || key.ctrl || key.meta);
1051
- // Kitty-keyboard-protocol / CSI-u modified Enter: \x1b[13;<m>u where
1052
- // <m> is a modifier bitmask (2=Shift, 3=Alt, 5=Ctrl, 6=Shift+Ctrl, ...).
1053
- // Also the legacy \x1b[13;<m>~ form. ANY modifier > 1 on Enter = newline.
1054
1021
  const seq = key.sequence || "";
1055
1022
  if (/^\x1b\[13;([2-9]|\d{2,})u$/.test(seq))
1056
1023
  return true;
1057
1024
  if (/^\x1b\[13;([2-9]|\d{2,})~$/.test(seq))
1058
1025
  return true;
1059
- // Legacy Shift+Enter decoded by Node as F3 with shift.
1060
1026
  if (key.name === "f3" && key.shift)
1061
1027
  return true;
1062
1028
  return false;
@@ -1064,23 +1030,20 @@ export function askBottomPrompt(label = "You", prefix = "❯", history = []) {
1064
1030
  const isModifiedEnterInput = (str, key) => {
1065
1031
  const value = str || "";
1066
1032
  const sequence = key?.sequence || "";
1067
- // Some Windows terminals do not preserve modifier flags when readline
1068
- // decodes Ctrl+J or Ctrl/Shift+Enter. Inspect the original bytes too.
1069
1033
  if (value === "\n" || value === "\x0a")
1070
1034
  return true;
1071
1035
  if (key?.name === "linefeed" || (key?.ctrl && key?.name === "j"))
1072
1036
  return true;
1073
- return /^\x1b\[13;(?:[2-9]|\d{2,})(?:u|~)$/.test(value) ||
1074
- /^\x1b\[13;(?:[2-9]|\d{2,})(?:u|~)$/.test(sequence);
1037
+ return (/^\x1b\[13;(?:[2-9]|\d{2,})(?:u|~)$/.test(value) ||
1038
+ /^\x1b\[13;(?:[2-9]|\d{2,})(?:u|~)$/.test(sequence));
1075
1039
  };
1076
- // True for a plain (unmodified) Enter: Node's "return" without modifiers,
1077
- // or the kitty-protocol encodings \x1b[13u / \x1b[13;1u (modifier flags = 1
1078
- // meaning none).
1079
1040
  const isSubmitEnterKey = (key) => {
1080
1041
  if (!key)
1081
1042
  return false;
1082
- if ((key.name === "return" || key.name === "enter") && !(key.shift || key.ctrl || key.meta))
1043
+ if ((key.name === "return" || key.name === "enter") &&
1044
+ !(key.shift || key.ctrl || key.meta)) {
1083
1045
  return true;
1046
+ }
1084
1047
  const seq = key.sequence || "";
1085
1048
  if (seq === "\x1b[13u" || /^\x1b\[13;1u$/.test(seq))
1086
1049
  return true;
@@ -1094,12 +1057,19 @@ export function askBottomPrompt(label = "You", prefix = "❯", history = []) {
1094
1057
  if (!bracketedPaste && (sequence === start || value === start)) {
1095
1058
  bracketedPaste = true;
1096
1059
  bracketedPasteBuffer = "";
1097
- const inline = value === start ? "" : value.startsWith(start) ? value.slice(start.length) : "";
1060
+ const inline = value === start
1061
+ ? ""
1062
+ : value.startsWith(start)
1063
+ ? value.slice(start.length)
1064
+ : "";
1098
1065
  if (inline)
1099
1066
  bracketedPasteBuffer = inline;
1100
1067
  if (inline.includes(end)) {
1101
1068
  const endAt = inline.indexOf(end);
1102
- const pasted = inline.slice(0, endAt).replace(/\r\n/g, "\n").replace(/\r/g, "\n");
1069
+ const pasted = inline
1070
+ .slice(0, endAt)
1071
+ .replace(/\r\n/g, "\n")
1072
+ .replace(/\r/g, "\n");
1103
1073
  bracketedPaste = false;
1104
1074
  bracketedPasteBuffer = "";
1105
1075
  if (pasted)
@@ -1111,7 +1081,9 @@ export function askBottomPrompt(label = "You", prefix = "❯", history = []) {
1111
1081
  if (!bracketedPaste)
1112
1082
  return false;
1113
1083
  if (sequence === end || value === end) {
1114
- const pasted = bracketedPasteBuffer.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
1084
+ const pasted = bracketedPasteBuffer
1085
+ .replace(/\r\n/g, "\n")
1086
+ .replace(/\r/g, "\n");
1115
1087
  bracketedPaste = false;
1116
1088
  bracketedPasteBuffer = "";
1117
1089
  if (pasted)
@@ -1119,12 +1091,15 @@ export function askBottomPrompt(label = "You", prefix = "❯", history = []) {
1119
1091
  repaint();
1120
1092
  return true;
1121
1093
  }
1122
- const chunk = value || (sequence && !sequence.startsWith("\x1b[") ? sequence : "");
1094
+ const chunk = value ||
1095
+ (sequence && !sequence.startsWith("\x1b[") ? sequence : "");
1123
1096
  if (chunk) {
1124
1097
  const endAt = chunk.indexOf(end);
1125
1098
  if (endAt >= 0) {
1126
1099
  bracketedPasteBuffer += chunk.slice(0, endAt);
1127
- const pasted = bracketedPasteBuffer.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
1100
+ const pasted = bracketedPasteBuffer
1101
+ .replace(/\r\n/g, "\n")
1102
+ .replace(/\r/g, "\n");
1128
1103
  bracketedPaste = false;
1129
1104
  bracketedPasteBuffer = "";
1130
1105
  if (pasted)
@@ -1149,9 +1124,6 @@ export function askBottomPrompt(label = "You", prefix = "❯", history = []) {
1149
1124
  const onKeypress = (str, key) => {
1150
1125
  if (consumeBracketedPaste(str, key))
1151
1126
  return;
1152
- // A normal typed character after an unbracketed paste ends the fallback
1153
- // window, so the next Enter can submit immediately. Multi-character
1154
- // clipboard chunks keep the window alive until their rows arrive.
1155
1127
  if (pasteBurst && str && str.length === 1 && !key?.ctrl && !key?.meta) {
1156
1128
  pasteBurst = false;
1157
1129
  if (pasteBurstTimer)
@@ -1166,18 +1138,13 @@ export function askBottomPrompt(label = "You", prefix = "❯", history = []) {
1166
1138
  settle(new Error("eof"), true);
1167
1139
  return;
1168
1140
  }
1169
- // Any modified Enter (Shift/Ctrl+Enter) inserts a newline. This check
1170
- // must happen before plain submit detection because some terminals expose
1171
- // Shift+Enter as `return` while keeping the modifier only in the escape
1172
- // sequence.
1173
- if (isNewlineKey(key) || isModifiedEnterInput(str, key) || (pasteBurst && isSubmitEnterKey(key))) {
1141
+ if (isNewlineKey(key) ||
1142
+ isModifiedEnterInput(str, key) ||
1143
+ (pasteBurst && isSubmitEnterKey(key))) {
1174
1144
  insert("\n");
1175
1145
  repaint();
1176
1146
  return;
1177
1147
  }
1178
- // Plain Enter submits; any modified Enter (Shift/Ctrl+Enter) inserts a
1179
- // newline, mirroring the Python original's _read_input_event (which maps
1180
- // \x1b[13;2u / \x1b[13;5u / Shift+\r to "\n").
1181
1148
  if (isSubmitEnterKey(key)) {
1182
1149
  if (buffer.trim()) {
1183
1150
  settle([buffer, pasteSpans], false);
@@ -1210,7 +1177,7 @@ export function askBottomPrompt(label = "You", prefix = "❯", history = []) {
1210
1177
  return;
1211
1178
  }
1212
1179
  if (key && key.name === "tab") {
1213
- insert(" ");
1180
+ insert(" ");
1214
1181
  repaint();
1215
1182
  return;
1216
1183
  }
@@ -1220,8 +1187,6 @@ export function askBottomPrompt(label = "You", prefix = "❯", history = []) {
1220
1187
  return;
1221
1188
  }
1222
1189
  if (str) {
1223
- // Multi-char sequence = a paste (single-char typed keys arrive one per
1224
- // event). Tag it so the prompt box collapses it when it meets the rules.
1225
1190
  const isPaste = str.length > 1;
1226
1191
  insert(str.replace(/\r\n/g, "\n").replace(/\r/g, "\n"), isPaste);
1227
1192
  if (isPaste)
@@ -1234,14 +1199,9 @@ export function askBottomPrompt(label = "You", prefix = "❯", history = []) {
1234
1199
  });
1235
1200
  }
1236
1201
  // ---------------------------------------------------------------------------
1237
- // User display + message text
1202
+ // User Display & Message Text Helpers
1238
1203
  // ---------------------------------------------------------------------------
1239
1204
  export function userDisplayText(payload, pasteSpans) {
1240
- // Mirror the prompt field exactly: collapse ONLY segments that fall inside a
1241
- // paste span and exceed the collapse threshold (via splitBlocks). Content that
1242
- // the user typed (no paste span) is never collapsed in the prompt, so it must
1243
- // not be collapsed here either — keeping the echoed message consistent with
1244
- // what the prompt box displayed.
1245
1205
  if (pasteSpans && pasteSpans.length) {
1246
1206
  const segs = splitBlocks(payload, pasteSpans);
1247
1207
  let out = "";
@@ -1249,7 +1209,7 @@ export function userDisplayText(payload, pasteSpans) {
1249
1209
  const [, kind, disp] = segs[i];
1250
1210
  if (i && !endsWithWs(segs[i - 1][2]))
1251
1211
  out += " ";
1252
- out += kind === "collapsed" ? `\x1b[1m\x1b[36m${disp}\x1b[0m` : disp;
1212
+ out += kind === "collapsed" ? `\x1b[1;36m${disp}\x1b[0m` : disp;
1253
1213
  }
1254
1214
  return out;
1255
1215
  }