@oxecli/oxe 1.0.15 → 1.0.17

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -3,7 +3,7 @@ import { fileURLToPath } from "node:url";
3
3
  import { loadOrPrompt, default_reasoning_effort, max_action_chars, max_resume_history_items, } from "./config.js";
4
4
  import { InferenceEngine, estimateTokens } from "./engine.js";
5
5
  import { saveSession, loadSession, listSessions, nextSessionId, conversationLabel, COMMAND_HELP, toolOutputFailed, } from "./sessions.js";
6
- import { clearScreen, enableAnsi, askBottomPrompt, userDisplayText, aiMarkdown, mutedMarkdown, messageText, formatToolAction, truncateEllipsis, renderPanel, } from "./ui.js";
6
+ import { clearScreen, enableAnsi, askBottomPrompt, userDisplayText, aiMarkdown, mutedMarkdown, messageText, formatToolAction, truncateEllipsis, renderPanel, renderTableString, enableRawStdin, disableRawStdin, waitRawKey, } from "./ui.js";
7
7
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
8
8
  function truncateLabel(s) {
9
9
  const t = String(s || "(conversation)").trim();
@@ -43,19 +43,49 @@ export class CLI {
43
43
  const style = status === "failed" ? "\x1b[31m" : "\x1b[32m";
44
44
  process.stdout.write(`${style}${truncateEllipsis(String(text), max_action_chars, "text")}\x1b[0m\n`);
45
45
  }
46
- showHelp() {
47
- process.stdout.write("\n");
48
- for (const [cmd, desc] of COMMAND_HELP) {
49
- // The whole command column is bold; any `<arg>` placeholder (e.g. <n>,
50
- // <level>) is additionally cyan — matching the original's
51
- // `[bold cyan]<n>[/bold cyan]`. Pad based on the plain text length so the
52
- // ANSI codes don't skew the column alignment.
46
+ async showHelp() {
47
+ // Mirror Python's `_show_help`: a bordered table panel with the command
48
+ // column bold (with `<arg>` placeholders cyan) and descriptions dim, inside
49
+ // a panel titled "Commands · Enter / Ctrl+C closes". In a TTY it stays on
50
+ // screen until Enter/Ctrl+C/arrows/q closes it.
51
+ const rows = COMMAND_HELP.map(([cmd, desc]) => {
53
52
  const styled = cmd.replace(/(<[^>]+>)/g, "\x1b[36m$1\x1b[0m");
54
- const pad = Math.max(18 - cmd.length, 0);
55
- process.stdout.write(`\x1b[1m${styled}${" ".repeat(pad)}\x1b[0m\x1b[2m${desc}\x1b[0m\n`);
53
+ return { cells: [`\x1b[1m${styled}\x1b[0m`, `\x1b[2m${desc}\x1b[0m`] };
54
+ });
55
+ const panel = renderTableString(rows, {
56
+ title: "Commands · Enter / Ctrl+C closes",
57
+ borderStyle: "90",
58
+ expand: false,
59
+ titleAlign: "left",
60
+ colGap: 2,
61
+ });
62
+ process.stdout.write("\n" + panel + "\n");
63
+ if (process.stdin.isTTY) {
64
+ // Wait for a close key, then erase the panel (mirrors Python's Live).
65
+ enableRawStdin();
66
+ try {
67
+ for (;;) {
68
+ if (await this.awaitRawCloseKey())
69
+ break;
70
+ }
71
+ }
72
+ finally {
73
+ disableRawStdin();
74
+ }
75
+ const rowsShown = panel.split("\n").length;
76
+ process.stdout.write(`\x1b[${rowsShown}A\r\x1b[J`);
56
77
  }
57
78
  process.stdout.write("\n");
58
79
  }
80
+ async awaitRawCloseKey() {
81
+ const k = await waitRawKey();
82
+ const closeNames = ["escape", "return", "enter", "up", "down", "left", "right"];
83
+ if (k.ctrl && (k.name === "c" || k.name === "z"))
84
+ return true;
85
+ if (k.str === "q" || k.str === "Q" || k.str === "\x1b" || closeNames.includes(k.name))
86
+ return true;
87
+ return false;
88
+ }
59
89
  renderHistory(items, maxItems = max_resume_history_items) {
60
90
  const outputsByCallId = new Map();
61
91
  for (const it of items) {
@@ -173,25 +203,110 @@ export class CLI {
173
203
  }
174
204
  process.stdout.write("\n");
175
205
  }
176
- pickConversation() {
206
+ /** Build the interactive picker panel (mirrors Python `_render_picker`). */
207
+ renderPickerPanel(recs, selected, visible = 8) {
208
+ const total = recs.length;
209
+ const half = Math.floor(visible / 2);
210
+ const lo = Math.min(Math.max(selected - half, 0), Math.max(total - visible, 0));
211
+ const hi = Math.min(lo + visible, total);
212
+ const rows = [];
213
+ for (let idx = lo; idx < hi; idx++) {
214
+ const rec = recs[idx];
215
+ const sid = String(rec["id"] ?? "?");
216
+ const updated = String(rec["updated_at"] ?? "").slice(0, 16).replace("T", " ");
217
+ const label = truncateLabel(rec["label"]);
218
+ const items = rec["input_items"] ?? [];
219
+ const toks = estimateTokens(items).toLocaleString();
220
+ const content = `\x1b[1m${sid.padStart(4)}\x1b[0m` +
221
+ `\x1b[2m ${updated}\x1b[0m ${label}` +
222
+ `\x1b[2m ${items.length} items · ${toks} tok\x1b[0m`;
223
+ rows.push({
224
+ cells: [idx === selected ? "\x1b[1m\x1b[36m❯\x1b[0m" : "", content],
225
+ style: idx === selected ? "\x1b[1m" : undefined,
226
+ });
227
+ }
228
+ const below = total - hi;
229
+ let footer = "";
230
+ if (lo > 0 && below > 0)
231
+ footer = `\x1b[2m▲ ${lo} earlier · ▼ ${below} more\x1b[0m`;
232
+ else if (lo > 0)
233
+ footer = `\x1b[2m▲ ${lo} earlier\x1b[0m`;
234
+ else if (below > 0)
235
+ footer = `\x1b[2m▼ ${below} more\x1b[0m`;
236
+ if (footer)
237
+ rows.push({ cells: ["", footer] });
238
+ const title = `Conversations \x1b[1m\x1b[36m${lo + 1}-${hi}/${total}\x1b[0m (↑/↓ · Quit (q) · Enter)`;
239
+ return renderTableString(rows, {
240
+ title,
241
+ borderStyle: "90",
242
+ expand: true,
243
+ titleAlign: "left",
244
+ colGap: 0,
245
+ colWidths: [3, undefined],
246
+ });
247
+ }
248
+ /** Interactive arrow up/down project picker (mirrors Python `_pick_conversation`). */
249
+ async pickConversation() {
177
250
  const recs = listSessions();
178
251
  if (!recs.length) {
179
252
  process.stdout.write("\n");
180
253
  renderPanel("No saved conversations yet. Type a prompt to start one.", "Conversations");
181
254
  return null;
182
255
  }
183
- this.showConversationsPlain(recs);
184
- return null;
256
+ if (!process.stdin.isTTY) {
257
+ this.showConversationsPlain(recs);
258
+ return null;
259
+ }
260
+ enableRawStdin();
261
+ let selected = 0;
262
+ let lastRows = 0;
263
+ const draw = () => {
264
+ const frame = "\n" + this.renderPickerPanel(recs, selected);
265
+ const n = frame.split("\n").length;
266
+ if (lastRows > 0)
267
+ process.stdout.write(`\x1b[${lastRows - 1}A\r\x1b[J`);
268
+ process.stdout.write(frame);
269
+ lastRows = n;
270
+ };
271
+ draw();
272
+ try {
273
+ for (;;) {
274
+ const k = await waitRawKey();
275
+ if (k.name === "up") {
276
+ selected = (selected - 1 + recs.length) % recs.length;
277
+ draw();
278
+ }
279
+ else if (k.name === "down") {
280
+ selected = (selected + 1) % recs.length;
281
+ draw();
282
+ }
283
+ else if (k.name === "return" || k.name === "enter") {
284
+ return Number(recs[selected]["id"] ?? null);
285
+ }
286
+ else if (k.str === "q" ||
287
+ k.str === "Q" ||
288
+ k.name === "escape" ||
289
+ (k.ctrl && (k.name === "c" || k.name === "z"))) {
290
+ return null;
291
+ }
292
+ }
293
+ }
294
+ finally {
295
+ process.stdout.write(`\x1b[${lastRows - 1}A\r\x1b[J`);
296
+ disableRawStdin();
297
+ }
185
298
  }
186
299
  async resumeConversation(engine, num) {
187
300
  const sid = parseInt(num, 10);
188
301
  if (Number.isNaN(sid)) {
189
- renderPanel(`Invalid conversation number: ${num}`, "Error");
302
+ process.stdout.write("\n");
303
+ renderPanel(`Invalid conversation number: ${num}`, "Error", "", true, "31");
190
304
  return;
191
305
  }
192
306
  const rec = loadSession(sid);
193
307
  if (!rec) {
194
- renderPanel(`Conversation ${sid} not found.`, "Error");
308
+ process.stdout.write("\n");
309
+ renderPanel(`Conversation ${sid} not found.`, "Error", "", true, "31");
195
310
  return;
196
311
  }
197
312
  this.saveSession(engine);
@@ -232,7 +347,7 @@ export class CLI {
232
347
  break;
233
348
  }
234
349
  if (lower === "/help") {
235
- this.showHelp();
350
+ await this.showHelp();
236
351
  continue;
237
352
  }
238
353
  if (lower === "/clear") {
@@ -249,7 +364,10 @@ export class CLI {
249
364
  if (lower.startsWith("/resume")) {
250
365
  const parts = input.trim().split(/\s+/);
251
366
  if (parts.length === 1) {
252
- this.pickConversation();
367
+ const picked = await this.pickConversation();
368
+ if (picked != null && !Number.isNaN(picked)) {
369
+ await this.resumeConversation(engine, String(picked));
370
+ }
253
371
  }
254
372
  else {
255
373
  await this.resumeConversation(engine, parts[1]);
@@ -264,7 +382,9 @@ export class CLI {
264
382
  process.stdout.write(`\n\x1b[2mReasoning effort set to ${effort}.\x1b[0m\n`);
265
383
  }
266
384
  else {
267
- renderPanel("Usage: /effort none|low|high", "Error");
385
+ process.stdout.write("\n");
386
+ renderPanel("Usage: /effort none|low|high", "Error", "", true, "31");
387
+ process.stdout.write("\n");
268
388
  }
269
389
  continue;
270
390
  }
package/dist/config.js CHANGED
@@ -145,7 +145,7 @@ export const SYSTEM_PROMPT_BODY = "You are a terminal-based coding agent named O
145
145
  "consequence in your response.\n";
146
146
  export const API_KEY_MAX = 64;
147
147
  import { validateOxeApiKey } from "./api.js";
148
- import { renderPanel, markupToAnsi } from "./ui.js";
148
+ import { renderPanel, markupToAnsi, hideCursor, showCursor } from "./ui.js";
149
149
  export async function loadOrPrompt() {
150
150
  let api_key = "";
151
151
  let key_data = {};
@@ -164,9 +164,13 @@ export async function loadOrPrompt() {
164
164
  }
165
165
  process.stdout.write("\n");
166
166
  const authSpinner = new Spinner();
167
+ // promptApiKey echoes input with the cursor visible; hide it while the
168
+ // validating-key label runs so no stray block cursor sits beside it.
169
+ hideCursor();
167
170
  authSpinner.start("Authenticating key with Oxe Cloud…");
168
171
  const validation = await validateOxeApiKey(api_key);
169
172
  authSpinner.stop();
173
+ showCursor();
170
174
  if (validation.valid) {
171
175
  key_data = validation.key_data || {};
172
176
  process.stdout.write(markupToAnsi(`[green]✓ Oxe API Key verified[/green] [dim](${String(key_data["name"] ?? "Desktop")})[/dim]`) + "\n");
package/dist/ui.js CHANGED
@@ -1,5 +1,44 @@
1
1
  import * as readline from "node:readline";
2
2
  // ---------------------------------------------------------------------------
3
+ // Raw stdin key reading (for interactive help / resume picker)
4
+ // ---------------------------------------------------------------------------
5
+ let rawKeyListeners = 0;
6
+ /** Enter raw mode and emit keypress events on stdin. */
7
+ export function enableRawStdin() {
8
+ if (rawKeyListeners === 0) {
9
+ readline.emitKeypressEvents(process.stdin);
10
+ if (process.stdin.isTTY)
11
+ process.stdin.setRawMode(true);
12
+ process.stdin.resume();
13
+ hideCursor();
14
+ }
15
+ rawKeyListeners++;
16
+ }
17
+ /** Exit raw mode. */
18
+ export function disableRawStdin() {
19
+ rawKeyListeners = Math.max(0, rawKeyListeners - 1);
20
+ if (rawKeyListeners === 0) {
21
+ try {
22
+ process.stdin.setRawMode(false);
23
+ }
24
+ catch {
25
+ /* ignore */
26
+ }
27
+ process.stdin.pause();
28
+ showCursor();
29
+ }
30
+ }
31
+ /** Wait for a single raw keypress. */
32
+ export function waitRawKey() {
33
+ return new Promise((resolve) => {
34
+ const onKeypress = (str, key) => {
35
+ process.stdin.removeListener("keypress", onKeypress);
36
+ resolve({ name: key?.name ?? "", str: str ?? "", ctrl: !!(key?.ctrl) });
37
+ };
38
+ process.stdin.on("keypress", onKeypress);
39
+ });
40
+ }
41
+ // ---------------------------------------------------------------------------
3
42
  // Terminal helpers
4
43
  // ---------------------------------------------------------------------------
5
44
  export function isWindows() {
@@ -142,7 +181,7 @@ export function mutedMarkdown(text) {
142
181
  * - title/subtitle are embedded in the top/bottom borders, centered by
143
182
  * default (rich default) or left-aligned.
144
183
  */
145
- export function renderPanel(content, title = "", subtitle = "", expand = true, borderStyle = "90", // 90 = bright black (grey50)
184
+ function panelString(content, title = "", subtitle = "", expand = true, borderStyle = "90", // 90 = bright black (grey50)
146
185
  titleAlign = "center") {
147
186
  const styled = markupToAnsi(content);
148
187
  const styledLines = styled.split("\n");
@@ -172,17 +211,59 @@ titleAlign = "center") {
172
211
  const right = fill - left;
173
212
  return `${"─".repeat(left)}${inner}${"─".repeat(right)}`;
174
213
  };
214
+ const out = [];
175
215
  const top = `╭${embed(title, titleAlign)}╮`;
176
- process.stdout.write(`\x1b[${borderStyle}m${top}\x1b[0m\n`);
216
+ out.push(`\x1b[${borderStyle}m${top}\x1b[0m`);
177
217
  for (let i = 0; i < styledLines.length; i++) {
178
218
  const line = styledLines[i];
179
219
  const plain = plainLines[i] ?? "";
180
220
  // Keep exactly one space padding each side; only pad the right to fill.
181
221
  const pad = Math.max(innerW - plain.length - 2, 0);
182
- process.stdout.write(`\x1b[${borderStyle}m│\x1b[0m ${line}${" ".repeat(pad)} \x1b[${borderStyle}m│\x1b[0m\n`);
222
+ out.push(`\x1b[${borderStyle}m│\x1b[0m ${line}${" ".repeat(pad)} \x1b[${borderStyle}m│\x1b[0m`);
183
223
  }
184
224
  const bottom = `╰${embed(subtitle, "center")}╯`;
185
- process.stdout.write(`\x1b[${borderStyle}m${bottom}\x1b[0m\n`);
225
+ out.push(`\x1b[${borderStyle}m${bottom}\x1b[0m`);
226
+ return out.join("\n");
227
+ }
228
+ export function renderPanel(content, title = "", subtitle = "", expand = true, borderStyle = "90", // 90 = bright black (grey50)
229
+ titleAlign = "center") {
230
+ process.stdout.write(panelString(content, title, subtitle, expand, borderStyle, titleAlign) + "\n");
231
+ }
232
+ function plainLen(s) {
233
+ return s.replace(/\x1b\[[0-9;]*m/g, "").length;
234
+ }
235
+ /**
236
+ * Render a rich-style bordered table panel (mirrors rich's Table + Panel).
237
+ *
238
+ * `rows` is a list of {cells, style?} where `style` (ANSI prefix) is applied to
239
+ * the whole row. Columns are left-aligned and padded; `colWidths` can fix a
240
+ * column's width (used for the selection gutter).
241
+ */
242
+ export function renderTableString(rows, opts = {}) {
243
+ const ncols = Math.max(0, ...rows.map((r) => r.cells.length));
244
+ if (ncols === 0)
245
+ return panelString("", opts.title ?? "", opts.subtitle ?? "", opts.expand, opts.borderStyle, opts.titleAlign);
246
+ const colGap = opts.colGap ?? 2;
247
+ const widths = [];
248
+ for (let c = 0; c < ncols; c++) {
249
+ let mw = 0;
250
+ for (const r of rows)
251
+ if (r.cells[c])
252
+ mw = Math.max(mw, plainLen(r.cells[c]));
253
+ widths.push(Math.max(mw, opts.colWidths?.[c] ?? 0));
254
+ }
255
+ const contentLines = [];
256
+ for (const r of rows) {
257
+ const parts = [];
258
+ for (let c = 0; c < ncols; c++) {
259
+ const cell = r.cells[c] ?? "";
260
+ const pad = c < ncols - 1 ? widths[c] - plainLen(cell) + colGap : 0;
261
+ parts.push(cell + " ".repeat(Math.max(pad, 0)));
262
+ }
263
+ const line = parts.join("");
264
+ contentLines.push(r.style ? `${r.style}${line}\x1b[0m` : line);
265
+ }
266
+ return panelString(contentLines.join("\n"), opts.title ?? "", opts.subtitle ?? "", opts.expand, opts.borderStyle, opts.titleAlign);
186
267
  }
187
268
  // ---------------------------------------------------------------------------
188
269
  // Durations
@@ -456,15 +537,76 @@ export function splitBlocks(buffer, pasteSpans) {
456
537
  }
457
538
  return segs;
458
539
  }
540
+ const PROMPT_CARET = "\u0000"; // sentinel marking the caret position
541
+ /**
542
+ * Wrap styled runs into physical rows of `width` visible columns, wrapping long
543
+ * lines so the panel box stays rectangular (mirrors rich's Panel auto-wrap).
544
+ * Returns the styled rows (no borders) and the caret's (row, col) in content
545
+ * coordinates, or (-1,-1) if no caret sentinel is present.
546
+ */
547
+ function wrapRuns(runs, width) {
548
+ const rows = [];
549
+ let cur = "";
550
+ let curLen = 0;
551
+ let caretRow = -1;
552
+ let caretCol = -1;
553
+ const flush = () => {
554
+ rows.push(cur);
555
+ cur = "";
556
+ curLen = 0;
557
+ };
558
+ for (const run of runs) {
559
+ if (run.text === PROMPT_CARET) {
560
+ // Caret occupies one visible column; if the current row is full, move it
561
+ // to the start of the next row so it stays inside the box.
562
+ if (curLen >= width)
563
+ flush();
564
+ caretRow = rows.length;
565
+ caretCol = curLen;
566
+ cur += run.style + "▏" + "\x1b[0m";
567
+ curLen += 1;
568
+ continue;
569
+ }
570
+ let text = run.text;
571
+ while (text) {
572
+ // Split off the leading run up to the next newline (if any).
573
+ const nl = text.indexOf("\n");
574
+ const seg = nl === -1 ? text : text.slice(0, nl);
575
+ let rest = seg;
576
+ while (rest.length > 0) {
577
+ if (curLen >= width)
578
+ flush();
579
+ const take = Math.min(rest.length, width - curLen);
580
+ const chunk = rest.slice(0, take);
581
+ cur += run.style ? run.style + chunk + "\x1b[0m" : chunk;
582
+ curLen += take;
583
+ rest = rest.slice(take);
584
+ }
585
+ if (nl !== -1) {
586
+ flush();
587
+ text = text.slice(nl + 1);
588
+ }
589
+ else {
590
+ break;
591
+ }
592
+ }
593
+ }
594
+ flush();
595
+ return { rows, caretRow, caretCol };
596
+ }
459
597
  export function renderBufferWithCursor(buffer, pasteSpans, prefix, label, cursor) {
460
598
  const w = terminalWidth();
461
599
  const borderW = Math.max(w - 4, 10);
462
- const [row, col] = cursorLineCol(buffer, cursor);
463
- // Build the inner content: prefix, then the text with the `▏` block cursor
464
- // always drawn at the cursor position (mirrors the original rich frame).
465
- let display;
600
+ // Content width inside the box (between `│ ` and ` │`).
601
+ const innerW = borderW - 2;
602
+ // Build styled runs: prefix, then the text with the `▏` block caret always
603
+ // drawn at the cursor position (mirrors the original rich frame).
604
+ const runs = [];
605
+ runs.push({ text: prefix, style: "\x1b[1m" });
606
+ runs.push({ text: " ", style: "" });
466
607
  if (!buffer) {
467
- display = `\x1b[1m▏\x1b[0m\x1b[2m${PROMPT_PLACEHOLDER}\x1b[0m`;
608
+ runs.push({ text: PROMPT_CARET, style: "\x1b[1m" });
609
+ runs.push({ text: PROMPT_PLACEHOLDER, style: "\x1b[2m" });
468
610
  }
469
611
  else {
470
612
  const segs = splitBlocks(buffer, pasteSpans);
@@ -513,50 +655,49 @@ export function renderBufferWithCursor(buffer, pasteSpans, prefix, label, cursor
513
655
  inside = ranges.length ? ranges[ranges.length - 1][1] - ranges[ranges.length - 1][0] : 0;
514
656
  }
515
657
  }
516
- display = "";
517
658
  for (let i = 0; i < segs.length; i++) {
518
659
  const [, kind, disp] = segs[i];
519
660
  if (i && !endsWithWs(segs[i - 1][2]))
520
- display += " ";
661
+ runs.push({ text: " ", style: "" });
521
662
  if (i === target) {
522
663
  if (kind === "collapsed") {
523
- display += `\x1b[1m\x1b[36m${disp}\x1b[0m`;
664
+ runs.push({ text: disp, style: "\x1b[1m\x1b[36m" });
524
665
  if (!endsWithWs(disp))
525
- display += " ";
526
- display += `\x1b[1m▏\x1b[0m`;
666
+ runs.push({ text: " ", style: "" });
667
+ runs.push({ text: PROMPT_CARET, style: "\x1b[1m" });
527
668
  }
528
669
  else {
529
- const bold = i === target;
530
- display += disp.slice(0, inside);
531
- display += `\x1b[1m▏\x1b[0m`;
532
- display += disp.slice(inside);
670
+ const before = disp.slice(0, inside);
671
+ const after = disp.slice(inside);
672
+ if (before)
673
+ runs.push({ text: before, style: "" });
674
+ runs.push({ text: PROMPT_CARET, style: "\x1b[1m" });
675
+ if (after)
676
+ runs.push({ text: after, style: "" });
533
677
  }
534
678
  }
535
679
  else {
536
- display += kind === "collapsed" ? `\x1b[1m\x1b[36m${disp}\x1b[0m` : disp;
680
+ runs.push({ text: disp, style: kind === "collapsed" ? "\x1b[1m\x1b[36m" : "" });
537
681
  }
538
682
  }
539
683
  }
540
- const content = `\x1b[1m${prefix}\x1b[0m ${display}`;
541
- const lines = content.split("\n");
684
+ const { rows, caretRow, caretCol } = wrapRuns(runs, innerW);
542
685
  // Top border with the label embedded on the left.
543
686
  const topPad = Math.max(borderW - label.length - 2, 0);
544
687
  const top = `\x1b[90m╭─ ${label} ${"─".repeat(topPad)}╮\x1b[0m`;
545
688
  // Body rows.
546
689
  const body = [];
547
- for (const l of lines) {
690
+ for (const l of rows) {
548
691
  const plain = l.replace(/\x1b\[[0-9;]*m/g, "");
549
692
  const pad = Math.max(borderW - 2 - plain.length, 0);
550
693
  body.push(`\x1b[90m│\x1b[0m ${l}${" ".repeat(pad)} \x1b[90m│\x1b[0m`);
551
694
  }
552
695
  const bottom = `\x1b[90m╰${"─".repeat(borderW)}╯\x1b[0m`;
553
696
  const frame = [top, ...body, bottom].join("\n");
554
- // Cursor placement: one row below the top border. Preceding columns are
555
- // `│ ` (2) + prefix (prefix.length) + ` ` (1) = prefix.length+3, so the first
556
- // display char (the `▏` block cursor) sits at column prefix.length+4 relative
557
- // to line column `col`.
558
- const cursorRow = row + 1;
559
- const cursorCol = col + prefix.length + 4;
697
+ // Cursor placement: one row below the top border. The body prefix (`│ `)
698
+ // shifts the content right by 2 columns, so add 2 to the wrapped caret col.
699
+ const cursorRow = (caretRow === -1 ? 0 : caretRow) + 1;
700
+ const cursorCol = (caretCol === -1 ? 0 : caretCol) + 2;
560
701
  const totalRows = body.length + 2;
561
702
  return { frame, cursorRow, cursorCol, totalRows };
562
703
  }
@@ -735,15 +876,32 @@ export function askBottomPrompt(label = "You", prefix = "❯", history = []) {
735
876
  pasteSpans = adjusted.filter(([s, e]) => s < e);
736
877
  };
737
878
  const moveLeft = () => {
738
- if (cursor > 0)
739
- cursor -= 1;
879
+ if (cursor <= 0)
880
+ return;
881
+ // Jump the caret to the start of a paste span it sits just past (mirrors
882
+ // Python), so a collapsed block is treated as one unit.
883
+ for (const [s, e] of pasteSpans) {
884
+ if (s < cursor && cursor <= e) {
885
+ cursor = s;
886
+ return;
887
+ }
888
+ }
889
+ cursor -= 1;
740
890
  };
741
891
  const moveRight = () => {
742
- if (cursor < buffer.length)
743
- cursor += 1;
892
+ if (cursor >= buffer.length)
893
+ return;
894
+ for (const [s, e] of pasteSpans) {
895
+ if (s <= cursor && cursor < e) {
896
+ cursor = e;
897
+ return;
898
+ }
899
+ }
900
+ cursor += 1;
744
901
  };
745
902
  const moveUp = () => {
746
903
  if (!buffer.includes("\n")) {
904
+ // Single row: navigate command history.
747
905
  if (!hist.length)
748
906
  return;
749
907
  if (histIdx === hist.length) {
@@ -755,10 +913,21 @@ export function askBottomPrompt(label = "You", prefix = "❯", history = []) {
755
913
  cursor = buffer.length;
756
914
  pasteSpans = [];
757
915
  }
916
+ return;
917
+ }
918
+ // Multi row: move the caret up to the previous line, preserving column.
919
+ if (cursor > 0) {
920
+ let lineStart = buffer.lastIndexOf("\n", cursor - 1);
921
+ lineStart = lineStart !== -1 ? lineStart : 0;
922
+ let prevStart = buffer.lastIndexOf("\n", lineStart - 1);
923
+ prevStart = prevStart !== -1 ? prevStart : 0;
924
+ const [, col] = cursorLineCol(buffer, cursor);
925
+ cursor = prevStart + Math.min(col, lineStart - prevStart);
758
926
  }
759
927
  };
760
928
  const moveDown = () => {
761
929
  if (!buffer.includes("\n")) {
930
+ // Single row: navigate command history.
762
931
  if (histIdx === hist.length || !draft)
763
932
  return;
764
933
  histIdx += 1;
@@ -772,6 +941,21 @@ export function askBottomPrompt(label = "You", prefix = "❯", history = []) {
772
941
  cursor = buffer.length;
773
942
  pasteSpans = [];
774
943
  }
944
+ return;
945
+ }
946
+ // Multi row: move the caret down to the next line, preserving column.
947
+ if (cursor < buffer.length) {
948
+ const [, col] = cursorLineCol(buffer, cursor);
949
+ let nextStart = buffer.indexOf("\n", cursor);
950
+ if (nextStart === -1) {
951
+ cursor = buffer.length;
952
+ }
953
+ else {
954
+ nextStart += 1;
955
+ let nxtEnd = buffer.indexOf("\n", nextStart);
956
+ nxtEnd = nxtEnd !== -1 ? nxtEnd : buffer.length;
957
+ cursor = Math.min(nextStart + col, nxtEnd);
958
+ }
775
959
  }
776
960
  };
777
961
  const onKeypress = (str, key) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oxecli/oxe",
3
- "version": "1.0.15",
3
+ "version": "1.0.17",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },