@oxecli/oxe 1.0.87 → 1.0.89
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 +42 -6
- package/dist/config.js +1 -1
- package/dist/engine.js +4 -4
- package/dist/tools.js +37 -1
- package/dist/ui.js +130 -67
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -18,6 +18,7 @@ export class CLI {
|
|
|
18
18
|
story = [];
|
|
19
19
|
sessionId = null;
|
|
20
20
|
promptHistory = [];
|
|
21
|
+
lastPrompt = "";
|
|
21
22
|
interruptPending = false;
|
|
22
23
|
constructor() {
|
|
23
24
|
clearScreen();
|
|
@@ -395,10 +396,50 @@ export class CLI {
|
|
|
395
396
|
const used = estimateTokens(this.inputItems);
|
|
396
397
|
const budget = max_context_tokens - context_overhead_margin;
|
|
397
398
|
const pct = Math.max(0, Math.min(100, Math.round(((budget - used) / budget) * 100)));
|
|
399
|
+
// Queries run through onSubmit so the prompt box stays focused and
|
|
400
|
+
// editable while the AI streams its reply and tool calls above it.
|
|
401
|
+
// Commands (slash inputs) settle back to this loop for handling.
|
|
402
|
+
const onSubmit = async (input, spans) => {
|
|
403
|
+
this.interruptPending = false;
|
|
404
|
+
this.promptHistory.push(input);
|
|
405
|
+
this.lastPrompt = input;
|
|
406
|
+
dockAppendContent(`\x1b[1;36m❯\x1b[0m ${userDisplayText(input, spans)}\n`);
|
|
407
|
+
queryStarted = true;
|
|
408
|
+
try {
|
|
409
|
+
await engine.executeQuery(input, this.inputItems, this.story, spans);
|
|
410
|
+
}
|
|
411
|
+
catch (qerr) {
|
|
412
|
+
if (qerr?.message === "interrupt") {
|
|
413
|
+
if (!engine.queryHasOutput && !engine.queryCalledTool) {
|
|
414
|
+
dockAppendContent(aiMarkdown("What else can I help you with?") + "\n");
|
|
415
|
+
}
|
|
416
|
+
engine.inQuery = false;
|
|
417
|
+
stripOrphanCalls(this.inputItems);
|
|
418
|
+
if (this.inputItems.length &&
|
|
419
|
+
this.inputItems[this.inputItems.length - 1]["role"] === "user") {
|
|
420
|
+
this.inputItems.pop();
|
|
421
|
+
}
|
|
422
|
+
if (this.story.length &&
|
|
423
|
+
this.story[this.story.length - 1]["type"] === "user") {
|
|
424
|
+
this.story.pop();
|
|
425
|
+
}
|
|
426
|
+
this.interruptPending = true;
|
|
427
|
+
dockAppendContent("\x1b[90mInterrupted agent. Press Ctrl+C again to exit, or type a new prompt.\x1b[0m\n");
|
|
428
|
+
}
|
|
429
|
+
else if (qerr?.message !== "eof") {
|
|
430
|
+
throw qerr;
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
finally {
|
|
434
|
+
queryStarted = false;
|
|
435
|
+
}
|
|
436
|
+
this.saveSession(engine);
|
|
437
|
+
};
|
|
438
|
+
const onInterrupt = () => engine.interrupt();
|
|
398
439
|
const [input, spans] = await askBottomPrompt("You", "❯", this.promptHistory, {
|
|
399
440
|
left: "\x1b[90mPress \x1b[1;36mCtrl+C\x1b[0m\x1b[90m to interrupt\x1b[0m",
|
|
400
441
|
right: `\x1b[1;36m${pct}%\x1b[0m \x1b[90muntil auto-compact\x1b[0m`,
|
|
401
|
-
});
|
|
442
|
+
}, this.lastPrompt, onSubmit, onInterrupt);
|
|
402
443
|
if (!input.trim())
|
|
403
444
|
continue;
|
|
404
445
|
this.interruptPending = false;
|
|
@@ -450,11 +491,6 @@ export class CLI {
|
|
|
450
491
|
}
|
|
451
492
|
continue;
|
|
452
493
|
}
|
|
453
|
-
dockAppendContent(`\x1b[1;36m❯\x1b[0m ${userDisplayText(input, spans)}\n`);
|
|
454
|
-
queryStarted = true;
|
|
455
|
-
await engine.executeQuery(input, this.inputItems, this.story, spans);
|
|
456
|
-
queryStarted = false;
|
|
457
|
-
this.saveSession(engine);
|
|
458
494
|
}
|
|
459
495
|
catch (err) {
|
|
460
496
|
if (err?.message === "eof") {
|
package/dist/config.js
CHANGED
|
@@ -191,7 +191,7 @@ export async function loadOrPrompt() {
|
|
|
191
191
|
break;
|
|
192
192
|
}
|
|
193
193
|
else {
|
|
194
|
-
process.stdout.write(`\x1b[31mAuthentication Error:\x1b[0m ${validation.error}\n
|
|
194
|
+
process.stdout.write(`\x1b[31mAuthentication Error:\x1b[0m ${validation.error}\n`);
|
|
195
195
|
api_key = "";
|
|
196
196
|
}
|
|
197
197
|
}
|
package/dist/engine.js
CHANGED
|
@@ -542,7 +542,7 @@ export class InferenceEngine {
|
|
|
542
542
|
// Print the tool label the instant the command starts, then show a
|
|
543
543
|
// "╰─ Working..." dots line that swaps to the real result in place.
|
|
544
544
|
dockAppendContent(`${started}\n`);
|
|
545
|
-
dockTransientStart();
|
|
545
|
+
dockTransientStart("flush");
|
|
546
546
|
const toolSpinner = new Spinner();
|
|
547
547
|
toolSpinner.startDots("\x1b[90m╰─\x1b[0m Working");
|
|
548
548
|
const rawResult = await this.runTool(c.name, c.arguments);
|
|
@@ -564,10 +564,10 @@ export class InferenceEngine {
|
|
|
564
564
|
// produced a diff it renders directly below the action line with no
|
|
565
565
|
// gap; otherwise the dock gap separates the action from what follows.
|
|
566
566
|
toolSpinner.stop();
|
|
567
|
-
dockReplaceTransient(action
|
|
567
|
+
dockReplaceTransient(action);
|
|
568
568
|
if (diff) {
|
|
569
|
-
dockTransientStart();
|
|
570
|
-
dockReplaceTransient(diff
|
|
569
|
+
dockTransientStart("flush");
|
|
570
|
+
dockReplaceTransient(diff);
|
|
571
571
|
}
|
|
572
572
|
const truncatedResult = c.name === "read_file"
|
|
573
573
|
? truncateToolOutput(rawResult, max_read_file_stored_chars)
|
package/dist/tools.js
CHANGED
|
@@ -31,6 +31,36 @@ function truncateDiffLine(line) {
|
|
|
31
31
|
}
|
|
32
32
|
return line;
|
|
33
33
|
}
|
|
34
|
+
/**
|
|
35
|
+
* Recolor bracket syntax characters (`( ) [ ] { }`) inside an already-ANSI
|
|
36
|
+
* rendered string to `fg`, restoring the row's white base after each bracket.
|
|
37
|
+
* ANSI escape sequences are skipped so the injected color codes are never
|
|
38
|
+
* corrupted. Used so a deleted (red) diff row's bracket syntax matches the
|
|
39
|
+
* red of its line number instead of inheriting the syntax highlighter's color.
|
|
40
|
+
*/
|
|
41
|
+
function recolorBrackets(text, fg, base) {
|
|
42
|
+
let out = "";
|
|
43
|
+
let i = 0;
|
|
44
|
+
while (i < text.length) {
|
|
45
|
+
if (text[i] === "\x1b") {
|
|
46
|
+
const m = text.slice(i).match(/^\x1b\[[0-9;]*[a-zA-Z]/);
|
|
47
|
+
if (m) {
|
|
48
|
+
out += m[0];
|
|
49
|
+
i += m[0].length;
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
const ch = text[i];
|
|
54
|
+
if (ch === "(" || ch === ")" || ch === "[" || ch === "]" || ch === "{" || ch === "}") {
|
|
55
|
+
out += fg + ch + base;
|
|
56
|
+
}
|
|
57
|
+
else {
|
|
58
|
+
out += ch;
|
|
59
|
+
}
|
|
60
|
+
i++;
|
|
61
|
+
}
|
|
62
|
+
return out;
|
|
63
|
+
}
|
|
34
64
|
// Diff output is deferred (not written straight to stdout) so it doesn't
|
|
35
65
|
// collide with the tool spinner that is still animating while the tool runs.
|
|
36
66
|
const pendingDiffOutput = [];
|
|
@@ -148,8 +178,14 @@ function displayDiff(pathName, oldContent, newContent) {
|
|
|
148
178
|
// the whole body.
|
|
149
179
|
const raw = highlightCodeLine(v.body);
|
|
150
180
|
const body = truncateStyled(raw.replace(/\x1b\[0m/g, `\x1b[0m${bg}\x1b[97m`), bodyMax);
|
|
181
|
+
// Deleted rows: recolor bracket syntax to the same red as the line
|
|
182
|
+
// number (fg) so brackets match the row's deleted color. Added rows keep
|
|
183
|
+
// their green highlighter as-is.
|
|
184
|
+
const shown = isAdd
|
|
185
|
+
? body
|
|
186
|
+
: recolorBrackets(body, fg, "\x1b[97m");
|
|
151
187
|
const fill = Math.max(0, termW - ind.length - numW - 4 - plainLen(body));
|
|
152
|
-
pendingDiffOutput.push(`${ind}${bg}${fg}${numStr} ${sign} \x1b[0m${bg}\x1b[97m${
|
|
188
|
+
pendingDiffOutput.push(`${ind}${bg}${fg}${numStr} ${sign} \x1b[0m${bg}\x1b[97m${shown}${bg}\x1b[97m${" ".repeat(fill)}\x1b[0m`);
|
|
153
189
|
}
|
|
154
190
|
else {
|
|
155
191
|
const raw = v.kind === "\\" ? v.body : highlightCodeLine(v.body);
|
package/dist/ui.js
CHANGED
|
@@ -293,6 +293,12 @@ export function markdownToAnsi(text) {
|
|
|
293
293
|
out.push(`\x1b[90m${"─".repeat(Math.min(width, 60))}\x1b[0m`);
|
|
294
294
|
continue;
|
|
295
295
|
}
|
|
296
|
+
// Collapse consecutive blank lines to a single empty row (never a leading one)
|
|
297
|
+
if (!raw.trim()) {
|
|
298
|
+
if (out.length && out[out.length - 1] !== "")
|
|
299
|
+
out.push("");
|
|
300
|
+
continue;
|
|
301
|
+
}
|
|
296
302
|
let line = raw;
|
|
297
303
|
// Headers (render as styled headings with the '#' markers stripped)
|
|
298
304
|
const h1 = line.match(/^#\s+(.+)$/);
|
|
@@ -505,13 +511,16 @@ export function displayRows(text) {
|
|
|
505
511
|
// one blank row is preserved between the latest content and the box.
|
|
506
512
|
//
|
|
507
513
|
// dockRel: how many rows the cursor sits above boxTop. 0 = parked at boxTop,
|
|
508
|
-
//
|
|
514
|
+
// 2 = transient content row (spinner/thinking line), with the blank gap row
|
|
515
|
+
// (boxTop-1) kept between it and the box.
|
|
516
|
+
// dockTransient: true while a transient spinner is parked on the content row.
|
|
509
517
|
// dockGap: true when a blank row already exists above the current row (after
|
|
510
518
|
// the box is torn down), so askBottomPrompt does not add a second one.
|
|
511
519
|
let docked = false;
|
|
512
520
|
let dockRel = 0;
|
|
513
521
|
let dockBoxRows = 0;
|
|
514
522
|
let dockGap = false;
|
|
523
|
+
let dockTransient = false;
|
|
515
524
|
let dockFrameLines = [];
|
|
516
525
|
export function dockIsDocked() {
|
|
517
526
|
return docked;
|
|
@@ -528,12 +537,14 @@ export function dockSetInactive() {
|
|
|
528
537
|
dockRel = 0;
|
|
529
538
|
dockBoxRows = 0;
|
|
530
539
|
dockGap = false;
|
|
540
|
+
dockTransient = false;
|
|
531
541
|
dockFrameLines = [];
|
|
532
542
|
}
|
|
533
543
|
export function dockEnterDocked() {
|
|
534
544
|
docked = true;
|
|
535
545
|
dockRel = 0;
|
|
536
546
|
dockGap = false;
|
|
547
|
+
dockTransient = false;
|
|
537
548
|
}
|
|
538
549
|
/** Erase the docked box and leave the cursor at boxTop with the gap above it,
|
|
539
550
|
* so a pager/replay can write there and a later prompt re-docks cleanly. */
|
|
@@ -546,55 +557,48 @@ export function dockTearDown() {
|
|
|
546
557
|
dockFrameLines = [];
|
|
547
558
|
}
|
|
548
559
|
}
|
|
549
|
-
/** Park
|
|
550
|
-
|
|
560
|
+
/** Park a transient spinner two rows above the box (dockRel=2), keeping boxTop-1
|
|
561
|
+
* as the single blank gap. "flush" parks the spinner directly under the
|
|
562
|
+
* preceding content (label, spinner, blank, box); "blank" gives it its own
|
|
563
|
+
* blank row above (echo, blank, spinner, blank, box). Idempotent while parked. */
|
|
564
|
+
export function dockTransientStart(mode = "blank") {
|
|
551
565
|
if (!docked || dockBoxRows < 1)
|
|
552
566
|
return;
|
|
553
|
-
if (
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
}
|
|
567
|
+
if (dockTransient)
|
|
568
|
+
return;
|
|
569
|
+
const insert = mode === "flush" ? 1 : 2;
|
|
570
|
+
process.stdout.write(`\x1b[${insert}L`);
|
|
571
|
+
if (mode === "flush")
|
|
572
|
+
process.stdout.write("\x1b[1A");
|
|
573
|
+
process.stdout.write("\r");
|
|
574
|
+
dockRel = 2;
|
|
575
|
+
dockTransient = true;
|
|
557
576
|
}
|
|
577
|
+
/** Erase the box and any transient rows above it, leaving the cursor at boxTop
|
|
578
|
+
* with the gap row intact above. */
|
|
558
579
|
function dockEraseBox() {
|
|
559
580
|
if (!docked || dockBoxRows < 1)
|
|
560
581
|
return;
|
|
561
|
-
|
|
582
|
+
const rel = dockRel;
|
|
583
|
+
process.stdout.write(`\x1b[${dockBoxRows - 1 + rel}B`);
|
|
562
584
|
for (let i = 0; i < dockBoxRows; i++) {
|
|
563
585
|
process.stdout.write("\r\x1b[K");
|
|
564
586
|
if (i < dockBoxRows - 1)
|
|
565
587
|
process.stdout.write("\x1b[1A");
|
|
566
588
|
}
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
}
|
|
571
|
-
function dockEraseBoxKeep() {
|
|
572
|
-
if (!docked || dockBoxRows < 1)
|
|
573
|
-
return;
|
|
574
|
-
process.stdout.write(`\x1b[${dockBoxRows - 1 + dockRel}B`);
|
|
575
|
-
for (let i = 0; i < dockBoxRows; i++) {
|
|
576
|
-
process.stdout.write("\r\x1b[K");
|
|
577
|
-
if (i < dockBoxRows - 1)
|
|
578
|
-
process.stdout.write("\x1b[1A");
|
|
589
|
+
if (rel > 0) {
|
|
590
|
+
for (let i = 0; i < rel; i++)
|
|
591
|
+
process.stdout.write("\x1b[1A\r\x1b[K");
|
|
592
|
+
process.stdout.write(`\x1b[${rel}B`);
|
|
579
593
|
}
|
|
580
|
-
process.stdout.write(`\x1b[${dockRel}A`);
|
|
581
594
|
docked = false;
|
|
582
595
|
dockRel = 0;
|
|
583
596
|
dockGap = true;
|
|
597
|
+
dockTransient = false;
|
|
584
598
|
}
|
|
585
|
-
/**
|
|
586
|
-
*
|
|
587
|
-
|
|
588
|
-
if (dockBoxRows < 1)
|
|
589
|
-
return;
|
|
590
|
-
process.stdout.write("\n\n" + dockFrameLines.join("\n"));
|
|
591
|
-
process.stdout.write(`\x1b[${dockBoxRows - 1}A\r`);
|
|
592
|
-
dockRel = 0;
|
|
593
|
-
docked = true;
|
|
594
|
-
dockGap = false;
|
|
595
|
-
}
|
|
596
|
-
/** Commit content at the box top, pushing the box down, preserving exactly one
|
|
597
|
-
* blank row between the content and the box. */
|
|
599
|
+
/** Commit content at the box top: insert `rows+1` lines to push the box down
|
|
600
|
+
* (no erase/redraw), write the body, leave exactly one blank row between the
|
|
601
|
+
* content and the box, and park the cursor at the new boxTop. */
|
|
598
602
|
export function dockAppendContent(text) {
|
|
599
603
|
if (!text)
|
|
600
604
|
return;
|
|
@@ -602,14 +606,18 @@ export function dockAppendContent(text) {
|
|
|
602
606
|
process.stdout.write(text);
|
|
603
607
|
return;
|
|
604
608
|
}
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
609
|
+
const body = text.replace(/\n+$/, "");
|
|
610
|
+
if (!body)
|
|
611
|
+
return;
|
|
612
|
+
const rows = displayRows(body);
|
|
613
|
+
process.stdout.write(`\x1b[${rows + 1}L`);
|
|
614
|
+
process.stdout.write(body + "\n\r");
|
|
615
|
+
process.stdout.write("\x1b[1B\r");
|
|
611
616
|
}
|
|
612
|
-
/** Replace a transient spinner row (
|
|
617
|
+
/** Replace a transient spinner row (dockRel=2, two rows above the box) with
|
|
618
|
+
* real (possibly multi-row) content: erase the spinner line, push the box
|
|
619
|
+
* down by `rows-1` if the content is taller than the transient row, write the
|
|
620
|
+
* content, and park the cursor at the boxTop with one blank row above it. */
|
|
613
621
|
export function dockReplaceTransient(text) {
|
|
614
622
|
if (!text)
|
|
615
623
|
return;
|
|
@@ -617,12 +625,22 @@ export function dockReplaceTransient(text) {
|
|
|
617
625
|
process.stdout.write(text);
|
|
618
626
|
return;
|
|
619
627
|
}
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
628
|
+
if (!dockTransient) {
|
|
629
|
+
dockAppendContent(text);
|
|
630
|
+
return;
|
|
631
|
+
}
|
|
632
|
+
const body = text.replace(/\n+$/, "");
|
|
633
|
+
if (!body)
|
|
634
|
+
return;
|
|
635
|
+
const rows = displayRows(body);
|
|
636
|
+
const push = rows + 1 - dockRel;
|
|
637
|
+
process.stdout.write("\r\x1b[K");
|
|
638
|
+
if (push > 0)
|
|
639
|
+
process.stdout.write(`\x1b[${push}L`);
|
|
640
|
+
process.stdout.write(body);
|
|
641
|
+
process.stdout.write("\x1b[2B\r");
|
|
642
|
+
dockRel = 0;
|
|
643
|
+
dockTransient = false;
|
|
626
644
|
}
|
|
627
645
|
// ---------------------------------------------------------------------------
|
|
628
646
|
// Cursor & Spinner
|
|
@@ -903,9 +921,23 @@ export function cursorLineCol(buffer, cursor) {
|
|
|
903
921
|
function endsWithWs(s) {
|
|
904
922
|
return s.length > 0 && /[ \t\n]/.test(s[s.length - 1]);
|
|
905
923
|
}
|
|
924
|
+
/**
|
|
925
|
+
* Whether a spacer should be inserted between the previous segment and the
|
|
926
|
+
* current one. A space is added when the previous segment doesn't already end
|
|
927
|
+
* in whitespace — EXCEPT for non-block (inline) pastes, which keep their
|
|
928
|
+
* leading space but never force a trailing space after them. Collapsed pasted
|
|
929
|
+
* blocks always get spaced on both sides.
|
|
930
|
+
*/
|
|
931
|
+
function joinNeedsSpace(prev) {
|
|
932
|
+
if (endsWithWs(prev[2]))
|
|
933
|
+
return false;
|
|
934
|
+
if (prev[1] === "text" && prev[3])
|
|
935
|
+
return false; // non-block inline paste
|
|
936
|
+
return true;
|
|
937
|
+
}
|
|
906
938
|
export function splitBlocks(buffer, pasteSpans) {
|
|
907
939
|
if (!buffer)
|
|
908
|
-
return [["", "text", ""]];
|
|
940
|
+
return [["", "text", "", false]];
|
|
909
941
|
const pts = new Set([0, buffer.length]);
|
|
910
942
|
for (const [s, e] of pasteSpans) {
|
|
911
943
|
pts.add(s);
|
|
@@ -922,10 +954,10 @@ export function splitBlocks(buffer, pasteSpans) {
|
|
|
922
954
|
const text = buffer.slice(a, b);
|
|
923
955
|
if (isPaste(a, b) && shouldCollapsePaste(text)) {
|
|
924
956
|
const n = text.split("\n").length;
|
|
925
|
-
segs.push(["", "collapsed", `[Pasted text, ${n} lines]
|
|
957
|
+
segs.push(["", "collapsed", `[Pasted text, ${n} lines]`, true]);
|
|
926
958
|
}
|
|
927
959
|
else {
|
|
928
|
-
segs.push(["", "text", text]);
|
|
960
|
+
segs.push(["", "text", text, isPaste(a, b)]);
|
|
929
961
|
}
|
|
930
962
|
}
|
|
931
963
|
return segs;
|
|
@@ -1056,8 +1088,8 @@ export function renderBufferWithCursor(buffer, pasteSpans, prefix, label, cursor
|
|
|
1056
1088
|
}
|
|
1057
1089
|
}
|
|
1058
1090
|
for (let i = 0; i < segs.length; i++) {
|
|
1059
|
-
const [, kind, disp] = segs[i];
|
|
1060
|
-
if (i &&
|
|
1091
|
+
const [, kind, disp, pasted] = segs[i];
|
|
1092
|
+
if (i && joinNeedsSpace(segs[i - 1]))
|
|
1061
1093
|
runs.push({ text: " ", style: "" });
|
|
1062
1094
|
if (i === target) {
|
|
1063
1095
|
if (kind === "collapsed") {
|
|
@@ -1131,7 +1163,7 @@ function isEnterKey(str, key) {
|
|
|
1131
1163
|
seq === "\x1bOM" // application keypad
|
|
1132
1164
|
);
|
|
1133
1165
|
}
|
|
1134
|
-
export function askBottomPrompt(label = "You", prefix = "❯", history = [], hints) {
|
|
1166
|
+
export function askBottomPrompt(label = "You", prefix = "❯", history = [], hints, initialBuffer = "", onSubmit, onInterrupt) {
|
|
1135
1167
|
return new Promise((resolve, reject) => {
|
|
1136
1168
|
const isTTY = process.stdin.isTTY;
|
|
1137
1169
|
if (!isTTY) {
|
|
@@ -1147,8 +1179,8 @@ export function askBottomPrompt(label = "You", prefix = "❯", history = [], hin
|
|
|
1147
1179
|
});
|
|
1148
1180
|
return;
|
|
1149
1181
|
}
|
|
1150
|
-
let buffer =
|
|
1151
|
-
let cursor =
|
|
1182
|
+
let buffer = initialBuffer;
|
|
1183
|
+
let cursor = buffer.length;
|
|
1152
1184
|
let pasteSpans = [];
|
|
1153
1185
|
const hist = history.slice();
|
|
1154
1186
|
let histIdx = hist.length;
|
|
@@ -1169,6 +1201,9 @@ export function askBottomPrompt(label = "You", prefix = "❯", history = [], hin
|
|
|
1169
1201
|
let lastCursorRow = 0;
|
|
1170
1202
|
let lastTotalRows = 0;
|
|
1171
1203
|
let prevFrameLines = null;
|
|
1204
|
+
// True while an onSubmit query is running. While it runs the box stays
|
|
1205
|
+
// focused and editable; Enter won't re-submit and Ctrl+C interrupts it.
|
|
1206
|
+
let submitting = false;
|
|
1172
1207
|
const clearBox = () => {
|
|
1173
1208
|
process.stdout.write(`\x1b[${lastCursorRow}A`);
|
|
1174
1209
|
for (let i = 0; i < lastTotalRows; i++) {
|
|
@@ -1221,12 +1256,14 @@ export function askBottomPrompt(label = "You", prefix = "❯", history = [], hin
|
|
|
1221
1256
|
process.stdout.write("\n");
|
|
1222
1257
|
}
|
|
1223
1258
|
prevFrameLines = newLines;
|
|
1224
|
-
lastCursorRow =
|
|
1259
|
+
lastCursorRow = 0;
|
|
1225
1260
|
lastTotalRows = totalRows;
|
|
1226
1261
|
dockSetFrame(frame, totalRows);
|
|
1227
1262
|
dockEnterDocked();
|
|
1228
|
-
|
|
1229
|
-
|
|
1263
|
+
// Park the cursor at the box top so concurrent dock content (AI
|
|
1264
|
+
// streaming, tool results) inserts above the box rather than into it.
|
|
1265
|
+
const up = totalRows - 1;
|
|
1266
|
+
process.stdout.write(`\x1b[${up}A\r`);
|
|
1230
1267
|
}
|
|
1231
1268
|
else {
|
|
1232
1269
|
process.stdout.write(`\x1b[${lastCursorRow}A\r`);
|
|
@@ -1240,13 +1277,11 @@ export function askBottomPrompt(label = "You", prefix = "❯", history = [], hin
|
|
|
1240
1277
|
process.stdout.write("\n");
|
|
1241
1278
|
}
|
|
1242
1279
|
const atLine = maxLen - 1;
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
process.stdout.write(`\x1b[${cursorRow - atLine}B`);
|
|
1247
|
-
process.stdout.write(`\r\x1b[${cursorCol}C`);
|
|
1280
|
+
// Return the cursor to the box top so concurrent dock content keeps
|
|
1281
|
+
// inserting above the box.
|
|
1282
|
+
process.stdout.write(`\x1b[${atLine}A\r`);
|
|
1248
1283
|
prevFrameLines = newLines;
|
|
1249
|
-
lastCursorRow =
|
|
1284
|
+
lastCursorRow = 0;
|
|
1250
1285
|
lastTotalRows = totalRows;
|
|
1251
1286
|
dockSetFrame(frame, totalRows);
|
|
1252
1287
|
}
|
|
@@ -1531,6 +1566,12 @@ export function askBottomPrompt(label = "You", prefix = "❯", history = [], hin
|
|
|
1531
1566
|
pasteBurstTimer = null;
|
|
1532
1567
|
}
|
|
1533
1568
|
if (key && key.ctrl && key.name === "c") {
|
|
1569
|
+
// While a query is running, Ctrl+C interrupts the query instead of
|
|
1570
|
+
// leaving the prompt (the box stays focused for editing).
|
|
1571
|
+
if (submitting && onInterrupt) {
|
|
1572
|
+
onInterrupt();
|
|
1573
|
+
return;
|
|
1574
|
+
}
|
|
1534
1575
|
settle(new Error("interrupt"), true);
|
|
1535
1576
|
return;
|
|
1536
1577
|
}
|
|
@@ -1571,9 +1612,31 @@ export function askBottomPrompt(label = "You", prefix = "❯", history = [], hin
|
|
|
1571
1612
|
return;
|
|
1572
1613
|
}
|
|
1573
1614
|
if (isEnterKey(str, key)) {
|
|
1574
|
-
|
|
1575
|
-
|
|
1615
|
+
const trimmed = buffer.trim();
|
|
1616
|
+
if (!trimmed)
|
|
1617
|
+
return;
|
|
1618
|
+
// While a query is running the box stays focused and editable; Enter is
|
|
1619
|
+
// ignored (even for commands) so the query finishes cleanly first.
|
|
1620
|
+
if (submitting)
|
|
1621
|
+
return;
|
|
1622
|
+
// With an onSubmit handler, ordinary prompts are submitted in place so
|
|
1623
|
+
// the box stays focused and editable while the query streams above it.
|
|
1624
|
+
// Slash commands (and any prompt when no handler is wired) settle so the
|
|
1625
|
+
// caller's existing command loop handles them.
|
|
1626
|
+
if (onSubmit && !trimmed.startsWith("/")) {
|
|
1627
|
+
submitting = true;
|
|
1628
|
+
const submitted = buffer;
|
|
1629
|
+
const submittedSpans = pasteSpans.slice();
|
|
1630
|
+
Promise.resolve()
|
|
1631
|
+
.then(() => onSubmit(submitted, submittedSpans))
|
|
1632
|
+
.then(() => {
|
|
1633
|
+
submitting = false;
|
|
1634
|
+
}, () => {
|
|
1635
|
+
submitting = false;
|
|
1636
|
+
});
|
|
1637
|
+
return;
|
|
1576
1638
|
}
|
|
1639
|
+
settle([buffer, pasteSpans], false);
|
|
1577
1640
|
return;
|
|
1578
1641
|
}
|
|
1579
1642
|
if (key && key.name === "backspace") {
|
|
@@ -1653,7 +1716,7 @@ export function userDisplayText(payload, pasteSpans) {
|
|
|
1653
1716
|
let out = "";
|
|
1654
1717
|
for (let i = 0; i < segs.length; i++) {
|
|
1655
1718
|
const [, kind, disp] = segs[i];
|
|
1656
|
-
if (i &&
|
|
1719
|
+
if (i && joinNeedsSpace(segs[i - 1]))
|
|
1657
1720
|
out += " ";
|
|
1658
1721
|
out += kind === "collapsed" ? `\x1b[1;36m${disp}\x1b[0m` : disp;
|
|
1659
1722
|
}
|