@oxecli/oxe 1.0.6 → 1.0.7

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
@@ -2,6 +2,7 @@ import fs from "node:fs";
2
2
  import os from "node:os";
3
3
  import path from "node:path";
4
4
  import { fileURLToPath } from "node:url";
5
+ import { Spinner } from "./ui.js";
5
6
  // ---------------------------------------------------------------------------
6
7
  // Paths
7
8
  // ---------------------------------------------------------------------------
@@ -162,8 +163,10 @@ export async function loadOrPrompt() {
162
163
  }
163
164
  }
164
165
  process.stdout.write("\n");
165
- process.stdout.write(markupToAnsi("[dim]Authenticating key with Oxe Cloud…[/dim]") + "\n");
166
+ const authSpinner = new Spinner();
167
+ authSpinner.start("Authenticating key with Oxe Cloud…");
166
168
  const validation = await validateOxeApiKey(api_key);
169
+ authSpinner.stop();
167
170
  if (validation.valid) {
168
171
  key_data = validation.key_data || {};
169
172
  process.stdout.write("\n");
package/dist/engine.js CHANGED
@@ -2,7 +2,7 @@ import OpenAI from "openai";
2
2
  import { max_output_tokens, max_empty_retries, max_agent_steps, max_context_tokens, context_overhead_margin, compact_keep_recent_turns, max_summary_source_chars, max_read_file_stored_chars, } from "./config.js";
3
3
  import { SYSTEM_PROMPT } from "./system.js";
4
4
  import { buildTools, truncateToolOutput, TOOL_IMPLEMENTATIONS } from "./tools.js";
5
- import { mutedMarkdown, aiMarkdown, tickDuration, safeCommitPoint, printAiChunk, formatToolAction, } from "./ui.js";
5
+ import { mutedMarkdown, aiMarkdown, tickDuration, displayRows, safeCommitPoint, printAiChunk, formatToolAction, renderPanel, Spinner, hideCursor, showCursor, } from "./ui.js";
6
6
  import { reportUsage } from "./api.js";
7
7
  import { stripOrphanCalls, persistCompactionSummary, toolOutputFailed, } from "./sessions.js";
8
8
  // ---------------------------------------------------------------------------
@@ -35,30 +35,6 @@ const objectId = (() => {
35
35
  };
36
36
  })();
37
37
  // ---------------------------------------------------------------------------
38
- // Simple status line helper
39
- // ---------------------------------------------------------------------------
40
- class Status {
41
- text;
42
- timer = null;
43
- started;
44
- constructor(initial) {
45
- this.text = initial;
46
- this.started = Date.now();
47
- }
48
- start() {
49
- process.stdout.write("\r\x1b[2K" + mutedMarkdown(this.text) + "\n");
50
- }
51
- update(text) {
52
- this.text = text;
53
- process.stdout.write("\r\x1b[2K" + mutedMarkdown(text) + "\x1b[1A");
54
- }
55
- stop() {
56
- if (this.timer)
57
- clearInterval(this.timer);
58
- this.timer = null;
59
- }
60
- }
61
- // ---------------------------------------------------------------------------
62
38
  // InferenceEngine
63
39
  // ---------------------------------------------------------------------------
64
40
  export class InferenceEngine {
@@ -66,6 +42,10 @@ export class InferenceEngine {
66
42
  reasoningEffort;
67
43
  keyData;
68
44
  client;
45
+ // "Worked for Xs" line is overwritten in place across tool iterations
46
+ // (mirrors Python's _work_active / _overwrite_work_line).
47
+ workActive = false;
48
+ workRows = 2;
69
49
  inQuery = false;
70
50
  temperature;
71
51
  storedResponseIds = [];
@@ -137,28 +117,70 @@ export class InferenceEngine {
137
117
  incompleteReason(response) {
138
118
  return response?.incomplete_details?.reason ?? null;
139
119
  }
120
+ /** Replace the previously-printed "Worked for Xs" line in place. */
121
+ overwriteWorkLine(text) {
122
+ for (let i = 0; i < this.workRows; i++) {
123
+ process.stdout.write("\x1b[1A\x1b[2K");
124
+ }
125
+ process.stdout.write(mutedMarkdown(text) + "\n\n");
126
+ this.workRows = displayRows(text) + 1;
127
+ }
140
128
  async streamOnce(inputItems, stats, story, previousResponseId) {
141
129
  let content = "";
142
130
  let committedLen = 0;
143
131
  let pending = "";
144
132
  let sawReasoning = false;
145
133
  let status = null;
146
- const thinkingStart = Date.now();
147
- const workStatus = new Status("Working for `0s`");
148
- workStatus.start();
134
+ let thinkingStart = null;
135
+ const workStatus = new Spinner();
136
+ const workingStarted = Date.now();
137
+ workStatus.start("Working for `0s`");
138
+ const workTimer = setInterval(() => {
139
+ workStatus.update(`Working for ${tickDuration((Date.now() - workingStarted) / 1000)}`);
140
+ }, 500);
141
+ let workingReported = false;
149
142
  let response = null;
150
143
  let etype = null;
144
+ const silenceWorking = () => {
145
+ clearInterval(workTimer);
146
+ workStatus.stop();
147
+ // Reasoning took over; suppress any later "Worked for Xs" report for this
148
+ // stream (mirrors Python's working_started = None in silence_working()).
149
+ workingReported = true;
150
+ };
151
+ const reportWorking = () => {
152
+ clearInterval(workTimer);
153
+ workStatus.stop();
154
+ if (workingReported)
155
+ return;
156
+ workingReported = true;
157
+ const elapsed = (Date.now() - workingStarted) / 1000;
158
+ const t = tickDuration(elapsed);
159
+ story.push({ type: "worked", text: `Worked for ${t}` });
160
+ if (this.workActive) {
161
+ this.overwriteWorkLine(`Worked for ${t}`);
162
+ }
163
+ else {
164
+ process.stdout.write(mutedMarkdown(`Worked for ${t}`) + "\n\n");
165
+ this.workActive = true;
166
+ this.workRows = displayRows(`Worked for ${t}`) + 1;
167
+ }
168
+ };
151
169
  const finishThinking = (report) => {
170
+ if (thinkingStart === null)
171
+ return;
172
+ const elapsed = (Date.now() - thinkingStart) / 1000;
152
173
  if (status) {
153
174
  status.stop();
154
175
  status = null;
155
176
  }
156
177
  if (report) {
157
- const elapsed = (Date.now() - thinkingStart) / 1000;
158
178
  const t = tickDuration(elapsed);
159
179
  story.push({ type: "thought", text: `Thought for ${t}` });
160
- process.stdout.write("\n" + mutedMarkdown(`Thought for ${t}`) + "\n");
180
+ process.stdout.write(mutedMarkdown(`Thought for ${t}`) + "\n\n");
181
+ this.workActive = false;
161
182
  }
183
+ thinkingStart = null;
162
184
  };
163
185
  try {
164
186
  const kwargs = {
@@ -190,13 +212,14 @@ export class InferenceEngine {
190
212
  if (etype === "response.reasoning_text.delta" ||
191
213
  etype === "response.reasoning.summary.delta") {
192
214
  sawReasoning = true;
193
- if (!status) {
194
- status = new Status("Thinking for `0s`");
195
- status.start();
215
+ silenceWorking();
216
+ if (thinkingStart === null) {
217
+ thinkingStart = Date.now();
218
+ status = new Spinner();
219
+ status.start("Thinking for `0s`");
196
220
  }
197
221
  else {
198
- const elapsed = (Date.now() - thinkingStart) / 1000;
199
- status.update(`Thinking for ${tickDuration(elapsed)}`);
222
+ status?.update(`Thinking for ${tickDuration((Date.now() - thinkingStart) / 1000)}`);
200
223
  }
201
224
  }
202
225
  else if (etype === "response.reasoning_text.done" ||
@@ -204,6 +227,7 @@ export class InferenceEngine {
204
227
  finishThinking(true);
205
228
  }
206
229
  else if (etype === "response.output_text.delta") {
230
+ reportWorking();
207
231
  finishThinking(true);
208
232
  content += event.delta;
209
233
  pending += event.delta;
@@ -224,8 +248,13 @@ export class InferenceEngine {
224
248
  }
225
249
  }
226
250
  finally {
251
+ clearInterval(workTimer);
227
252
  workStatus.stop();
228
- finishThinking(true);
253
+ // If the stream ended while still reasoning (no *.done event), still report
254
+ // the elapsed "Thought for Xs" (mirrors Python's `still_thinking` handling).
255
+ const stillThinking = thinkingStart !== null;
256
+ finishThinking(stillThinking);
257
+ reportWorking();
229
258
  if (content.slice(committedLen))
230
259
  printAiChunk(content.slice(committedLen));
231
260
  }
@@ -332,13 +361,17 @@ export class InferenceEngine {
332
361
  if (!this.contextOverBudget(conversation))
333
362
  return [conversation, false];
334
363
  const started = Date.now();
335
- const comp = new Status("Compacting conversation for `0s`");
336
- comp.start();
364
+ const comp = new Spinner();
365
+ comp.start("Compacting conversation for `0s`");
366
+ const compTimer = setInterval(() => {
367
+ comp.update(`Compacting conversation for ${tickDuration((Date.now() - started) / 1000)}`);
368
+ }, 500);
337
369
  let compacted;
338
370
  try {
339
371
  compacted = await this.compactHistory(conversation);
340
372
  }
341
373
  finally {
374
+ clearInterval(compTimer);
342
375
  comp.stop();
343
376
  }
344
377
  if (!compacted)
@@ -346,11 +379,14 @@ export class InferenceEngine {
346
379
  persistCompactionSummary(inputItems, compacted[0]);
347
380
  const text = `Compacted conversation in ${tickDuration((Date.now() - started) / 1000)}`;
348
381
  story.push({ type: "compacted", text });
349
- process.stdout.write("\n" + mutedMarkdown(text) + "\n");
382
+ this.workActive = false;
383
+ process.stdout.write(mutedMarkdown(text) + "\n");
384
+ process.stdout.write("\n");
350
385
  return [compacted, true];
351
386
  }
352
387
  async executeQuery(userPrompt, inputItems, story, pasteSpans) {
353
388
  this.inQuery = true;
389
+ hideCursor();
354
390
  inputItems.push({
355
391
  role: "user",
356
392
  content: userPrompt,
@@ -384,7 +420,6 @@ export class InferenceEngine {
384
420
  };
385
421
  let prevId = null;
386
422
  let pending = conversation;
387
- let resetInQuery = true;
388
423
  try {
389
424
  for (let step = 0; step < max_agent_steps; step++) {
390
425
  let text = "";
@@ -418,13 +453,11 @@ export class InferenceEngine {
418
453
  respId = res.responseId;
419
454
  }
420
455
  catch (err2) {
421
- process.stdout.write("\n");
422
456
  renderErrorPanel(`API Error: ${err2}`);
423
457
  return;
424
458
  }
425
459
  }
426
460
  else {
427
- process.stdout.write("\n");
428
461
  renderErrorPanel(`API Error: ${err}`);
429
462
  return;
430
463
  }
@@ -458,7 +491,7 @@ export class InferenceEngine {
458
491
  continue;
459
492
  }
460
493
  dropRetryPrompts(conversation, inputItems, retryPrompts);
461
- process.stdout.write("\n" + aiMarkdown(emptyMessage) + "\n");
494
+ process.stdout.write(aiMarkdown(emptyMessage) + "\n");
462
495
  process.stdout.write("\n" + summary() + "\n");
463
496
  return;
464
497
  }
@@ -472,6 +505,7 @@ export class InferenceEngine {
472
505
  if (text.trim())
473
506
  process.stdout.write("\n");
474
507
  for (const c of calls) {
508
+ this.workActive = false;
475
509
  const started = formatToolAction(c.name, c.arguments, "started");
476
510
  process.stdout.write(`\x1b[2m${started}\x1b[0m\n`);
477
511
  if (c.name === "edit_file" || c.name === "write_file")
@@ -496,9 +530,11 @@ export class InferenceEngine {
496
530
  pending.push(outputItem);
497
531
  }
498
532
  }
499
- process.stdout.write("\n\x1b[1;33m⚠ Max tool-call iterations reached for this turn.\x1b[0m\n" +
500
- "\x1b[33mThe work so far is saved. If you want the agent to keep going, type " +
501
- "[bold]continue[/bold] and the next step will resume from where it left off.\x1b[0m\n");
533
+ process.stdout.write("\n");
534
+ renderPanel("[bold yellow]⚠ Max tool-call iterations reached for this turn.[/bold yellow]\n" +
535
+ "[yellow]The work so far is saved. If you want the agent to keep going, type " +
536
+ "[bold]continue[/bold] and the next step will resume from where it left off.[/yellow]", "Warning", "", false, "33");
537
+ process.stdout.write("\n");
502
538
  if (retryPrompts.length)
503
539
  dropRetryPrompts(conversation, inputItems, retryPrompts);
504
540
  stripOrphanCalls(conversation);
@@ -508,11 +544,11 @@ export class InferenceEngine {
508
544
  if (err?.message === "interrupt" || err?.message === "eof") {
509
545
  throw err;
510
546
  }
511
- process.stdout.write("\n");
512
547
  renderErrorPanel(`Runtime Exception: ${err}`);
513
548
  }
514
549
  finally {
515
550
  this.inQuery = false;
551
+ showCursor();
516
552
  if (stats["tokens"] > 0 && this.keyData) {
517
553
  const userId = this.keyData["user_id"];
518
554
  if (userId) {
@@ -531,6 +567,7 @@ export class InferenceEngine {
531
567
  }
532
568
  function renderErrorPanel(msg) {
533
569
  const text = msg.replace(/\[bold yellow\]|\[yellow\]|\[\/.*?\]/g, "");
534
- process.stdout.write(`\x1b[31m${text}\x1b[0m\n`);
570
+ renderPanel(text, "Error", "", true, "31");
571
+ process.stdout.write("\n");
535
572
  }
536
573
  export { renderErrorPanel };
package/dist/ui.js CHANGED
@@ -210,8 +210,14 @@ export function tickDuration(seconds) {
210
210
  // ---------------------------------------------------------------------------
211
211
  const FENCE_MARKER_RE = /`{3,}/g;
212
212
  export function printAiChunk(chunk) {
213
- process.stdout.write(markdownToAnsi(chunk) + "\n");
214
- if (/^```/m.test(chunk))
213
+ // rich's Markdown renderer ignores leading blank lines; a commit boundary in
214
+ // "…\n\n…" text can leave the second chunk starting with "\n", so strip it to
215
+ // avoid double-printing a blank row (mirrors the original's output).
216
+ const trimmed = chunk.replace(/^\n+/, "");
217
+ if (!trimmed)
218
+ return;
219
+ process.stdout.write(markdownToAnsi(trimmed) + "\n");
220
+ if (/^```/m.test(trimmed))
215
221
  process.stdout.write("\n");
216
222
  }
217
223
  export function safeCommitPoint(text) {
@@ -233,7 +239,7 @@ export function truncateEllipsis(text, maxChars, label = "text") {
233
239
  export function displayRows(text) {
234
240
  if (!text)
235
241
  return 1;
236
- const plain = text.replace(/`/g, "");
242
+ const plain = text.replace(/\x1b\[[0-9;]*m/g, "").replace(/`/g, "");
237
243
  const width = Math.max(terminalWidth() || 80, 1);
238
244
  let rows = 0;
239
245
  const lines = plain.split("\n");
@@ -245,6 +251,92 @@ export function displayRows(text) {
245
251
  return rows;
246
252
  }
247
253
  // ---------------------------------------------------------------------------
254
+ // Cursor + spinner helpers (mirror rich's console.status spinner="dots")
255
+ // ---------------------------------------------------------------------------
256
+ let cursorHidden = false;
257
+ export function hideCursor() {
258
+ if (!cursorHidden) {
259
+ process.stdout.write("\x1b[?25l");
260
+ cursorHidden = true;
261
+ }
262
+ }
263
+ export function showCursor() {
264
+ if (cursorHidden) {
265
+ process.stdout.write("\x1b[?25h");
266
+ cursorHidden = false;
267
+ }
268
+ }
269
+ const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
270
+ /**
271
+ * A transient status line with an animated dot spinner, updated in place
272
+ * (no new lines appended on every tick). On stop() the line is erased.
273
+ * Mirrors rich's console.status(..., spinner="dots").
274
+ */
275
+ export class Spinner {
276
+ timer = null;
277
+ frame = 0;
278
+ rows = 0;
279
+ text = "";
280
+ enabled;
281
+ constructor(enabled = true) {
282
+ this.enabled = enabled;
283
+ }
284
+ start(text) {
285
+ if (!this.enabled)
286
+ return;
287
+ if (this.timer) {
288
+ this.update(text);
289
+ return;
290
+ }
291
+ this.text = text;
292
+ this.frame = 0;
293
+ this.rows = 0;
294
+ this.timer = setInterval(() => {
295
+ this.frame++;
296
+ this.draw();
297
+ }, 120);
298
+ this.draw();
299
+ }
300
+ update(text) {
301
+ this.text = text;
302
+ if (this.timer)
303
+ this.draw();
304
+ }
305
+ draw() {
306
+ const rendered = mutedMarkdown(`${SPINNER_FRAMES[this.frame % SPINNER_FRAMES.length]} ${this.text}`);
307
+ const newRows = Math.max(1, displayRows(rendered));
308
+ const clearRows = Math.max(this.rows, newRows, 1);
309
+ if (this.rows > 0)
310
+ process.stdout.write(`\x1b[${this.rows}A`);
311
+ for (let i = 0; i < clearRows; i++) {
312
+ process.stdout.write("\r\x1b[2K");
313
+ if (i < clearRows - 1)
314
+ process.stdout.write("\n");
315
+ }
316
+ if (clearRows > 0)
317
+ process.stdout.write(`\x1b[${clearRows - 1}A\r`);
318
+ process.stdout.write(rendered);
319
+ this.rows = newRows;
320
+ }
321
+ stop() {
322
+ if (this.timer) {
323
+ clearInterval(this.timer);
324
+ this.timer = null;
325
+ }
326
+ if (this.enabled && this.rows > 0) {
327
+ process.stdout.write(`\x1b[${this.rows}A`);
328
+ for (let i = 0; i < this.rows; i++) {
329
+ process.stdout.write("\r\x1b[2K");
330
+ if (i < this.rows - 1)
331
+ process.stdout.write("\n");
332
+ }
333
+ if (this.rows > 0)
334
+ process.stdout.write(`\x1b[${this.rows - 1}A`);
335
+ }
336
+ this.rows = 0;
337
+ }
338
+ }
339
+ // ---------------------------------------------------------------------------
248
340
  // Tool action formatter
249
341
  // ---------------------------------------------------------------------------
250
342
  export function formatToolAction(name, argumentsJson, status = "ok") {
@@ -481,6 +573,7 @@ export function askBottomPrompt(label = "You", prefix = "❯", history = []) {
481
573
  if (process.stdin.isTTY)
482
574
  process.stdin.setRawMode(true);
483
575
  process.stdin.resume();
576
+ hideCursor();
484
577
  let done = false;
485
578
  let lastCursorRow = 0;
486
579
  let lastTotalRows = 0;
@@ -489,19 +582,22 @@ export function askBottomPrompt(label = "You", prefix = "❯", history = []) {
489
582
  return;
490
583
  done = true;
491
584
  process.stdin.setRawMode(false);
492
- // Erase the whole prompt box (which occupies `lastTotalRows` lines) so no
493
- // frame borders are left behind, then leave the cursor on the box's top
494
- // row (a blank line). That way the caller's single leading newline yields
495
- // exactly ONE blank row before the echoed user prompt never two.
585
+ showCursor();
586
+ // Erase the prompt region: a leading blank line + the box. Cursor sits at
587
+ // lastCursorRow inside the box; move up to the box's top border, erase the
588
+ // box rows, then erase the leading blank line, leaving the cursor on that
589
+ // blank row. The caller's single leading newline then yields exactly ONE
590
+ // blank row before the echoed user prompt.
496
591
  process.stdout.write(`\x1b[${lastCursorRow}A`);
497
592
  for (let i = 0; i < lastTotalRows; i++) {
498
593
  process.stdout.write("\r\x1b[K");
499
594
  if (i < lastTotalRows - 1)
500
595
  process.stdout.write("\n");
501
596
  }
502
- // Cursor is now on the last box row; move back up to the box's top row.
503
597
  if (lastTotalRows > 1)
504
598
  process.stdout.write(`\x1b[${lastTotalRows - 1}A`);
599
+ // Clear the leading blank line above the box.
600
+ process.stdout.write("\x1b[1A\r\x1b[K");
505
601
  resolve(resolveVal);
506
602
  };
507
603
  const repaint = (isFirst = false) => {
@@ -509,7 +605,11 @@ export function askBottomPrompt(label = "You", prefix = "❯", history = []) {
509
605
  lastCursorRow = cursorRow;
510
606
  lastTotalRows = totalRows;
511
607
  const frameLines = frame.split("\n");
512
- if (!isFirst) {
608
+ if (isFirst) {
609
+ // A blank line above the box (mirrors the original's leading Text("")).
610
+ process.stdout.write("\n");
611
+ }
612
+ else {
513
613
  // Cursor currently sits at cursorRow (inside the box). Move to the top
514
614
  // border, then clear+rewrite each line so old content is fully removed.
515
615
  process.stdout.write(`\x1b[${cursorRow}A`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oxecli/oxe",
3
- "version": "1.0.6",
3
+ "version": "1.0.7",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },