@oxecli/oxe 1.0.14 → 1.0.16

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/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
@@ -456,15 +456,76 @@ export function splitBlocks(buffer, pasteSpans) {
456
456
  }
457
457
  return segs;
458
458
  }
459
+ const PROMPT_CARET = "\u0000"; // sentinel marking the caret position
460
+ /**
461
+ * Wrap styled runs into physical rows of `width` visible columns, wrapping long
462
+ * lines so the panel box stays rectangular (mirrors rich's Panel auto-wrap).
463
+ * Returns the styled rows (no borders) and the caret's (row, col) in content
464
+ * coordinates, or (-1,-1) if no caret sentinel is present.
465
+ */
466
+ function wrapRuns(runs, width) {
467
+ const rows = [];
468
+ let cur = "";
469
+ let curLen = 0;
470
+ let caretRow = -1;
471
+ let caretCol = -1;
472
+ const flush = () => {
473
+ rows.push(cur);
474
+ cur = "";
475
+ curLen = 0;
476
+ };
477
+ for (const run of runs) {
478
+ if (run.text === PROMPT_CARET) {
479
+ // Caret occupies one visible column; if the current row is full, move it
480
+ // to the start of the next row so it stays inside the box.
481
+ if (curLen >= width)
482
+ flush();
483
+ caretRow = rows.length;
484
+ caretCol = curLen;
485
+ cur += run.style + "▏" + "\x1b[0m";
486
+ curLen += 1;
487
+ continue;
488
+ }
489
+ let text = run.text;
490
+ while (text) {
491
+ // Split off the leading run up to the next newline (if any).
492
+ const nl = text.indexOf("\n");
493
+ const seg = nl === -1 ? text : text.slice(0, nl);
494
+ let rest = seg;
495
+ while (rest.length > 0) {
496
+ if (curLen >= width)
497
+ flush();
498
+ const take = Math.min(rest.length, width - curLen);
499
+ const chunk = rest.slice(0, take);
500
+ cur += run.style ? run.style + chunk + "\x1b[0m" : chunk;
501
+ curLen += take;
502
+ rest = rest.slice(take);
503
+ }
504
+ if (nl !== -1) {
505
+ flush();
506
+ text = text.slice(nl + 1);
507
+ }
508
+ else {
509
+ break;
510
+ }
511
+ }
512
+ }
513
+ flush();
514
+ return { rows, caretRow, caretCol };
515
+ }
459
516
  export function renderBufferWithCursor(buffer, pasteSpans, prefix, label, cursor) {
460
517
  const w = terminalWidth();
461
518
  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;
519
+ // Content width inside the box (between `│ ` and ` │`).
520
+ const innerW = borderW - 2;
521
+ // Build styled runs: prefix, then the text with the `▏` block caret always
522
+ // drawn at the cursor position (mirrors the original rich frame).
523
+ const runs = [];
524
+ runs.push({ text: prefix, style: "\x1b[1m" });
525
+ runs.push({ text: " ", style: "" });
466
526
  if (!buffer) {
467
- display = `\x1b[1m▏\x1b[0m\x1b[2m${PROMPT_PLACEHOLDER}\x1b[0m`;
527
+ runs.push({ text: PROMPT_CARET, style: "\x1b[1m" });
528
+ runs.push({ text: PROMPT_PLACEHOLDER, style: "\x1b[2m" });
468
529
  }
469
530
  else {
470
531
  const segs = splitBlocks(buffer, pasteSpans);
@@ -513,50 +574,49 @@ export function renderBufferWithCursor(buffer, pasteSpans, prefix, label, cursor
513
574
  inside = ranges.length ? ranges[ranges.length - 1][1] - ranges[ranges.length - 1][0] : 0;
514
575
  }
515
576
  }
516
- display = "";
517
577
  for (let i = 0; i < segs.length; i++) {
518
578
  const [, kind, disp] = segs[i];
519
579
  if (i && !endsWithWs(segs[i - 1][2]))
520
- display += " ";
580
+ runs.push({ text: " ", style: "" });
521
581
  if (i === target) {
522
582
  if (kind === "collapsed") {
523
- display += `\x1b[1m\x1b[36m${disp}\x1b[0m`;
583
+ runs.push({ text: disp, style: "\x1b[1m\x1b[36m" });
524
584
  if (!endsWithWs(disp))
525
- display += " ";
526
- display += `\x1b[1m▏\x1b[0m`;
585
+ runs.push({ text: " ", style: "" });
586
+ runs.push({ text: PROMPT_CARET, style: "\x1b[1m" });
527
587
  }
528
588
  else {
529
- const bold = i === target;
530
- display += disp.slice(0, inside);
531
- display += `\x1b[1m▏\x1b[0m`;
532
- display += disp.slice(inside);
589
+ const before = disp.slice(0, inside);
590
+ const after = disp.slice(inside);
591
+ if (before)
592
+ runs.push({ text: before, style: "" });
593
+ runs.push({ text: PROMPT_CARET, style: "\x1b[1m" });
594
+ if (after)
595
+ runs.push({ text: after, style: "" });
533
596
  }
534
597
  }
535
598
  else {
536
- display += kind === "collapsed" ? `\x1b[1m\x1b[36m${disp}\x1b[0m` : disp;
599
+ runs.push({ text: disp, style: kind === "collapsed" ? "\x1b[1m\x1b[36m" : "" });
537
600
  }
538
601
  }
539
602
  }
540
- const content = `\x1b[1m${prefix}\x1b[0m ${display}`;
541
- const lines = content.split("\n");
603
+ const { rows, caretRow, caretCol } = wrapRuns(runs, innerW);
542
604
  // Top border with the label embedded on the left.
543
605
  const topPad = Math.max(borderW - label.length - 2, 0);
544
606
  const top = `\x1b[90m╭─ ${label} ${"─".repeat(topPad)}╮\x1b[0m`;
545
607
  // Body rows.
546
608
  const body = [];
547
- for (const l of lines) {
609
+ for (const l of rows) {
548
610
  const plain = l.replace(/\x1b\[[0-9;]*m/g, "");
549
611
  const pad = Math.max(borderW - 2 - plain.length, 0);
550
612
  body.push(`\x1b[90m│\x1b[0m ${l}${" ".repeat(pad)} \x1b[90m│\x1b[0m`);
551
613
  }
552
614
  const bottom = `\x1b[90m╰${"─".repeat(borderW)}╯\x1b[0m`;
553
615
  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;
616
+ // Cursor placement: one row below the top border. The body prefix (`│ `)
617
+ // shifts the content right by 2 columns, so add 2 to the wrapped caret col.
618
+ const cursorRow = (caretRow === -1 ? 0 : caretRow) + 1;
619
+ const cursorCol = (caretCol === -1 ? 0 : caretCol) + 2;
560
620
  const totalRows = body.length + 2;
561
621
  return { frame, cursorRow, cursorCol, totalRows };
562
622
  }
@@ -670,12 +730,37 @@ export function askBottomPrompt(label = "You", prefix = "❯", history = []) {
670
730
  return;
671
731
  }
672
732
  };
