@oxecli/oxe 1.0.60 → 1.0.62

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
@@ -183,11 +183,14 @@ function highlightCodeLine(line, _lang = "") {
183
183
  }
184
184
  function formatInlineMarkdown(text) {
185
185
  let line = text;
186
+ // Links MUST be converted first: the other rules inject ANSI escape codes,
187
+ // and the link pattern `\[[^\]]+\]\([^)]+\)` would otherwise match the `[`
188
+ // inside an already-inserted escape (e.g. `\x1b[1m`) and corrupt the output.
189
+ line = line.replace(/\[([^\]]+)\]\(([^)]+)\)/g, "\x1b[4;36m$1\x1b[0m \x1b[2m($2)\x1b[0m");
186
190
  line = line.replace(/\*\*\*([^*]+)\*\*\*/g, "\x1b[1;3m$1\x1b[0m");
187
191
  line = line.replace(/\*\*([^*]+)\*\*/g, "\x1b[1m$1\x1b[0m");
188
192
  line = line.replace(/(^|[^\w*])\*([^*\n]+)\*(?=$|[^\w*])/g, "$1\x1b[3m$2\x1b[0m");
189
193
  line = line.replace(/`([^`]+)`/g, "\x1b[1;36m`$1`\x1b[0m");
190
- line = line.replace(/\[([^\]]+)\]\(([^)]+)\)/g, "\x1b[4;36m$1\x1b[0m \x1b[2m($2)\x1b[0m");
191
194
  return line;
192
195
  }
193
196
  // ---------------------------------------------------------------------------
@@ -251,12 +254,12 @@ export function markdownToAnsi(text) {
251
254
  return;
252
255
  const badge = codeLang ? ` \x1b[1;36m${codeLang}\x1b[0m ` : " ";
253
256
  const barLen = Math.max(10, Math.min(width - plainLen(badge) - 4, 60));
254
- out.push(`\x1b[90m┌─${badge}${"─".repeat(barLen)}┐\x1b[0m`);
257
+ out.push(`\x1b[90m╭─${badge}${"─".repeat(barLen)}╮\x1b[0m`);
255
258
  for (const cline of codeBuffer) {
256
259
  const highlighted = highlightCodeLine(cline, codeLang);
257
260
  out.push(`\x1b[90m│\x1b[0m ${highlighted}`);
258
261
  }
259
- out.push(`\x1b[90m└${"─".repeat(barLen + plainLen(badge) + 2)}┘\x1b[0m`);
262
+ out.push(`\x1b[90m╰${"─".repeat(barLen + plainLen(badge) + 2)}╯\x1b[0m`);
260
263
  codeBuffer = [];
261
264
  codeLang = "";
262
265
  };
@@ -523,21 +526,29 @@ export class Spinner {
523
526
  timer = null;
524
527
  frame = 0;
525
528
  rows = 0;
526
- text = "";
529
+ render = null;
527
530
  enabled;
531
+ lastRendered = "";
528
532
  constructor(enabled = true) {
529
533
  this.enabled = enabled;
530
534
  }
531
535
  start(text) {
536
+ this.startWithRender(() => text);
537
+ }
538
+ /** Start the spinner with a render callback (recomputed each animation tick),
539
+ * avoiding the need for a second, conflicting timer. */
540
+ startWithRender(render) {
532
541
  if (!this.enabled)
533
542
  return;
534
543
  if (this.timer) {
535
- this.update(text);
544
+ this.render = render;
545
+ this.draw();
536
546
  return;
537
547
  }
538
- this.text = text;
548
+ this.render = render;
539
549
  this.frame = 0;
540
550
  this.rows = 0;
551
+ this.lastRendered = "";
541
552
  this.timer = setInterval(() => {
542
553
  this.frame++;
543
554
  this.draw();
@@ -545,13 +556,19 @@ export class Spinner {
545
556
  this.draw();
546
557
  }
547
558
  update(text) {
548
- this.text = text;
549
- if (this.timer)
559
+ if (this.timer) {
560
+ this.render = () => text;
550
561
  this.draw();
562
+ }
551
563
  }
552
564
  draw() {
565
+ const text = this.render ? this.render() : "";
553
566
  const icon = SPINNER_FRAMES[this.frame % SPINNER_FRAMES.length];
554
- const rendered = `\x1b[36m${icon}\x1b[0m \x1b[2m${this.text}\x1b[0m`;
567
+ const rendered = `\x1b[36m${icon}\x1b[0m \x1b[2m${text}\x1b[0m`;
568
+ // Nothing changed on this tick -> do not rewrite the line (avoids flicker).
569
+ if (rendered === this.lastRendered)
570
+ return;
571
+ this.lastRendered = rendered;
555
572
  const newRows = Math.max(1, displayRows(rendered));
556
573
  const clearRows = Math.max(this.rows, newRows, 1);
557
574
  for (let i = 0; i < clearRows; i++) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oxecli/oxe",
3
- "version": "1.0.60",
3
+ "version": "1.0.62",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },