@oxecli/oxe 1.0.51 → 1.0.53

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
@@ -9,8 +9,6 @@ export function enableRawStdin() {
9
9
  readline.emitKeypressEvents(process.stdin);
10
10
  if (process.stdin.isTTY) {
11
11
  process.stdin.setRawMode(true);
12
- // Enable the kitty keyboard protocol (CSI u) so modified Enter (Shift /
13
- // Ctrl+Enter) arrives as \x1b[13;<mod>u rather than a plain \r.
14
12
  process.stdout.write("\x1b[>1u");
15
13
  }
16
14
  process.stdin.resume();
@@ -28,10 +26,6 @@ export function disableRawStdin() {
28
26
  catch {
29
27
  /* ignore */
30
28
  }
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
29
  showCursor();
36
30
  }
37
31
  }
@@ -40,7 +34,7 @@ export function waitRawKey() {
40
34
  return new Promise((resolve) => {
41
35
  const onKeypress = (str, key) => {
42
36
  process.stdin.removeListener("keypress", onKeypress);
43
- resolve({ name: key?.name ?? "", str: str ?? "", ctrl: !!(key?.ctrl) });
37
+ resolve({ name: key?.name ?? "", str: str ?? "", ctrl: !!key?.ctrl });
44
38
  };
45
39
  process.stdin.on("keypress", onKeypress);
46
40
  });
@@ -52,22 +46,25 @@ export function isWindows() {
52
46
  return process.platform === "win32";
53
47
  }
54
48
  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.
49
+ // Modern Windows Terminal and host consoles support ANSI automatically.
57
50
  }
58
51
  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
- }
52
+ process.stdout.write("\x1b[2J\x1b[3J\x1b[H");
66
53
  }
67
54
  export function terminalWidth() {
68
55
  return process.stdout.columns || 80;
69
56
  }
70
57
  // ---------------------------------------------------------------------------
58
+ // ANSI and plain text length helpers
59
+ // ---------------------------------------------------------------------------
60
+ 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;
61
+ export function stripAnsi(s) {
62
+ return s.replace(ANSI_RE, "");
63
+ }
64
+ export function plainLen(s) {
65
+ return stripAnsi(s).length;
66
+ }
67
+ // ---------------------------------------------------------------------------
71
68
  // Markup tag -> ANSI
72
69
  // ---------------------------------------------------------------------------
73
70
  const TAG_MAP = {
@@ -82,9 +79,17 @@ const TAG_MAP = {
82
79
  magenta: "\x1b[35m",
83
80
  cyan: "\x1b[36m",
84
81
  white: "\x1b[37m",
82
+ bright_black: "\x1b[90m",
83
+ bright_red: "\x1b[91m",
84
+ bright_green: "\x1b[92m",
85
+ bright_yellow: "\x1b[93m",
86
+ bright_blue: "\x1b[94m",
87
+ bright_magenta: "\x1b[95m",
88
+ bright_cyan: "\x1b[96m",
89
+ bright_white: "\x1b[97m",
85
90
  };
86
91
  const RESET = "\x1b[0m";
87
- const MARKUP_RE = /\[(\/?)([a-z0-9 ]+)\]/gi;
92
+ const MARKUP_RE = /\[(\/?)([a-z0-9_ ]+)\]/gi;
88
93
  export function escapeMarkup(text) {
89
94
  return text.replace(/\[/g, "\\[").replace(/\]/g, "\\]");
90
95
  }
@@ -115,90 +120,214 @@ export function markupToAnsi(text) {
115
120
  });
116
121
  }
117
122
  // ---------------------------------------------------------------------------
118
- // Lightweight markdown -> ANSI
123
+ // Syntax Highlighter for Code Blocks
119
124
  // ---------------------------------------------------------------------------
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
- }
125
+ const KEYWORDS = new Set([
126
+ "const", "let", "var", "function", "class", "import", "export", "from",
127
+ "return", "if", "else", "for", "while", "do", "switch", "case", "break",
128
+ "continue", "async", "await", "try", "catch", "finally", "throw", "new",
129
+ "typeof", "instanceof", "interface", "type", "enum", "extends", "implements",
130
+ "public", "private", "protected", "readonly", "static", "def", "self",
131
+ "lambda", "with", "as", "yield", "pass", "None", "True", "False", "null",
132
+ "undefined", "true", "false", "fn", "struct", "pub", "impl", "mut", "use",
133
+ ]);
134
+ function highlightCodeLine(line, _lang = "") {
135
+ if (!line)
136
+ return "";
137
+ if (/^\s*(\/\/|#|\/\*)/.test(line)) {
138
+ return `\x1b[2;37m${line}\x1b[0m`;
139
+ }
140
+ return line.replace(/("(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|`(?:\\.|[^`\\])*`)|(\b\d+\.?\d*\b)|(\b[a-zA-Z_$][\w$]*\b)|(\/\/[^\n]*|#[^\n]*)/g, (m, str, num, ident, comment) => {
141
+ if (str)
142
+ return `\x1b[32m${str}\x1b[0m`;
143
+ if (num)
144
+ return `\x1b[33m${num}\x1b[0m`;
145
+ if (comment)
146
+ return `\x1b[2;37m${comment}\x1b[0m`;
147
+ if (ident && KEYWORDS.has(ident))
148
+ return `\x1b[1;36m${ident}\x1b[0m`;
149
+ return m;
150
+ });
151
+ }
152
+ function formatInlineMarkdown(text) {
153
+ let line = text;
154
+ line = line.replace(/\*\*\*([^*]+)\*\*\*/g, "\x1b[1;3m$1\x1b[0m");
155
+ line = line.replace(/\*\*([^*]+)\*\*/g, "\x1b[1m$1\x1b[0m");
156
+ line = line.replace(/(^|[^\w*])\*([^*\n]+)\*(?=$|[^\w*])/g, "$1\x1b[3m$2\x1b[0m");
157
+ line = line.replace(/`([^`]+)`/g, "\x1b[1;36m`$1`\x1b[0m");
158
+ line = line.replace(/\[([^\]]+)\]\(([^)]+)\)/g, "\x1b[4;36m$1\x1b[0m \x1b[2m($2)\x1b[0m");
159
+ return line;
160
+ }
161
+ // ---------------------------------------------------------------------------
162
+ // Markdown Table Formatter
163
+ // ---------------------------------------------------------------------------
164
+ function formatMarkdownTable(tableLines) {
165
+ if (tableLines.length < 2)
166
+ return tableLines;
167
+ const parseRow = (row) => row
168
+ .replace(/^\||\|$/g, "")
169
+ .split("|")
170
+ .map((c) => c.trim());
171
+ const header = parseRow(tableLines[0]);
172
+ const rows = tableLines.slice(2).map(parseRow);
173
+ const allRows = [header, ...rows];
174
+ const colWidths = [];
175
+ for (let c = 0; c < header.length; c++) {
176
+ let mw = 0;
177
+ for (const r of allRows) {
178
+ if (r[c])
179
+ mw = Math.max(mw, plainLen(formatInlineMarkdown(r[c])));
151
180
  }
152
- if (current || !wrapped.length)
153
- wrapped.push(current);
154
- return wrapped;
181
+ colWidths.push(Math.max(mw, 3));
182
+ }
183
+ const out = [];
184
+ const topBorder = `\x1b[90m┌${colWidths.map((w) => "─".repeat(w + 2)).join("┬")}┐\x1b[0m`;
185
+ const midBorder = `\x1b[90m├${colWidths.map((w) => "─".repeat(w + 2)).join("┼")}┤\x1b[0m`;
186
+ const botBorder = `\x1b[90m└${colWidths.map((w) => "─".repeat(w + 2)).join("┴")}┘\x1b[0m`;
187
+ out.push(topBorder);
188
+ const headCells = header.map((h, i) => {
189
+ const formatted = `\x1b[1;37m${formatInlineMarkdown(h)}\x1b[0m`;
190
+ const pad = colWidths[i] - plainLen(formatted);
191
+ return ` ${formatted}${" ".repeat(Math.max(pad, 0))} `;
155
192
  });
193
+ out.push(`\x1b[90m│\x1b[0m${headCells.join("\x1b[90m│\x1b[0m")}\x1b[90m│\x1b[0m`);
194
+ out.push(midBorder);
195
+ for (const r of rows) {
196
+ const rowCells = header.map((_, i) => {
197
+ const cell = r[i] ? formatInlineMarkdown(r[i]) : "";
198
+ const pad = colWidths[i] - plainLen(cell);
199
+ return ` ${cell}${" ".repeat(Math.max(pad, 0))} `;
200
+ });
201
+ out.push(`\x1b[90m│\x1b[0m${rowCells.join("\x1b[90m│\x1b[0m")}\x1b[90m│\x1b[0m`);
202
+ }
203
+ out.push(botBorder);
204
+ return out;
205
+ }
206
+ // ---------------------------------------------------------------------------
207
+ // Rich Markdown -> ANSI Terminal Renderer
208
+ // ---------------------------------------------------------------------------
209
+ export function markdownToAnsi(text) {
210
+ const width = Math.max(terminalWidth() - 2, 40);
211
+ const rawLines = text.split("\n");
156
212
  const out = [];
157
213
  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 = [];
214
+ let codeLang = "";
215
+ let codeBuffer = [];
216
+ let tableBuffer = [];
217
+ const flushCodeBlock = () => {
218
+ if (!codeBuffer.length && !codeLang)
219
+ return;
220
+ const badge = codeLang ? ` \x1b[1;36m${codeLang}\x1b[0m ` : " ";
221
+ const barLen = Math.max(10, Math.min(width - plainLen(badge) - 4, 60));
222
+ out.push(`\x1b[90m┌─${badge}${"─".repeat(barLen)}┐\x1b[0m`);
223
+ for (const cline of codeBuffer) {
224
+ const highlighted = highlightCodeLine(cline, codeLang);
225
+ out.push(`\x1b[90m│\x1b[0m ${highlighted}`);
163
226
  }
227
+ out.push(`\x1b[90m└${"─".repeat(barLen + plainLen(badge) + 2)}┘\x1b[0m`);
228
+ codeBuffer = [];
229
+ codeLang = "";
164
230
  };
165
- for (const raw of lines) {
166
- const line = raw;
167
- const fence = line.match(/^(`{3,})/);
168
- if (fence) {
231
+ const flushTable = () => {
232
+ if (!tableBuffer.length)
233
+ return;
234
+ const formatted = formatMarkdownTable(tableBuffer);
235
+ out.push(...formatted);
236
+ tableBuffer = [];
237
+ };
238
+ for (let i = 0; i < rawLines.length; i++) {
239
+ const raw = rawLines[i];
240
+ const fenceMatch = raw.match(/^(`{3,})(.*)$/);
241
+ if (fenceMatch) {
242
+ flushTable();
169
243
  if (!inCode) {
170
244
  inCode = true;
245
+ codeLang = fenceMatch[2].trim().toLowerCase();
246
+ codeBuffer = [];
171
247
  continue;
172
248
  }
173
249
  else {
174
250
  inCode = false;
175
- flushCode();
251
+ flushCodeBlock();
176
252
  continue;
177
253
  }
178
254
  }
179
255
  if (inCode) {
180
- codeBuf.push(line);
256
+ codeBuffer.push(raw);
257
+ continue;
258
+ }
259
+ // Markdown Table rows
260
+ if (/^\s*\|.*\|\s*$/.test(raw)) {
261
+ tableBuffer.push(raw.trim());
262
+ continue;
263
+ }
264
+ else if (tableBuffer.length) {
265
+ flushTable();
266
+ }
267
+ // Horizontal rules
268
+ if (/^(\*{3,}|-{3,}|_{3,})$/.test(raw.trim())) {
269
+ out.push(`\x1b[90m${"─".repeat(Math.min(width, 60))}\x1b[0m`);
270
+ continue;
271
+ }
272
+ let line = raw;
273
+ // Headers
274
+ const h1 = line.match(/^#\s+(.+)$/);
275
+ if (h1) {
276
+ out.push(`\x1b[1;36m# ${formatInlineMarkdown(h1[1])}\x1b[0m`);
277
+ continue;
278
+ }
279
+ const h2 = line.match(/^##\s+(.+)$/);
280
+ if (h2) {
281
+ out.push(`\x1b[1;34m## ${formatInlineMarkdown(h2[1])}\x1b[0m`);
181
282
  continue;
182
283
  }
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`;
284
+ const h3 = line.match(/^###\s+(.+)$/);
285
+ if (h3) {
286
+ out.push(`\x1b[1;37m### ${formatInlineMarkdown(h3[1])}\x1b[0m`);
287
+ continue;
288
+ }
289
+ // Blockquote
290
+ const bq = line.match(/^>\s*(.+)$/);
291
+ if (bq) {
292
+ out.push(`\x1b[90m│\x1b[0m \x1b[3m${formatInlineMarkdown(bq[1])}\x1b[0m`);
293
+ continue;
294
+ }
295
+ // Unordered List item
296
+ line = line.replace(/^(\s*)[-*+]\s+(.+)$/, (_, indent, item) => {
297
+ return `${indent}\x1b[36m•\x1b[0m ${formatInlineMarkdown(item)}`;
298
+ });
299
+ // Ordered List item
300
+ line = line.replace(/^(\s*)(\d+)\.\s+(.+)$/, (_, indent, num, item) => {
301
+ return `${indent}\x1b[36m${num}.\x1b[0m ${formatInlineMarkdown(item)}`;
302
+ });
303
+ // Inline formatting if not a list
304
+ if (!line.includes("\x1b[36m•\x1b[0m") && !line.includes(".\x1b[0m ")) {
305
+ line = formatInlineMarkdown(line);
306
+ }
307
+ // Word wrap paragraph line if too long
308
+ if (plainLen(line) > width && !line.startsWith(" ")) {
309
+ const words = line.split(" ");
310
+ let cur = "";
311
+ for (const w of words) {
312
+ if (plainLen(cur) + plainLen(w) + 1 > width) {
313
+ if (cur)
314
+ out.push(cur);
315
+ cur = w;
316
+ }
317
+ else {
318
+ cur = cur ? `${cur} ${w}` : w;
319
+ }
320
+ }
321
+ if (cur)
322
+ out.push(cur);
323
+ }
324
+ else {
325
+ out.push(line);
194
326
  }
195
- // links
196
- s = s.replace(/\[([^\]]+)\]\([^)]+\)/g, "$1");
197
- // leading list markers
198
- s = s.replace(/^(\s*)([-*+]\s)/, "$1· ");
199
- out.push(s);
200
327
  }
201
- flushCode();
328
+ flushTable();
329
+ if (inCode)
330
+ flushCodeBlock();
202
331
  return out.join("\n");
203
332
  }
204
333
  export function aiMarkdown(text) {
@@ -212,39 +341,29 @@ export function mutedMarkdown(text) {
212
341
  return markdownToAnsi(dimmed);
213
342
  }
214
343
  // ---------------------------------------------------------------------------
215
- // Panel
344
+ // Panel Rendering
216
345
  // ---------------------------------------------------------------------------
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") {
346
+ function panelString(content, title = "", subtitle = "", expand = true, borderStyle = "90", titleAlign = "center") {
227
347
  const styled = markupToAnsi(content);
228
348
  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;
349
+ const plainLines = styledLines.map((l) => stripAnsi(l));
350
+ let maxLineW = 0;
232
351
  for (const p of plainLines)
233
- contentW = Math.max(contentW, p.length);
234
- contentW = Math.max(contentW, 1);
235
- let innerW = contentW + 2;
352
+ maxLineW = Math.max(maxLineW, p.length);
236
353
  if (title)
237
- innerW = Math.max(innerW, plainLen(title) + 2);
354
+ maxLineW = Math.max(maxLineW, plainLen(title) + 4);
238
355
  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.
356
+ maxLineW = Math.max(maxLineW, plainLen(subtitle) + 4);
357
+ const termW = terminalWidth();
358
+ const boxW = expand
359
+ ? Math.max(termW - 4, 20)
360
+ : Math.min(Math.max(maxLineW + 4, 20), termW - 4);
361
+ const innerW = Math.max(boxW - 4, 1);
243
362
  const embed = (text, align) => {
244
363
  if (!text)
245
- return "─".repeat(innerW);
364
+ return "─".repeat(Math.max(boxW - 2, 0));
246
365
  const inner = ` ${text} `;
247
- const fill = Math.max(innerW - plainLen(text) - 2, 0);
366
+ const fill = Math.max(boxW - 2 - plainLen(inner), 0);
248
367
  if (align === "left") {
249
368
  return `${inner}${"─".repeat(fill)}`;
250
369
  }
@@ -258,39 +377,39 @@ titleAlign = "center") {
258
377
  for (let i = 0; i < styledLines.length; i++) {
259
378
  const line = styledLines[i];
260
379
  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);
263
- out.push(`\x1b[${borderStyle}m│\x1b[0m ${line}${" ".repeat(pad)} \x1b[${borderStyle}m│\x1b[0m`);
380
+ if (plain.length > innerW) {
381
+ // Truncate cleanly if single line overflows max box width
382
+ const truncated = line.slice(0, innerW);
383
+ out.push(`\x1b[${borderStyle}m│\x1b[0m ${truncated} \x1b[${borderStyle}m│\x1b[0m`);
384
+ }
385
+ else {
386
+ const pad = Math.max(innerW - plain.length, 0);
387
+ out.push(`\x1b[${borderStyle}m│\x1b[0m ${line}${" ".repeat(pad)} \x1b[${borderStyle}m│\x1b[0m`);
388
+ }
264
389
  }
265
390
  const bottom = `╰${embed(subtitle, "center")}╯`;
266
391
  out.push(`\x1b[${borderStyle}m${bottom}\x1b[0m`);
267
392
  return out.join("\n");
268
393
  }
269
- export function renderPanel(content, title = "", subtitle = "", expand = true, borderStyle = "90", // 90 = bright black (grey50)
270
- titleAlign = "center") {
394
+ export function renderPanel(content, title = "", subtitle = "", expand = true, borderStyle = "90", titleAlign = "center") {
271
395
  process.stdout.write(panelString(content, title, subtitle, expand, borderStyle, titleAlign) + "\n");
272
396
  }
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
- */
397
+ // ---------------------------------------------------------------------------
398
+ // Table Panel Rendering
399
+ // ---------------------------------------------------------------------------
283
400
  export function renderTableString(rows, opts = {}) {
284
401
  const ncols = Math.max(0, ...rows.map((r) => r.cells.length));
285
- if (ncols === 0)
402
+ if (ncols === 0) {
286
403
  return panelString("", opts.title ?? "", opts.subtitle ?? "", opts.expand, opts.borderStyle, opts.titleAlign);
404
+ }
287
405
  const colGap = opts.colGap ?? 2;
288
406
  const widths = [];
289
407
  for (let c = 0; c < ncols; c++) {
290
408
  let mw = 0;
291
- for (const r of rows)
409
+ for (const r of rows) {
292
410
  if (r.cells[c])
293
411
  mw = Math.max(mw, plainLen(r.cells[c]));
412
+ }
294
413
  widths.push(Math.max(mw, opts.colWidths?.[c] ?? 0));
295
414
  }
296
415
  const contentLines = [];
@@ -307,40 +426,25 @@ export function renderTableString(rows, opts = {}) {
307
426
  return panelString(contentLines.join("\n"), opts.title ?? "", opts.subtitle ?? "", opts.expand, opts.borderStyle, opts.titleAlign);
308
427
  }
309
428
  // ---------------------------------------------------------------------------
310
- // Durations
429
+ // Durations & Text Formatting
311
430
  // ---------------------------------------------------------------------------
312
431
  export function formatDuration(seconds) {
313
- const total = Math.max(0, Math.round(seconds));
314
- const hours = Math.floor(total / 3600);
315
- const rem = total % 3600;
316
- const minutes = Math.floor(rem / 60);
317
- const secs = rem % 60;
318
- const parts = [];
319
- if (hours)
320
- parts.push(`${hours}h`);
321
- if (minutes)
322
- parts.push(`${minutes}min`);
323
- if (secs || !parts.length)
324
- parts.push(`${secs}s`);
325
- return parts.join(" ");
432
+ const total = Math.max(0, Math.round(seconds * 10) / 10);
433
+ if (total < 60)
434
+ return `${total}s`;
435
+ const mins = Math.floor(total / 60);
436
+ const secs = Math.round(total % 60);
437
+ return `${mins}m ${secs}s`;
326
438
  }
327
439
  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
440
  return `\x1b[22;37m${formatDuration(seconds)}\x1b[2m`;
332
441
  }
333
- // ---------------------------------------------------------------------------
334
- // Text helpers
335
- // ---------------------------------------------------------------------------
336
- const FENCE_MARKER_RE = /`{3,}/g;
337
442
  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
443
  if (!chunk)
341
444
  return;
342
445
  process.stdout.write(markdownToAnsi(chunk));
343
446
  }
447
+ const FENCE_MARKER_RE = /`{3,}/g;
344
448
  export function safeCommitPoint(text) {
345
449
  let idx = text.lastIndexOf("\n\n");
346
450
  while (idx !== -1) {
@@ -353,14 +457,15 @@ export function safeCommitPoint(text) {
353
457
  }
354
458
  export function truncateEllipsis(text, maxChars, label = "text") {
355
459
  if (text.length > maxChars) {
356
- return text.slice(0, maxChars) + ` …(${label} truncated: ${text.length.toLocaleString()} chars total)`;
460
+ return (text.slice(0, maxChars) +
461
+ ` …(${label} truncated: ${text.length.toLocaleString()} chars total)`);
357
462
  }
358
463
  return text;
359
464
  }
360
465
  export function displayRows(text) {
361
466
  if (!text)
362
467
  return 1;
363
- const plain = text.replace(/\x1b\[[0-9;]*m/g, "").replace(/`/g, "");
468
+ const plain = stripAnsi(text);
364
469
  const width = Math.max(terminalWidth() || 80, 1);
365
470
  let rows = 0;
366
471
  const lines = plain.split("\n");
@@ -372,7 +477,7 @@ export function displayRows(text) {
372
477
  return rows;
373
478
  }
374
479
  // ---------------------------------------------------------------------------
375
- // Cursor + spinner helpers (mirror rich's console.status spinner="dots")
480
+ // Cursor & Spinner
376
481
  // ---------------------------------------------------------------------------
377
482
  let cursorHidden = false;
378
483
  export function hideCursor() {
@@ -388,11 +493,6 @@ export function showCursor() {
388
493
  }
389
494
  }
390
495
  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
496
  export class Spinner {
397
497
  timer = null;
398
498
  frame = 0;
@@ -415,7 +515,7 @@ export class Spinner {
415
515
  this.timer = setInterval(() => {
416
516
  this.frame++;
417
517
  this.draw();
418
- }, 120);
518
+ }, 80);
419
519
  this.draw();
420
520
  }
421
521
  update(text) {
@@ -424,24 +524,20 @@ export class Spinner {
424
524
  this.draw();
425
525
  }
426
526
  draw() {
427
- const rendered = mutedMarkdown(`${SPINNER_FRAMES[this.frame % SPINNER_FRAMES.length]} ${this.text}`);
527
+ const icon = SPINNER_FRAMES[this.frame % SPINNER_FRAMES.length];
528
+ const rendered = `\x1b[36m${icon}\x1b[0m \x1b[2m${this.text}\x1b[0m`;
428
529
  const newRows = Math.max(1, displayRows(rendered));
429
530
  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
531
  for (let i = 0; i < clearRows; i++) {
434
532
  process.stdout.write("\r\x1b[2K");
435
533
  if (i < clearRows - 1)
436
534
  process.stdout.write("\n");
437
535
  }
438
- // Cursor is now `clearRows-1` lines below home; move back to home.
439
536
  if (clearRows > 1)
440
537
  process.stdout.write(`\x1b[${clearRows - 1}A\r`);
441
538
  else
442
539
  process.stdout.write("\r");
443
540
  process.stdout.write(rendered);
444
- // Return cursor to home (col 0) for the next draw.
445
541
  if (newRows > 1)
446
542
  process.stdout.write(`\x1b[${newRows - 1}A\r`);
447
543
  else
@@ -454,7 +550,6 @@ export class Spinner {
454
550
  this.timer = null;
455
551
  }
456
552
  if (this.enabled && this.rows > 0) {
457
- // Cursor is at home (col 0); clear the rendered rows downward.
458
553
  for (let i = 0; i < this.rows; i++) {
459
554
  process.stdout.write("\r\x1b[2K");
460
555
  if (i < this.rows - 1)
@@ -469,7 +564,7 @@ export class Spinner {
469
564
  }
470
565
  }
471
566
  // ---------------------------------------------------------------------------
472
- // Tool action formatter
567
+ // Tool Action Formatter
473
568
  // ---------------------------------------------------------------------------
474
569
  export function formatToolAction(name, argumentsJson, status = "ok") {
475
570
  let args = {};
@@ -492,19 +587,19 @@ export function formatToolAction(name, argumentsJson, status = "ok") {
492
587
  const start = args["start_line"];
493
588
  const end = args["end_line"];
494
589
  const span = start != null || end != null
495
- ? `, lines ${start ?? 1} to ${end ?? "end"}`
590
+ ? ` (lines ${start ?? 1} to ${end ?? "end"})`
496
591
  : "";
497
592
  return phrase("Read", "Reading", "Failed to read", `${p}${span}`);
498
593
  }
499
594
  case "write_file":
500
- return phrase("Wrote", "Writing", "Failed to write", `${p} (${String(args["content"] ?? "").length} chars)`);
595
+ return phrase("Wrote", "Writing", "Failed to write", `${p} (${String(args["content"] ?? "").length.toLocaleString()} chars)`);
501
596
  case "edit_file": {
502
597
  const suffix = args["replace_all"] ? " (replace all)" : "";
503
598
  return phrase("Edited", "Editing", "Failed to edit", `${p}${suffix}`);
504
599
  }
505
600
  case "bash": {
506
601
  const cwd = args["cwd"];
507
- const location = cwd ? ` in ${cwd}` : "";
602
+ const location = cwd ? ` [in ${cwd}]` : "";
508
603
  const command = truncateEllipsis(String(args["command"] ?? ""), 400, "command");
509
604
  if (status === "started")
510
605
  return `Running command${location}: ${command}`;
@@ -515,27 +610,27 @@ export function formatToolAction(name, argumentsJson, status = "ok") {
515
610
  case "glob": {
516
611
  const detail = String(args["pattern"] ?? "");
517
612
  if (status === "failed")
518
- return `Search failed for ${detail}`;
519
- return phrase("Searched for", "Searching for", "", detail);
613
+ return `Search failed for: ${detail}`;
614
+ return phrase("Searched files for", "Searching files for", "Search failed for", detail);
520
615
  }
521
616
  case "grep": {
522
617
  const detail = String(args["pattern"] ?? "");
523
618
  if (status === "failed")
524
- return `Grep failed for ${detail}`;
525
- return phrase("Grepped for", "Grepping for", "", detail);
619
+ return `Grep failed for: ${detail}`;
620
+ return phrase("Grepped for", "Grepping for", "Grep failed for", detail);
526
621
  }
527
622
  case "load_skill": {
528
623
  const skill = String(args["skill_name"] ?? "");
529
624
  return phrase("Loaded skill", "Loading skill", "Failed to load skill", skill);
530
625
  }
531
626
  default:
532
- return phrase("Invoked internal routine", "Invoking internal routine", "Internal routine failed", name);
627
+ return phrase("Executed", "Executing", "Failed to execute", name);
533
628
  }
534
629
  }
535
630
  // ---------------------------------------------------------------------------
536
- // Prompt editing / interactive input
631
+ // Prompt Editing & Interactive Input
537
632
  // ---------------------------------------------------------------------------
538
- export const PROMPT_PLACEHOLDER = "Describe a coding task, or type /help to see available commands";
633
+ export const PROMPT_PLACEHOLDER = "Describe a coding task, or type /help for commands";
539
634
  const MAX_PROMPT_DISPLAY_LINES = 12;
540
635
  const MAX_PROMPT_PASTE_CHARS = 400;
541
636
  function shouldCollapsePaste(text) {
@@ -577,13 +672,7 @@ export function splitBlocks(buffer, pasteSpans) {
577
672
  }
578
673
  return segs;
579
674
  }
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
- */
675
+ const PROMPT_CARET = "\u0000";
587
676
  function wrapRuns(runs, width) {
588
677
  const rows = [];
589
678
  let cur = "";
@@ -597,8 +686,6 @@ function wrapRuns(runs, width) {
597
686
  };
598
687
  for (const run of runs) {
599
688
  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
689
  if (curLen >= width)
603
690
  flush();
604
691
  caretRow = rows.length;
@@ -609,7 +696,6 @@ function wrapRuns(runs, width) {
609
696
  }
610
697
  let text = run.text;
611
698
  while (text) {
612
- // Split off the leading run up to the next newline (if any).
613
699
  const nl = text.indexOf("\n");
614
700
  const seg = nl === -1 ? text : text.slice(0, nl);
615
701
  let rest = seg;
@@ -617,9 +703,6 @@ function wrapRuns(runs, width) {
617
703
  if (curLen >= width)
618
704
  flush();
619
705
  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
706
  if (take < rest.length && curLen > 0) {
624
707
  const lastSpace = rest.slice(0, take).lastIndexOf(" ");
625
708
  if (lastSpace > 0)
@@ -643,32 +726,26 @@ function wrapRuns(runs, width) {
643
726
  return { rows, caretRow, caretCol };
644
727
  }
645
728
  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).
729
+ const termW = terminalWidth();
730
+ const boxW = Math.max(termW - 4, 16);
731
+ const innerW = boxW - 4;
652
732
  const runs = [];
653
- runs.push({ text: prefix, style: "\x1b[1m" });
733
+ runs.push({ text: prefix, style: "\x1b[1;36m" });
654
734
  runs.push({ text: " ", style: "" });
655
735
  if (!buffer) {
656
- runs.push({ text: PROMPT_CARET, style: "\x1b[1m" });
657
- runs.push({ text: PROMPT_PLACEHOLDER, style: "\x1b[2m" });
736
+ runs.push({ text: PROMPT_CARET, style: "\x1b[1;36m" });
737
+ runs.push({ text: PROMPT_PLACEHOLDER, style: "\x1b[2;37m" });
658
738
  }
659
739
  else {
660
740
  const segs = splitBlocks(buffer, pasteSpans);
661
- // Recompute each segment's buffer range [a, b) so we can locate the cursor.
662
741
  const ranges = [];
663
742
  {
664
- let pos = 0;
665
743
  const pts = new Set([0, buffer.length]);
666
744
  for (const [s, e] of pasteSpans) {
667
745
  pts.add(s);
668
746
  pts.add(e);
669
747
  }
670
748
  const sorted = [...pts].sort((a, b) => a - b);
671
- const isPaste = (a, b) => pasteSpans.some(([s, e]) => s <= a && b <= e);
672
749
  const used = new Set();
673
750
  for (const seg of segs) {
674
751
  for (let i = 0; i < sorted.length - 1; i++) {
@@ -682,12 +759,13 @@ export function renderBufferWithCursor(buffer, pasteSpans, prefix, label, cursor
682
759
  }
683
760
  }
684
761
  }
685
- // Determine the target segment + inside offset (mirrors Python).
686
762
  let target = -1;
687
763
  let inside = 0;
688
764
  if (cursor >= buffer.length) {
689
765
  target = segs.length - 1;
690
- inside = ranges.length ? ranges[ranges.length - 1][1] - ranges[ranges.length - 1][0] : 0;
766
+ inside = ranges.length
767
+ ? ranges[ranges.length - 1][1] - ranges[ranges.length - 1][0]
768
+ : 0;
691
769
  }
692
770
  else {
693
771
  for (let i = 0; i < ranges.length; i++) {
@@ -700,7 +778,9 @@ export function renderBufferWithCursor(buffer, pasteSpans, prefix, label, cursor
700
778
  }
701
779
  if (target === -1) {
702
780
  target = segs.length - 1;
703
- inside = ranges.length ? ranges[ranges.length - 1][1] - ranges[ranges.length - 1][0] : 0;
781
+ inside = ranges.length
782
+ ? ranges[ranges.length - 1][1] - ranges[ranges.length - 1][0]
783
+ : 0;
704
784
  }
705
785
  }
706
786
  for (let i = 0; i < segs.length; i++) {
@@ -709,41 +789,41 @@ export function renderBufferWithCursor(buffer, pasteSpans, prefix, label, cursor
709
789
  runs.push({ text: " ", style: "" });
710
790
  if (i === target) {
711
791
  if (kind === "collapsed") {
712
- runs.push({ text: disp, style: "\x1b[1m\x1b[36m" });
792
+ runs.push({ text: disp, style: "\x1b[1;36m" });
713
793
  if (!endsWithWs(disp))
714
794
  runs.push({ text: " ", style: "" });
715
- runs.push({ text: PROMPT_CARET, style: "\x1b[1m" });
795
+ runs.push({ text: PROMPT_CARET, style: "\x1b[1;36m" });
716
796
  }
717
797
  else {
718
798
  const before = disp.slice(0, inside);
719
799
  const after = disp.slice(inside);
720
800
  if (before)
721
801
  runs.push({ text: before, style: "" });
722
- runs.push({ text: PROMPT_CARET, style: "\x1b[1m" });
802
+ runs.push({ text: PROMPT_CARET, style: "\x1b[1;36m" });
723
803
  if (after)
724
804
  runs.push({ text: after, style: "" });
725
805
  }
726
806
  }
727
807
  else {
728
- runs.push({ text: disp, style: kind === "collapsed" ? "\x1b[1m\x1b[36m" : "" });
808
+ runs.push({
809
+ text: disp,
810
+ style: kind === "collapsed" ? "\x1b[1;36m" : "",
811
+ });
729
812
  }
730
813
  }
731
814
  }
732
815
  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.
816
+ const labelLen = plainLen(label);
817
+ const topPad = Math.max(0, boxW - labelLen - 5);
818
+ const top = `\x1b[90m╭─ \x1b[1m\x1b[36m${label}\x1b[0m\x1b[90m ${"─".repeat(topPad)}╮\x1b[0m`;
737
819
  const body = [];
738
820
  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);
821
+ const plain = stripAnsi(l);
822
+ const pad = Math.max(0, innerW - plain.length);
741
823
  body.push(`\x1b[90m│\x1b[0m ${l}${" ".repeat(pad)} \x1b[90m│\x1b[0m`);
742
824
  }
743
- const bottom = `\x1b[90m╰${"─".repeat(borderW)}╯\x1b[0m`;
825
+ const bottom = `\x1b[90m╰${"─".repeat(Math.max(0, boxW - 2))}╯\x1b[0m`;
744
826
  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
827
  const cursorRow = (caretRow === -1 ? 0 : caretRow) + 1;
748
828
  const cursorCol = (caretCol === -1 ? 0 : caretCol) + 2;
749
829
  const totalRows = body.length + 2;
@@ -753,14 +833,14 @@ export function askBottomPrompt(label = "You", prefix = "❯", history = []) {
753
833
  return new Promise((resolve, reject) => {
754
834
  const isTTY = process.stdin.isTTY;
755
835
  if (!isTTY) {
756
- // Non-interactive: read a line from stdin
757
836
  const chunks = [];
758
837
  process.stdin.setEncoding("utf-8");
759
838
  process.stdin.on("data", (d) => chunks.push(Buffer.from(String(d))));
760
839
  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.
840
+ const text = Buffer.concat(chunks)
841
+ .toString()
842
+ .replace(/\r\n/g, "\n")
843
+ .replace(/\r/g, "\n");
764
844
  resolve([text.endsWith("\n") ? text.slice(0, -1) : text, []]);
765
845
  });
766
846
  return;
@@ -778,12 +858,7 @@ export function askBottomPrompt(label = "You", prefix = "❯", history = []) {
778
858
  readline.emitKeypressEvents(process.stdin);
779
859
  if (process.stdin.isTTY) {
780
860
  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
861
  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
862
  process.stdout.write("\x1b[>1u");
788
863
  }
789
864
  process.stdin.resume();
@@ -792,11 +867,6 @@ export function askBottomPrompt(label = "You", prefix = "❯", history = []) {
792
867
  let lastCursorRow = 0;
793
868
  let lastTotalRows = 0;
794
869
  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
870
  const clearBox = () => {
801
871
  process.stdout.write(`\x1b[${lastCursorRow}A`);
802
872
  for (let i = 0; i < lastTotalRows; i++) {
@@ -828,11 +898,6 @@ export function askBottomPrompt(label = "You", prefix = "❯", history = []) {
828
898
  const { frame, cursorRow, cursorCol, totalRows } = renderBufferWithCursor(buffer, pasteSpans, prefix, label, cursor);
829
899
  const newLines = frame.split("\n");
830
900
  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
901
  process.stdout.write("\n");
837
902
  for (let i = 0; i < newLines.length; i++) {
838
903
  process.stdout.write(newLines[i]);
@@ -842,16 +907,10 @@ export function askBottomPrompt(label = "You", prefix = "❯", history = []) {
842
907
  prevFrameLines = newLines;
843
908
  lastCursorRow = cursorRow;
844
909
  lastTotalRows = totalRows;
845
- // Cursor sits on the bottom border line; move it up to the input row.
846
910
  const up = totalRows - 1 - cursorRow;
847
911
  process.stdout.write(`\x1b[${up}A\r\x1b[${cursorCol}C`);
848
- return;
849
912
  }
850
913
  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
914
  process.stdout.write(`\x1b[${lastCursorRow}A\r`);
856
915
  const oldLines = prevFrameLines ?? [];
857
916
  const maxLen = Math.max(oldLines.length, newLines.length);
@@ -862,8 +921,6 @@ export function askBottomPrompt(label = "You", prefix = "❯", history = []) {
862
921
  if (i < maxLen - 1)
863
922
  process.stdout.write("\n");
864
923
  }
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
924
  const atLine = maxLen - 1;
868
925
  if (atLine > cursorRow)
869
926
  process.stdout.write(`\x1b[${atLine - cursorRow}A`);
@@ -873,7 +930,6 @@ export function askBottomPrompt(label = "You", prefix = "❯", history = []) {
873
930
  prevFrameLines = newLines;
874
931
  lastCursorRow = cursorRow;
875
932
  lastTotalRows = totalRows;
876
- return;
877
933
  }
878
934
  };
879
935
  const shiftSpansAfterInsert = (pos, delta) => {
@@ -897,12 +953,7 @@ export function askBottomPrompt(label = "You", prefix = "❯", history = []) {
897
953
  }
898
954
  const pasteStart = cursor;
899
955
  buffer = buffer.slice(0, cursor) + text + buffer.slice(cursor);
900
- // Keep existing paste spans valid across the insertion point.
901
956
  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
957
  if (isPaste) {
907
958
  pasteSpans.push([pasteStart, pasteStart + text.length]);
908
959
  pasteSpans.sort((a, b) => a[0] - b[0]);
@@ -916,9 +967,6 @@ export function askBottomPrompt(label = "You", prefix = "❯", history = []) {
916
967
  draft = { buffer, cursor, spans: pasteSpans.slice() };
917
968
  histIdx = hist.length;
918
969
  }
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
970
  const containing = pasteSpans.find(([s, e]) => s < cursor && cursor <= e);
923
971
  if (containing) {
924
972
  const [start, end] = containing;
@@ -930,14 +978,11 @@ export function askBottomPrompt(label = "You", prefix = "❯", history = []) {
930
978
  cursor = start;
931
979
  return;
932
980
  }
933
- // remove one grapheme before cursor
934
981
  const before = Array.from(buffer.slice(0, cursor));
935
982
  before.pop();
936
983
  buffer = before.join("") + buffer.slice(cursor);
937
984
  const deletedAt = cursor - 1;
938
985
  cursor -= 1;
939
- // Adjust spans: shrink any span covering the deleted char, shift spans
940
- // that start after it left by one.
941
986
  const adjusted = [];
942
987
  for (const [s, e] of pasteSpans) {
943
988
  if (deletedAt >= s && deletedAt < e) {
@@ -954,11 +999,39 @@ export function askBottomPrompt(label = "You", prefix = "❯", history = []) {
954
999
  }
955
1000
  pasteSpans = adjusted.filter(([s, e]) => s < e);
956
1001
  };
1002
+ const deleteChar = () => {
1003
+ if (cursor >= buffer.length)
1004
+ return;
1005
+ if (histIdx !== hist.length) {
1006
+ draft = { buffer, cursor, spans: pasteSpans.slice() };
1007
+ histIdx = hist.length;
1008
+ }
1009
+ const containing = pasteSpans.find(([s, e]) => s <= cursor && cursor < e);
1010
+ if (containing) {
1011
+ const [start, end] = containing;
1012
+ buffer = buffer.slice(0, start) + buffer.slice(end);
1013
+ const delta = start - end;
1014
+ pasteSpans = pasteSpans
1015
+ .filter(([s, e]) => s !== start || e !== end)
1016
+ .map(([s, e]) => (s >= end ? [s + delta, e + delta] : [s, e]));
1017
+ return;
1018
+ }
1019
+ buffer = buffer.slice(0, cursor) + buffer.slice(cursor + 1);
1020
+ };
1021
+ const deleteWordBefore = () => {
1022
+ if (cursor <= 0)
1023
+ return;
1024
+ const before = buffer.slice(0, cursor);
1025
+ const trimmed = before.replace(/\s+$/, "");
1026
+ const lastSpace = Math.max(trimmed.lastIndexOf(" "), trimmed.lastIndexOf("\t"), trimmed.lastIndexOf("\n"));
1027
+ const targetPos = lastSpace === -1 ? 0 : lastSpace + 1;
1028
+ buffer = buffer.slice(0, targetPos) + buffer.slice(cursor);
1029
+ cursor = targetPos;
1030
+ pasteSpans = [];
1031
+ };
957
1032
  const moveLeft = () => {
958
1033
  if (cursor <= 0)
959
1034
  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
1035
  for (const [s, e] of pasteSpans) {
963
1036
  if (s < cursor && cursor <= e) {
964
1037
  cursor = s;
@@ -980,7 +1053,6 @@ export function askBottomPrompt(label = "You", prefix = "❯", history = []) {
980
1053
  };
981
1054
  const moveUp = () => {
982
1055
  if (!buffer.includes("\n")) {
983
- // Single row: navigate command history.
984
1056
  if (!hist.length)
985
1057
  return;
986
1058
  if (histIdx === hist.length) {
@@ -994,7 +1066,6 @@ export function askBottomPrompt(label = "You", prefix = "❯", history = []) {
994
1066
  }
995
1067
  return;
996
1068
  }
997
- // Multi row: move the caret up to the previous line, preserving column.
998
1069
  if (cursor > 0) {
999
1070
  let lineStart = buffer.lastIndexOf("\n", cursor - 1);
1000
1071
  lineStart = lineStart !== -1 ? lineStart : 0;
@@ -1006,7 +1077,6 @@ export function askBottomPrompt(label = "You", prefix = "❯", history = []) {
1006
1077
  };
1007
1078
  const moveDown = () => {
1008
1079
  if (!buffer.includes("\n")) {
1009
- // Single row: navigate command history.
1010
1080
  if (histIdx === hist.length || !draft)
1011
1081
  return;
1012
1082
  histIdx += 1;
@@ -1022,7 +1092,6 @@ export function askBottomPrompt(label = "You", prefix = "❯", history = []) {
1022
1092
  }
1023
1093
  return;
1024
1094
  }
1025
- // Multi row: move the caret down to the next line, preserving column.
1026
1095
  if (cursor < buffer.length) {
1027
1096
  const [, col] = cursorLineCol(buffer, cursor);
1028
1097
  let nextStart = buffer.indexOf("\n", cursor);
@@ -1040,23 +1109,17 @@ export function askBottomPrompt(label = "You", prefix = "❯", history = []) {
1040
1109
  const isNewlineKey = (key) => {
1041
1110
  if (!key)
1042
1111
  return false;
1043
- // Bare \n / Ctrl+J = newline.
1044
1112
  if (key.ctrl && key.name === "j")
1045
1113
  return true;
1046
1114
  if (key.name === "enter")
1047
1115
  return !!(key.shift || key.ctrl || key.meta);
1048
- // Enter with a modifier (Shift/Ctrl/Alt/Meta) = newline.
1049
1116
  if (key.name === "return")
1050
1117
  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
1118
  const seq = key.sequence || "";
1055
1119
  if (/^\x1b\[13;([2-9]|\d{2,})u$/.test(seq))
1056
1120
  return true;
1057
1121
  if (/^\x1b\[13;([2-9]|\d{2,})~$/.test(seq))
1058
1122
  return true;
1059
- // Legacy Shift+Enter decoded by Node as F3 with shift.
1060
1123
  if (key.name === "f3" && key.shift)
1061
1124
  return true;
1062
1125
  return false;
@@ -1064,23 +1127,20 @@ export function askBottomPrompt(label = "You", prefix = "❯", history = []) {
1064
1127
  const isModifiedEnterInput = (str, key) => {
1065
1128
  const value = str || "";
1066
1129
  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
1130
  if (value === "\n" || value === "\x0a")
1070
1131
  return true;
1071
1132
  if (key?.name === "linefeed" || (key?.ctrl && key?.name === "j"))
1072
1133
  return true;
1073
- return /^\x1b\[13;(?:[2-9]|\d{2,})(?:u|~)$/.test(value) ||
1074
- /^\x1b\[13;(?:[2-9]|\d{2,})(?:u|~)$/.test(sequence);
1134
+ return (/^\x1b\[13;(?:[2-9]|\d{2,})(?:u|~)$/.test(value) ||
1135
+ /^\x1b\[13;(?:[2-9]|\d{2,})(?:u|~)$/.test(sequence));
1075
1136
  };
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
1137
  const isSubmitEnterKey = (key) => {
1080
1138
  if (!key)
1081
1139
  return false;
1082
- if ((key.name === "return" || key.name === "enter") && !(key.shift || key.ctrl || key.meta))
1140
+ if ((key.name === "return" || key.name === "enter") &&
1141
+ !(key.shift || key.ctrl || key.meta)) {
1083
1142
  return true;
1143
+ }
1084
1144
  const seq = key.sequence || "";
1085
1145
  if (seq === "\x1b[13u" || /^\x1b\[13;1u$/.test(seq))
1086
1146
  return true;
@@ -1094,12 +1154,19 @@ export function askBottomPrompt(label = "You", prefix = "❯", history = []) {
1094
1154
  if (!bracketedPaste && (sequence === start || value === start)) {
1095
1155
  bracketedPaste = true;
1096
1156
  bracketedPasteBuffer = "";
1097
- const inline = value === start ? "" : value.startsWith(start) ? value.slice(start.length) : "";
1157
+ const inline = value === start
1158
+ ? ""
1159
+ : value.startsWith(start)
1160
+ ? value.slice(start.length)
1161
+ : "";
1098
1162
  if (inline)
1099
1163
  bracketedPasteBuffer = inline;
1100
1164
  if (inline.includes(end)) {
1101
1165
  const endAt = inline.indexOf(end);
1102
- const pasted = inline.slice(0, endAt).replace(/\r\n/g, "\n").replace(/\r/g, "\n");
1166
+ const pasted = inline
1167
+ .slice(0, endAt)
1168
+ .replace(/\r\n/g, "\n")
1169
+ .replace(/\r/g, "\n");
1103
1170
  bracketedPaste = false;
1104
1171
  bracketedPasteBuffer = "";
1105
1172
  if (pasted)
@@ -1111,7 +1178,9 @@ export function askBottomPrompt(label = "You", prefix = "❯", history = []) {
1111
1178
  if (!bracketedPaste)
1112
1179
  return false;
1113
1180
  if (sequence === end || value === end) {
1114
- const pasted = bracketedPasteBuffer.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
1181
+ const pasted = bracketedPasteBuffer
1182
+ .replace(/\r\n/g, "\n")
1183
+ .replace(/\r/g, "\n");
1115
1184
  bracketedPaste = false;
1116
1185
  bracketedPasteBuffer = "";
1117
1186
  if (pasted)
@@ -1119,12 +1188,15 @@ export function askBottomPrompt(label = "You", prefix = "❯", history = []) {
1119
1188
  repaint();
1120
1189
  return true;
1121
1190
  }
1122
- const chunk = value || (sequence && !sequence.startsWith("\x1b[") ? sequence : "");
1191
+ const chunk = value ||
1192
+ (sequence && !sequence.startsWith("\x1b[") ? sequence : "");
1123
1193
  if (chunk) {
1124
1194
  const endAt = chunk.indexOf(end);
1125
1195
  if (endAt >= 0) {
1126
1196
  bracketedPasteBuffer += chunk.slice(0, endAt);
1127
- const pasted = bracketedPasteBuffer.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
1197
+ const pasted = bracketedPasteBuffer
1198
+ .replace(/\r\n/g, "\n")
1199
+ .replace(/\r/g, "\n");
1128
1200
  bracketedPaste = false;
1129
1201
  bracketedPasteBuffer = "";
1130
1202
  if (pasted)
@@ -1149,9 +1221,6 @@ export function askBottomPrompt(label = "You", prefix = "❯", history = []) {
1149
1221
  const onKeypress = (str, key) => {
1150
1222
  if (consumeBracketedPaste(str, key))
1151
1223
  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
1224
  if (pasteBurst && str && str.length === 1 && !key?.ctrl && !key?.meta) {
1156
1225
  pasteBurst = false;
1157
1226
  if (pasteBurstTimer)
@@ -1166,18 +1235,40 @@ export function askBottomPrompt(label = "You", prefix = "❯", history = []) {
1166
1235
  settle(new Error("eof"), true);
1167
1236
  return;
1168
1237
  }
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))) {
1238
+ if (key && key.ctrl && key.name === "a") {
1239
+ cursor = 0;
1240
+ repaint();
1241
+ return;
1242
+ }
1243
+ if (key && key.ctrl && key.name === "e") {
1244
+ cursor = buffer.length;
1245
+ repaint();
1246
+ return;
1247
+ }
1248
+ if (key && key.ctrl && key.name === "u") {
1249
+ buffer = "";
1250
+ cursor = 0;
1251
+ pasteSpans = [];
1252
+ repaint();
1253
+ return;
1254
+ }
1255
+ if (key && key.ctrl && key.name === "k") {
1256
+ buffer = buffer.slice(0, cursor);
1257
+ repaint();
1258
+ return;
1259
+ }
1260
+ if (key && key.ctrl && key.name === "w") {
1261
+ deleteWordBefore();
1262
+ repaint();
1263
+ return;
1264
+ }
1265
+ if (isNewlineKey(key) ||
1266
+ isModifiedEnterInput(str, key) ||
1267
+ (pasteBurst && isSubmitEnterKey(key))) {
1174
1268
  insert("\n");
1175
1269
  repaint();
1176
1270
  return;
1177
1271
  }
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
1272
  if (isSubmitEnterKey(key)) {
1182
1273
  if (buffer.trim()) {
1183
1274
  settle([buffer, pasteSpans], false);
@@ -1189,6 +1280,21 @@ export function askBottomPrompt(label = "You", prefix = "❯", history = []) {
1189
1280
  repaint();
1190
1281
  return;
1191
1282
  }
1283
+ if (key && (key.name === "delete" || key.sequence === "\x1b[3~")) {
1284
+ deleteChar();
1285
+ repaint();
1286
+ return;
1287
+ }
1288
+ if (key && (key.name === "home" || key.sequence === "\x1b[H" || key.sequence === "\x1b[1~")) {
1289
+ cursor = 0;
1290
+ repaint();
1291
+ return;
1292
+ }
1293
+ if (key && (key.name === "end" || key.sequence === "\x1b[F" || key.sequence === "\x1b[4~")) {
1294
+ cursor = buffer.length;
1295
+ repaint();
1296
+ return;
1297
+ }
1192
1298
  if (key && key.name === "left") {
1193
1299
  moveLeft();
1194
1300
  repaint();
@@ -1210,7 +1316,7 @@ export function askBottomPrompt(label = "You", prefix = "❯", history = []) {
1210
1316
  return;
1211
1317
  }
1212
1318
  if (key && key.name === "tab") {
1213
- insert(" ");
1319
+ insert(" ");
1214
1320
  repaint();
1215
1321
  return;
1216
1322
  }
@@ -1220,8 +1326,6 @@ export function askBottomPrompt(label = "You", prefix = "❯", history = []) {
1220
1326
  return;
1221
1327
  }
1222
1328
  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
1329
  const isPaste = str.length > 1;
1226
1330
  insert(str.replace(/\r\n/g, "\n").replace(/\r/g, "\n"), isPaste);
1227
1331
  if (isPaste)
@@ -1234,14 +1338,9 @@ export function askBottomPrompt(label = "You", prefix = "❯", history = []) {
1234
1338
  });
1235
1339
  }
1236
1340
  // ---------------------------------------------------------------------------
1237
- // User display + message text
1341
+ // User Display & Message Text Helpers
1238
1342
  // ---------------------------------------------------------------------------
1239
1343
  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
1344
  if (pasteSpans && pasteSpans.length) {
1246
1345
  const segs = splitBlocks(payload, pasteSpans);
1247
1346
  let out = "";
@@ -1249,7 +1348,7 @@ export function userDisplayText(payload, pasteSpans) {
1249
1348
  const [, kind, disp] = segs[i];
1250
1349
  if (i && !endsWithWs(segs[i - 1][2]))
1251
1350
  out += " ";
1252
- out += kind === "collapsed" ? `\x1b[1m\x1b[36m${disp}\x1b[0m` : disp;
1351
+ out += kind === "collapsed" ? `\x1b[1;36m${disp}\x1b[0m` : disp;
1253
1352
  }
1254
1353
  return out;
1255
1354
  }