673
- const insert = (text) => {
733
+ const shiftSpansAfterInsert = (pos, delta) => {
734
+ const newSpans = [];
735
+ for (const [s, e] of pasteSpans) {
736
+ if (pos <= s)
737
+ newSpans.push([s + delta, e + delta]);
738
+ else if (pos >= e)
739
+ newSpans.push([s, e]);
740
+ else {
741
+ newSpans.push([s, pos]);
742
+ newSpans.push([pos + delta, e + delta]);
743
+ }
744
+ }
745
+ return newSpans;
746
+ };
747
+ const insert = (text, isPaste = false) => {
674
748
  if (histIdx !== hist.length) {
675
749
  draft = { buffer, cursor, spans: pasteSpans.slice() };
676
750
  histIdx = hist.length;
677
751
  }
752
+ const pasteStart = cursor;
678
753
  buffer = buffer.slice(0, cursor) + text + buffer.slice(cursor);
754
+ // Keep existing paste spans valid across the insertion point.
755
+ pasteSpans = shiftSpansAfterInsert(cursor, text.length);
756
+ // A real paste records a span so the prompt box can collapse it (mirrors
757
+ // Python's handle_paste). splitBlocks only collapses segments inside a
758
+ // span that meet the threshold, so small pastes stay expanded but remain
759
+ // tagged for cursor/backspace handling.
760
+ if (isPaste) {
761
+ pasteSpans.push([pasteStart, pasteStart + text.length]);
762
+ pasteSpans.sort((a, b) => a[0] - b[0]);
763
+ }
679
764
  cursor += text.length;
680
765
  };
681
766
  const backspace = () => {
@@ -689,18 +774,53 @@ export function askBottomPrompt(label = "You", prefix = "❯", history = []) {
689
774
  const before = Array.from(buffer.slice(0, cursor));
690
775
  before.pop();
691
776
  buffer = before.join("") + buffer.slice(cursor);
777
+ const deletedAt = cursor - 1;
692
778
  cursor -= 1;
779
+ // Adjust spans: shrink any span covering the deleted char, shift spans
780
+ // that start after it left by one.
781
+ const adjusted = [];
782
+ for (const [s, e] of pasteSpans) {
783
+ if (deletedAt >= s && deletedAt < e) {
784
+ const ne = e - 1;
785
+ if (s < ne)
786
+ adjusted.push([s, ne]);
787
+ }
788
+ else if (deletedAt < s) {
789
+ adjusted.push([s - 1, e - 1]);
790
+ }
791
+ else {
792
+ adjusted.push([s, e]);
793
+ }
794
+ }
795
+ pasteSpans = adjusted.filter(([s, e]) => s < e);
693
796
  };
694
797
  const moveLeft = () => {
695
- if (cursor > 0)
696
- cursor -= 1;
798
+ if (cursor <= 0)
799
+ return;
800
+ // Jump the caret to the start of a paste span it sits just past (mirrors
801
+ // Python), so a collapsed block is treated as one unit.
802
+ for (const [s, e] of pasteSpans) {
803
+ if (s < cursor && cursor <= e) {
804
+ cursor = s;
805
+ return;
806
+ }
807
+ }
808
+ cursor -= 1;
697
809
  };
698
810
  const moveRight = () => {
699
- if (cursor < buffer.length)
700
- cursor += 1;
811
+ if (cursor >= buffer.length)
812
+ return;
813
+ for (const [s, e] of pasteSpans) {
814
+ if (s <= cursor && cursor < e) {
815
+ cursor = e;
816
+ return;
817
+ }
818
+ }
819
+ cursor += 1;
701
820
  };
702
821
  const moveUp = () => {
703
822
  if (!buffer.includes("\n")) {
823
+ // Single row: navigate command history.
704
824
  if (!hist.length)
705
825
  return;
706
826
  if (histIdx === hist.length) {
@@ -712,10 +832,21 @@ export function askBottomPrompt(label = "You", prefix = "❯", history = []) {
712
832
  cursor = buffer.length;
713
833
  pasteSpans = [];
714
834
  }
835
+ return;
836
+ }
837
+ // Multi row: move the caret up to the previous line, preserving column.
838
+ if (cursor > 0) {
839
+ let lineStart = buffer.lastIndexOf("\n", cursor - 1);
840
+ lineStart = lineStart !== -1 ? lineStart : 0;
841
+ let prevStart = buffer.lastIndexOf("\n", lineStart - 1);
842
+ prevStart = prevStart !== -1 ? prevStart : 0;
843
+ const [, col] = cursorLineCol(buffer, cursor);
844
+ cursor = prevStart + Math.min(col, lineStart - prevStart);
715
845
  }
716
846
  };
717
847
  const moveDown = () => {
718
848
  if (!buffer.includes("\n")) {
849
+ // Single row: navigate command history.
719
850
  if (histIdx === hist.length || !draft)
720
851
  return;
721
852
  histIdx += 1;
@@ -729,6 +860,21 @@ export function askBottomPrompt(label = "You", prefix = "❯", history = []) {
729
860
  cursor = buffer.length;
730
861
  pasteSpans = [];
731
862
  }
863
+ return;
864
+ }
865
+ // Multi row: move the caret down to the next line, preserving column.
866
+ if (cursor < buffer.length) {
867
+ const [, col] = cursorLineCol(buffer, cursor);
868
+ let nextStart = buffer.indexOf("\n", cursor);
869
+ if (nextStart === -1) {
870
+ cursor = buffer.length;
871
+ }
872
+ else {
873
+ nextStart += 1;
874
+ let nxtEnd = buffer.indexOf("\n", nextStart);
875
+ nxtEnd = nxtEnd !== -1 ? nxtEnd : buffer.length;
876
+ cursor = Math.min(nextStart + col, nxtEnd);
877
+ }
732
878
  }
733
879
  };
734
880
  const onKeypress = (str, key) => {
@@ -782,8 +928,9 @@ export function askBottomPrompt(label = "You", prefix = "❯", history = []) {
782
928
  return;
783
929
  }
784
930
  if (str) {
785
- // paste / multi-char sequence
786
- insert(str);
931
+ // Multi-char sequence = a paste (single-char typed keys arrive one per
932
+ // event). Tag it so the prompt box collapses it when it meets the rules.
933
+ insert(str, str.length > 1);
787
934
  repaint();
788
935
  }
789
936
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oxecli/oxe",
3
- "version": "1.0.14",
3
+ "version": "1.0.16",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },