@oxecli/oxe 1.0.61 → 1.0.63

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/engine.js CHANGED
@@ -5,6 +5,7 @@ import { buildTools, truncateToolOutput, toolReadFile, toolWriteFile, toolEditFi
5
5
  import { mutedMarkdown, aiMarkdown, tickDuration, displayRows, safeCommitPoint, printAiChunk, formatToolAction, renderPanel, Spinner, hideCursor, } from "./ui.js";
6
6
  import { reportUsage } from "./api.js";
7
7
  import { stripOrphanCalls, persistCompactionSummary, toolOutputFailed, } from "./sessions.js";
8
+ import { flushPendingDiffOutput } from "./tools.js";
8
9
  // ---------------------------------------------------------------------------
9
10
  // Token estimation
10
11
  // ---------------------------------------------------------------------------
@@ -169,20 +170,15 @@ export class InferenceEngine {
169
170
  let thinkingStart = null;
170
171
  const workStatus = new Spinner();
171
172
  const workingStarted = Date.now();
172
- workStatus.start(`Working ${tickDuration(0)}`);
173
- const workTimer = setInterval(() => {
174
- workStatus.update(`Working ${tickDuration((Date.now() - workingStarted) / 1000)}`);
175
- }, 100);
173
+ workStatus.startWithRender(() => `Working ${tickDuration((Date.now() - workingStarted) / 1000)}`);
176
174
  let workingReported = false;
177
175
  let response = null;
178
176
  let etype = null;
179
177
  const silenceWorking = () => {
180
- clearInterval(workTimer);
181
178
  workStatus.stop();
182
179
  workingReported = true;
183
180
  };
184
181
  const reportWorking = () => {
185
- clearInterval(workTimer);
186
182
  workStatus.stop();
187
183
  if (workingReported)
188
184
  return;
@@ -288,7 +284,6 @@ export class InferenceEngine {
288
284
  }
289
285
  }
290
286
  finally {
291
- clearInterval(workTimer);
292
287
  workStatus.stop();
293
288
  const stillThinking = thinkingStart !== null;
294
289
  finishThinking(stillThinking);
@@ -563,12 +558,9 @@ export class InferenceEngine {
563
558
  return;
564
559
  }
565
560
  if (!calls.length) {
566
- const gap = text.endsWith("\n\n")
567
- ? ""
568
- : text.endsWith("\n")
569
- ? "\n"
570
- : "\n\n";
571
- process.stdout.write(gap + summary() + "\n");
561
+ // Exactly one blank row between the message and the footer summary.
562
+ const trailing = (text.match(/\n+$/) ?? [""])[0].length;
563
+ process.stdout.write("\n".repeat(Math.max(0, 2 - trailing)) + summary() + "\n");
572
564
  story.push({ type: "footer", text: footerText() });
573
565
  attachFooter(conversation);
574
566
  attachFooter(inputItems);
@@ -576,8 +568,12 @@ export class InferenceEngine {
576
568
  this.workRows = 0;
577
569
  return;
578
570
  }
579
- if (text.trim())
580
- process.stdout.write("\n");
571
+ if (text.trim()) {
572
+ // Ensure exactly one blank row between the assistant message and the
573
+ // first tool call, no matter how many newlines the message ended with.
574
+ const trailing = (text.match(/\n+$/) ?? [""])[0].length;
575
+ process.stdout.write("\n".repeat(Math.max(0, 2 - trailing)));
576
+ }
581
577
  for (const c of calls) {
582
578
  this.workActive = false;
583
579
  const started = formatToolAction(c.name, c.arguments, "started");
@@ -598,6 +594,9 @@ export class InferenceEngine {
598
594
  const icon = failed ? "\x1b[31m✗\x1b[0m" : "\x1b[32m✓\x1b[0m";
599
595
  const actionStyle = failed ? "\x1b[31m" : "\x1b[32m";
600
596
  process.stdout.write(`${icon} ${actionStyle}${action}\x1b[0m\n\n`);
597
+ // Any diff the tool produced is flushed only now, after the spinner
598
+ // has stopped, so the box renders cleanly below the action line.
599
+ flushPendingDiffOutput();
601
600
  const truncatedResult = c.name === "read_file"
602
601
  ? truncateToolOutput(rawResult, max_read_file_stored_chars)
603
602
  : truncateToolOutput(rawResult);
@@ -612,11 +611,12 @@ export class InferenceEngine {
612
611
  pending.push(outputItem);
613
612
  }
614
613
  }
615
- process.stdout.write("\n");
614
+ // The preceding tool action already leaves the cursor on a blank row, so
615
+ // no extra newline is needed above; renderPanel ends with its own "\n",
616
+ // and the prompt adds one leading "\n" -> exactly one blank row below.
616
617
  renderPanel("[bold yellow]⚠ Max tool-call iterations reached for this turn.[/bold yellow]\n" +
617
618
  "[yellow]The work so far is saved. If you want the agent to keep going, type " +
618
619
  "[bold]continue[/bold] and the next step will resume from where it left off.[/yellow]", "Warning", "", false, "33");
619
- process.stdout.write("\n");
620
620
  if (retryPrompts.length)
621
621
  dropRetryPrompts(conversation, inputItems, retryPrompts);
622
622
  stripOrphanCalls(conversation);
package/dist/tools.js CHANGED
@@ -31,10 +31,19 @@ function truncateDiffLine(line) {
31
31
  }
32
32
  return line;
33
33
  }
34
+ // Diff output is deferred (not written straight to stdout) so it doesn't
35
+ // collide with the tool spinner that is still animating while the tool runs.
36
+ const pendingDiffOutput = [];
37
+ export function flushPendingDiffOutput() {
38
+ if (!pendingDiffOutput.length)
39
+ return;
40
+ process.stdout.write(pendingDiffOutput.join("\n") + "\n\n");
41
+ pendingDiffOutput.length = 0;
42
+ }
34
43
  function displayDiff(pathName, oldContent, newContent) {
35
44
  if (oldContent.length + newContent.length > max_diff_source_chars) {
36
- process.stdout.write(`\x1b[2m${pathName}: ${oldContent.length.toLocaleString()} chars -> ${newContent.length.toLocaleString()} chars ` +
37
- `(diff hidden: exceeds ${max_diff_source_chars.toLocaleString()} char limit)\x1b[0m\n`);
45
+ pendingDiffOutput.push(`\x1b[2m${pathName}: ${oldContent.length.toLocaleString()} chars -> ${newContent.length.toLocaleString()} chars ` +
46
+ `(diff hidden: exceeds ${max_diff_source_chars.toLocaleString()} char limit)\x1b[0m`);
38
47
  return;
39
48
  }
40
49
  const patch = structuredPatch(pathName, pathName, oldContent, newContent, "", "", { context: max_diff_context_lines });
@@ -98,7 +107,7 @@ function displayDiff(pathName, oldContent, newContent) {
98
107
  const header = `\x1b[1m${pathDisp}\x1b[0m \x1b[32m+${added}\x1b[0m \x1b[31m-${removed}\x1b[0m`;
99
108
  const headerStr = `─ ${header} `;
100
109
  const top = `╭${headerStr}${"─".repeat(Math.max(0, boxW - 2 - plainLen(headerStr)))}╮`;
101
- process.stdout.write(`\x1b[90m${top}\x1b[0m\n`);
110
+ pendingDiffOutput.push(`\x1b[90m${top}\x1b[0m`);
102
111
  for (const v of visible) {
103
112
  let line;
104
113
  if (v.kind === "+") {
@@ -114,9 +123,9 @@ function displayDiff(pathName, oldContent, newContent) {
114
123
  line = `\x1b[2m ${body}\x1b[0m`;
115
124
  }
116
125
  const fill = Math.max(0, innerW - plainLen(line));
117
- process.stdout.write(`\x1b[90m│\x1b[0m ${line}${" ".repeat(fill)} \x1b[90m│\x1b[0m\n`);
126
+ pendingDiffOutput.push(`\x1b[90m│\x1b[0m ${line}${" ".repeat(fill)} \x1b[90m│\x1b[0m`);
118
127
  }
119
- process.stdout.write(`\x1b[90m╰${"─".repeat(boxW - 2)}╯\x1b[0m\n`);
128
+ pendingDiffOutput.push(`\x1b[90m╰${"─".repeat(boxW - 2)}╯\x1b[0m`);
120
129
  }
121
130
  export function truncateToolOutput(output, maxChars = max_output_chars) {
122
131
  if (output.length > maxChars) {
package/dist/ui.js CHANGED
@@ -526,21 +526,31 @@ export class Spinner {
526
526
  timer = null;
527
527
  frame = 0;
528
528
  rows = 0;
529
- text = "";
529
+ render = null;
530
530
  enabled;
531
+ lastRendered = "";
532
+ lastLen = 0;
531
533
  constructor(enabled = true) {
532
534
  this.enabled = enabled;
533
535
  }
534
536
  start(text) {
537
+ this.startWithRender(() => text);
538
+ }
539
+ /** Start the spinner with a render callback (recomputed each animation tick),
540
+ * avoiding the need for a second, conflicting timer. */
541
+ startWithRender(render) {
535
542
  if (!this.enabled)
536
543
  return;
537
544
  if (this.timer) {
538
- this.update(text);
545
+ this.render = render;
546
+ this.draw();
539
547
  return;
540
548
  }
541
- this.text = text;
549
+ this.render = render;
542
550
  this.frame = 0;
543
551
  this.rows = 0;
552
+ this.lastRendered = "";
553
+ this.lastLen = 0;
544
554
  this.timer = setInterval(() => {
545
555
  this.frame++;
546
556
  this.draw();
@@ -548,29 +558,49 @@ export class Spinner {
548
558
  this.draw();
549
559
  }
550
560
  update(text) {
551
- this.text = text;
552
- if (this.timer)
561
+ if (this.timer) {
562
+ this.render = () => text;
553
563
  this.draw();
564
+ }
554
565
  }
555
566
  draw() {
567
+ const text = this.render ? this.render() : "";
556
568
  const icon = SPINNER_FRAMES[this.frame % SPINNER_FRAMES.length];
557
- const rendered = `\x1b[36m${icon}\x1b[0m \x1b[2m${this.text}\x1b[0m`;
569
+ const rendered = `\x1b[36m${icon}\x1b[0m \x1b[2m${text}\x1b[0m`;
570
+ // Nothing changed on this tick -> do not rewrite the line (avoids flicker).
571
+ if (rendered === this.lastRendered)
572
+ return;
573
+ this.lastRendered = rendered;
558
574
  const newRows = Math.max(1, displayRows(rendered));
559
- const clearRows = Math.max(this.rows, newRows, 1);
560
- for (let i = 0; i < clearRows; i++) {
561
- process.stdout.write("\r\x1b[2K");
562
- if (i < clearRows - 1)
563
- process.stdout.write("\n");
575
+ if (newRows === 1) {
576
+ // Overwrite in place: NEVER erase the whole line (the \x1b[2K erase-to-blank
577
+ // followed by a full redraw is what caused the flicker/flash). We just
578
+ // re-emit from the start; identical glyphs stay put, only the changed
579
+ // cells (spinner frame, trailing timer) actually change on screen. Pad
580
+ // with spaces so a shorter line clears any previously longer text.
581
+ const plain = stripAnsi(rendered);
582
+ const pad = Math.max(0, this.lastLen - plain.length);
583
+ process.stdout.write("\r" + rendered + " ".repeat(pad) + "\r");
584
+ this.lastLen = plain.length;
585
+ }
586
+ else {
587
+ // Multi-row wrapped text: fall back to an erase-based redraw.
588
+ const clearRows = Math.max(this.rows, newRows, 1);
589
+ for (let i = 0; i < clearRows; i++) {
590
+ process.stdout.write("\r\x1b[2K");
591
+ if (i < clearRows - 1)
592
+ process.stdout.write("\n");
593
+ }
594
+ if (clearRows > 1)
595
+ process.stdout.write(`\x1b[${clearRows - 1}A\r`);
596
+ else
597
+ process.stdout.write("\r");
598
+ process.stdout.write(rendered);
599
+ if (newRows > 1)
600
+ process.stdout.write(`\x1b[${newRows - 1}A\r`);
601
+ else
602
+ process.stdout.write("\r");
564
603
  }
565
- if (clearRows > 1)
566
- process.stdout.write(`\x1b[${clearRows - 1}A\r`);
567
- else
568
- process.stdout.write("\r");
569
- process.stdout.write(rendered);
570
- if (newRows > 1)
571
- process.stdout.write(`\x1b[${newRows - 1}A\r`);
572
- else
573
- process.stdout.write("\r");
574
604
  this.rows = newRows;
575
605
  }
576
606
  stop() {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oxecli/oxe",
3
- "version": "1.0.61",
3
+ "version": "1.0.63",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },